iachat/docs/superpowers/plans/2026-04-15-jornada-do-cliente-ui.md
Rodribm10 cfffea9c16 feat(captain): semantic memory fixes + roleta + reclamações + analytics
Consolida o trabalho desta branch de abril/2026 em um bloco pronto pra
testar em staging antes do merge pra main.

## Correções de memória semântica
- ExtractionService: Princípio Zero + Regra de Ouro (ação consumada vs intenção).
- Cenário Daniela_Reservas: Passo 0 de classificação (consulta/intenção/fora).

## Roleta da Sorte (end-to-end)
- Schema Supabase + 7 RPCs atômicas (server-side, idempotentes).
- Services: Offer, Redeem, WeeklyReport.
- Jobs: OfferRouletteJob (hook em ConfirmationService após Pix pago),
  NotifyRevealed + Scheduler de fallback.
- Tool manual GenerateRoletaLinkTool + endpoint público /roleta/notify.
- Dashboard /captain/roleta com Resgate + Relatório + anomaly detection.

## Cenário Reclamacoes_Ouvidoria
- Triagem P1-P4, framework LAST, Three-level listening, Self-check.
- Sem compensação material, detecção de cliente frustrado eleva prioridade.

## Analytics
- Funil de conversão /captain/funnel: 5 etapas via regex, zero LLM.
- Detector de churn via ChurnOutreach* (cron dias úteis 10h-17h BRT).

## Trabalho pré-existente incluído
- Captain Executive Reports (ceo_digest, mattermost_delivery).
- get_reserva_preco_tool, Lifecycle ajustes, Reservations UI polimentos.

## Outros
- .gitignore: patterns pra credenciais.
- Migrations de scenarios idempotentes.
- i18n completa pt_BR+en pra roleta/funnel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:36:25 -03:00

2804 lines
97 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Jornada do Cliente — UI (Fase B) Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Entregar a aba "Jornada do Cliente" no menu Captain — 3 tabs (Regras, Configurações, Histórico) — consumindo o backend de lifecycle automation já shippado em `enterprise/app/models/captain/lifecycle/` e `enterprise/app/services/captain/lifecycle/`.
**Architecture:** REST endpoints novos em `enterprise/app/controllers/api/v1/accounts/captain/` (3 controllers + extensão do `UnitsController` pra concierge config), jbuilder views, Pundit policies, clientes API Vue + Pinia stores via `storeFactory`, rota-pai com `TabBar` + 3 children routes, páginas Vue em `components-next` seguindo o padrão de `captain/reservations`. Multilíngue obrigatório (en + pt_BR).
**Tech Stack:** Rails 7.1 + Pundit + jbuilder, Vue 3 `<script setup>` + Pinia (via `storeFactory`), `ApiClient` base, `TabBar.vue`, `PageLayout.vue`, `Input/TextArea/Button/Checkbox` de `components-next`, vue-i18n, RSpec request specs, Vitest.
---
## Pré-requisitos de contexto
Antes de começar, **leia** estes 3 arquivos:
- `docs/superpowers/specs/2026-04-15-jornada-do-cliente-design.md` — seção 9 é o escopo; seções 4, 5 e 8 explicam domínio (eventos, variáveis Liquid, schema)
- `docs/superpowers/plans/2026-04-15-jornada-do-cliente-backend-handoff.md` — mapa do que o backend já entregou + 9 gotchas
- `CLAUDE.md` da raiz — convenção `fazer.ai` minúsculo, branding, pnpm obrigatório
**Gotchas relevantes pra UI** (do handoff):
1. Reservation hook `after_create_commit :schedule_lifecycle_rules` dispara ao criar reserva — specs que testam endpoint de deliveries podem criar deliveries "fantasma". Stubbar `Captain::Lifecycle::Scheduler.schedule_for` nos specs ou criar delivery diretamente via `Delivery.create!`.
2. `belongs_to` top-level dentro de `Captain::Lifecycle::*` precisa `class_name: '::Conversation'` etc — já resolvido nos models, só não refatorar.
3. `captain_unit` factory aceita `create(:captain_unit)` direto (fix em `325f05c3e`).
4. `Captain::Reservation` usa associação `unit:`, não `captain_unit:`.
---
## Estrutura de arquivos
**Backend (novos):**
| Arquivo | Responsabilidade |
|---|---|
| `app/models/account.rb` | Adicionar 3 `has_many` pras novas tabelas lifecycle |
| `enterprise/app/policies/captain/lifecycle/rule_policy.rb` | Pundit — admin pode CRUD, agent só read |
| `enterprise/app/policies/captain/lifecycle/config_policy.rb` | Pundit — só admin |
| `enterprise/app/policies/captain/lifecycle/delivery_policy.rb` | Pundit — admin + agent podem ler |
| `config/routes.rb` | 3 resources dentro do `namespace :captain` |
| `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_rules_controller.rb` | CRUD de regras |
| `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_configs_controller.rb` | `show`/`update` do singleton `Config.for_account` |
| `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller.rb` | `index` paginada + `show` com `rendered_body` |
| `enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb` | Adicionar action `update_concierge` + permit de `concierge_inbox_id` e `concierge_config` |
| `enterprise/app/views/api/v1/models/captain/_lifecycle_rule.json.jbuilder` | Partial |
| `enterprise/app/views/api/v1/models/captain/_lifecycle_config.json.jbuilder` | Partial |
| `enterprise/app/views/api/v1/models/captain/_lifecycle_delivery.json.jbuilder` | Partial |
| `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/{index,show,create,update}.json.jbuilder` | Templates |
| `enterprise/app/views/api/v1/accounts/captain/lifecycle_configs/{show,update}.json.jbuilder` | Templates |
| `enterprise/app/views/api/v1/accounts/captain/lifecycle_deliveries/{index,show}.json.jbuilder` | Templates |
**Frontend (novos):**
| Arquivo | Responsabilidade |
|---|---|
| `app/javascript/dashboard/api/captain/lifecycleRules.js` | Cliente REST |
| `app/javascript/dashboard/api/captain/lifecycleConfig.js` | Cliente REST (singleton) |
| `app/javascript/dashboard/api/captain/lifecycleDeliveries.js` | Cliente REST |
| `app/javascript/dashboard/store/captain/lifecycleRules.js` | Pinia store via factory |
| `app/javascript/dashboard/store/captain/lifecycleConfig.js` | Pinia store (singleton, get/update manuais) |
| `app/javascript/dashboard/store/captain/lifecycleDeliveries.js` | Pinia store via factory |
| `app/javascript/dashboard/store/index.js` | Registrar 3 novos módulos |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Index.vue` | Pai com `TabBar` + `<router-view />` |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Rules.vue` | Tab 1 — lista + templates + botão "Nova regra" |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Settings.vue` | Tab 2 — guards + Sofia por unidade |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/History.vue` | Tab 3 — tabela paginada + modal preview |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/RuleWizardDialog.vue` | Wizard 4 passos criar/editar regra |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/MessageEditor.vue` | Textarea + autocomplete de variáveis + botões |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/ConciergeUnitCard.vue` | Card expansível por unidade dentro de Settings |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/DeliveryPreviewModal.vue` | Modal com `rendered_body` |
| `app/javascript/dashboard/routes/dashboard/captain/lifecycle/constants.js` | `EVENTS`, `MESSAGE_TYPES`, templates prontos |
| `app/javascript/dashboard/routes/dashboard/captain/captain.routes.js` | Adicionar 4 rotas (1 pai + 3 filhas) |
| `app/javascript/dashboard/components-next/sidebar/Sidebar.vue` | Entrada nova na seção Captain |
| `app/javascript/dashboard/i18n/locale/en/captain.json` | Chaves `CAPTAIN_LIFECYCLE.*` |
| `app/javascript/dashboard/i18n/locale/pt_BR/captain.json` | Idem |
| `app/javascript/dashboard/i18n/locale/en/settings.json` | `SIDEBAR.CAPTAIN_LIFECYCLE` |
| `app/javascript/dashboard/i18n/locale/pt_BR/settings.json` | Idem |
---
## Task 1: Associações no Account model
**Files:**
- Modify: `app/models/account.rb` (linha ~99, depois de `captain_reservations`)
- [ ] **Step 1: Escrever spec falhando**
Append ao final de `spec/models/account_spec.rb`:
```ruby
describe 'captain lifecycle associations' do
it { is_expected.to have_many(:captain_lifecycle_rules).class_name('Captain::Lifecycle::Rule') }
it { is_expected.to have_many(:captain_lifecycle_deliveries).class_name('Captain::Lifecycle::Delivery') }
it { is_expected.to have_one(:captain_lifecycle_config).class_name('Captain::Lifecycle::Config') }
end
```
- [ ] **Step 2: Rodar e ver falhar**
```bash
eval "$(rbenv init - zsh)" && rbenv shell 3.4.4 && bundle exec rspec spec/models/account_spec.rb -e "captain lifecycle associations"
```
Esperado: FAIL com `expected Account to have many captain_lifecycle_rules`.
- [ ] **Step 3: Implementar**
Em `app/models/account.rb`, logo após a linha `has_many :captain_reservations, ...`:
```ruby
has_many :captain_lifecycle_rules, class_name: 'Captain::Lifecycle::Rule', dependent: :destroy
has_many :captain_lifecycle_deliveries, class_name: 'Captain::Lifecycle::Delivery', dependent: :destroy
has_one :captain_lifecycle_config, class_name: 'Captain::Lifecycle::Config', dependent: :destroy
```
- [ ] **Step 4: Rodar e ver passar**
```bash
bundle exec rspec spec/models/account_spec.rb -e "captain lifecycle associations"
```
Esperado: PASS.
- [ ] **Step 5: Commit**
```bash
git add app/models/account.rb spec/models/account_spec.rb
git commit -m "feat(lifecycle): add Account associations for lifecycle models"
```
---
## Task 2: Pundit policies
**Files:**
- Create: `enterprise/app/policies/captain/lifecycle/rule_policy.rb`
- Create: `enterprise/app/policies/captain/lifecycle/config_policy.rb`
- Create: `enterprise/app/policies/captain/lifecycle/delivery_policy.rb`
- Create: `spec/enterprise/policies/captain/lifecycle/rule_policy_spec.rb`
- [ ] **Step 1: Spec falhando**
Criar `spec/enterprise/policies/captain/lifecycle/rule_policy_spec.rb`:
```ruby
require 'rails_helper'
RSpec.describe Captain::Lifecycle::RulePolicy do
subject { described_class }
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:admin_context) { { user: admin, account: account, account_user: admin.account_users.first } }
let(:agent_context) { { user: agent, account: account, account_user: agent.account_users.first } }
let(:record) { Captain::Lifecycle::Rule.new(account: account) }
permissions :index?, :show? do
it { is_expected.to permit(admin_context, record) }
it { is_expected.to permit(agent_context, record) }
end
permissions :create?, :update?, :destroy? do
it { is_expected.to permit(admin_context, record) }
it { is_expected.not_to permit(agent_context, record) }
end
end
```
- [ ] **Step 2: Rodar e falhar**
```bash
bundle exec rspec spec/enterprise/policies/captain/lifecycle/rule_policy_spec.rb
```
Esperado: FAIL `uninitialized constant Captain::Lifecycle::RulePolicy`.
- [ ] **Step 3: Implementar as 3 policies**
Criar `enterprise/app/policies/captain/lifecycle/rule_policy.rb`:
```ruby
class Captain::Lifecycle::RulePolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
def create?
@account_user.administrator?
end
def update?
@account_user.administrator?
end
def destroy?
@account_user.administrator?
end
end
```
Criar `enterprise/app/policies/captain/lifecycle/config_policy.rb`:
```ruby
class Captain::Lifecycle::ConfigPolicy < ApplicationPolicy
def show?
true
end
def update?
@account_user.administrator?
end
end
```
Criar `enterprise/app/policies/captain/lifecycle/delivery_policy.rb`:
```ruby
class Captain::Lifecycle::DeliveryPolicy < ApplicationPolicy
def index?
true
end
def show?
true
end
end
```
- [ ] **Step 4: Rodar e passar**
```bash
bundle exec rspec spec/enterprise/policies/captain/lifecycle/rule_policy_spec.rb
```
Esperado: PASS (5 examples).
- [ ] **Step 5: Commit**
```bash
git add enterprise/app/policies/captain/lifecycle spec/enterprise/policies/captain/lifecycle
git commit -m "feat(lifecycle): add Pundit policies for rule/config/delivery"
```
---
## Task 3: Rotas REST
**Files:**
- Modify: `config/routes.rb` (dentro do `namespace :captain`, linha ~60)
- [ ] **Step 1: Spec falhando**
Criar `spec/routing/captain_lifecycle_routes_spec.rb`:
```ruby
require 'rails_helper'
RSpec.describe 'Captain lifecycle routes', type: :routing do
it 'routes GET /api/v1/accounts/1/captain/lifecycle_rules' do
expect(get: '/api/v1/accounts/1/captain/lifecycle_rules')
.to route_to(controller: 'api/v1/accounts/captain/lifecycle_rules', action: 'index', account_id: '1')
end
it 'routes GET /api/v1/accounts/1/captain/lifecycle_config' do
expect(get: '/api/v1/accounts/1/captain/lifecycle_config')
.to route_to(controller: 'api/v1/accounts/captain/lifecycle_configs', action: 'show', account_id: '1')
end
it 'routes GET /api/v1/accounts/1/captain/lifecycle_deliveries' do
expect(get: '/api/v1/accounts/1/captain/lifecycle_deliveries')
.to route_to(controller: 'api/v1/accounts/captain/lifecycle_deliveries', action: 'index', account_id: '1')
end
it 'routes PATCH /api/v1/accounts/1/captain/units/5/concierge' do
expect(patch: '/api/v1/accounts/1/captain/units/5/concierge')
.to route_to(controller: 'api/v1/accounts/captain/units', action: 'update_concierge', account_id: '1', id: '5')
end
end
```
- [ ] **Step 2: Rodar e falhar**
```bash
bundle exec rspec spec/routing/captain_lifecycle_routes_spec.rb
```
Esperado: FAIL (nenhuma das rotas existe).
- [ ] **Step 3: Adicionar rotas**
Em `config/routes.rb`, dentro do `namespace :captain` (procure `resources :units`), adicionar logo antes de `resources :units`:
```ruby
resources :lifecycle_rules
resource :lifecycle_config, only: [:show, :update], controller: 'lifecycle_configs'
resources :lifecycle_deliveries, only: [:index, :show]
```
E alterar `resources :units` pra:
```ruby
resources :units do
member do
patch :concierge, action: :update_concierge
end
end
```
- [ ] **Step 4: Rodar e passar**
```bash
bundle exec rspec spec/routing/captain_lifecycle_routes_spec.rb
```
Esperado: PASS (4 examples).
- [ ] **Step 5: Commit**
```bash
git add config/routes.rb spec/routing/captain_lifecycle_routes_spec.rb
git commit -m "feat(lifecycle): add REST routes for rules, config, deliveries, concierge"
```
---
## Task 4: LifecycleRulesController + jbuilders
**Files:**
- Create: `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_rules_controller.rb`
- Create: `enterprise/app/views/api/v1/models/captain/_lifecycle_rule.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/index.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/show.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/create.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/update.json.jbuilder`
- Create: `spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_rules_controller_spec.rb`
- Create: `spec/factories/captain/lifecycle/rule.rb`
- [ ] **Step 1: Factory**
Criar `spec/factories/captain/lifecycle/rule.rb`:
```ruby
FactoryBot.define do
factory :captain_lifecycle_rule, class: 'Captain::Lifecycle::Rule' do
account
sequence(:name) { |n| "Rule #{n}" }
enabled { true }
event { 'checkin.scheduled_at' }
offset_minutes { -10 }
filters { {} }
message_type { 'text' }
message_body { 'Olá {{ customer.first_name }}!' }
priority { 50 }
end
end
```
- [ ] **Step 2: Spec falhando**
Criar `spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_rules_controller_spec.rb`:
```ruby
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::LifecycleRules', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
let(:other_account) { create(:account) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/lifecycle_rules' do
it 'returns 401 when unauthenticated' do
get "/api/v1/accounts/#{account.id}/captain/lifecycle_rules"
expect(response).to have_http_status(:unauthorized)
end
it 'returns the account rules (and not others)' do
create_list(:captain_lifecycle_rule, 2, account: account)
create(:captain_lifecycle_rule, account: other_account)
get "/api/v1/accounts/#{account.id}/captain/lifecycle_rules",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:payload].length).to eq(2)
end
end
describe 'POST /api/v1/accounts/:account_id/captain/lifecycle_rules' do
let(:valid_params) do
{
rule: {
name: 'Lembrete pré check-in',
event: 'checkin.scheduled_at',
offset_minutes: -10,
message_type: 'text',
message_body: 'Oi {{ customer.first_name }}',
filters: { unit_ids: [1] },
enabled: true
}
}
end
it 'blocks agents' do
post "/api/v1/accounts/#{account.id}/captain/lifecycle_rules",
params: valid_params, headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates for admin' do
post "/api/v1/accounts/#{account.id}/captain/lifecycle_rules",
params: valid_params, headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:name]).to eq('Lembrete pré check-in')
expect(Captain::Lifecycle::Rule.where(account: account).count).to eq(1)
end
end
describe 'PATCH /api/v1/accounts/:account_id/captain/lifecycle_rules/:id' do
let(:rule) { create(:captain_lifecycle_rule, account: account, name: 'old') }
it 'updates for admin' do
patch "/api/v1/accounts/#{account.id}/captain/lifecycle_rules/#{rule.id}",
params: { rule: { name: 'new' } },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(rule.reload.name).to eq('new')
end
end
describe 'DELETE /api/v1/accounts/:account_id/captain/lifecycle_rules/:id' do
it 'destroys for admin' do
rule = create(:captain_lifecycle_rule, account: account)
delete "/api/v1/accounts/#{account.id}/captain/lifecycle_rules/#{rule.id}",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(Captain::Lifecycle::Rule.where(id: rule.id)).to be_empty
end
end
end
```
- [ ] **Step 3: Rodar e falhar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_rules_controller_spec.rb
```
Esperado: FAIL — `uninitialized constant`.
- [ ] **Step 4: Implementar controller**
Criar `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_rules_controller.rb`:
```ruby
class Api::V1::Accounts::Captain::LifecycleRulesController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::Lifecycle::Rule) }
before_action :set_rule, only: [:show, :update, :destroy]
def index
@rules = Current.account.captain_lifecycle_rules.order(priority: :asc, id: :desc)
end
def show; end
def create
@rule = Current.account.captain_lifecycle_rules.create!(
rule_params.merge(created_by_user: Current.user)
)
render 'api/v1/accounts/captain/lifecycle_rules/show'
end
def update
@rule.update!(rule_params)
render 'api/v1/accounts/captain/lifecycle_rules/show'
end
def destroy
@rule.destroy!
head :no_content
end
private
def set_rule
@rule = Current.account.captain_lifecycle_rules.find(params[:id])
end
def rule_params
params.require(:rule).permit(
:name, :description, :enabled, :event, :offset_minutes,
:message_type, :message_body, :priority,
filters: {},
message_payload: {}
)
end
end
```
- [ ] **Step 5: Implementar jbuilder partial**
Criar `enterprise/app/views/api/v1/models/captain/_lifecycle_rule.json.jbuilder`:
```jbuilder
json.id resource.id
json.account_id resource.account_id
json.name resource.name
json.description resource.description
json.enabled resource.enabled
json.event resource.event
json.offset_minutes resource.offset_minutes
json.filters resource.filters || {}
json.message_type resource.message_type
json.message_body resource.message_body
json.message_payload resource.message_payload
json.priority resource.priority
json.created_by_user_id resource.created_by_user_id
json.created_at resource.created_at&.iso8601
json.updated_at resource.updated_at&.iso8601
```
- [ ] **Step 6: Templates**
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/index.json.jbuilder`:
```jbuilder
json.payload do
json.array! @rules do |rule|
json.partial! 'api/v1/models/captain/lifecycle_rule', resource: rule
end
end
json.meta do
json.total_count @rules.count
end
```
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/show.json.jbuilder`:
```jbuilder
json.partial! 'api/v1/models/captain/lifecycle_rule', resource: @rule
```
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/create.json.jbuilder`:
```jbuilder
json.partial! 'api/v1/models/captain/lifecycle_rule', resource: @rule
```
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_rules/update.json.jbuilder`:
```jbuilder
json.partial! 'api/v1/models/captain/lifecycle_rule', resource: @rule
```
- [ ] **Step 7: Rodar e passar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_rules_controller_spec.rb
```
Esperado: PASS (6 examples).
- [ ] **Step 8: Commit**
```bash
git add enterprise/app/controllers/api/v1/accounts/captain/lifecycle_rules_controller.rb \
enterprise/app/views/api/v1/models/captain/_lifecycle_rule.json.jbuilder \
enterprise/app/views/api/v1/accounts/captain/lifecycle_rules \
spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_rules_controller_spec.rb \
spec/factories/captain/lifecycle/rule.rb
git commit -m "feat(lifecycle): REST endpoint for lifecycle rules CRUD"
```
---
## Task 5: LifecycleConfigsController
**Files:**
- Create: `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_configs_controller.rb`
- Create: `enterprise/app/views/api/v1/models/captain/_lifecycle_config.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_configs/show.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_configs/update.json.jbuilder`
- Create: `spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_configs_controller_spec.rb`
- [ ] **Step 1: Spec falhando**
Criar `spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_configs_controller_spec.rb`:
```ruby
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::LifecycleConfigs', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:agent) { create(:user, account: account, role: :agent) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/lifecycle_config' do
it 'creates a default config on first access' do
get "/api/v1/accounts/#{account.id}/captain/lifecycle_config",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:quiet_hours_enabled]).to eq(false)
expect(json_response[:min_interval_minutes]).to eq(30)
expect(json_response[:max_per_reservation]).to eq(5)
end
end
describe 'PATCH /api/v1/accounts/:account_id/captain/lifecycle_config' do
it 'blocks agents' do
patch "/api/v1/accounts/#{account.id}/captain/lifecycle_config",
params: { config: { quiet_hours_enabled: true } },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates for admin' do
patch "/api/v1/accounts/#{account.id}/captain/lifecycle_config",
params: { config: { quiet_hours_enabled: true, quiet_hours_from: '22:00', min_interval_minutes: 60 } },
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:quiet_hours_enabled]).to eq(true)
expect(json_response[:min_interval_minutes]).to eq(60)
end
end
end
```
- [ ] **Step 2: Rodar e falhar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_configs_controller_spec.rb
```
Esperado: FAIL.
- [ ] **Step 3: Controller**
Criar `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_configs_controller.rb`:
```ruby
class Api::V1::Accounts::Captain::LifecycleConfigsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action :set_config
before_action -> { check_authorization(@config) }
def show; end
def update
@config.update!(config_params)
render 'api/v1/accounts/captain/lifecycle_configs/show'
end
private
def set_config
@config = Captain::Lifecycle::Config.for_account(Current.account)
end
def config_params
params.require(:config).permit(
:quiet_hours_enabled, :quiet_hours_from, :quiet_hours_to,
:min_interval_minutes, :pause_on_customer_reply,
:pause_on_customer_reply_within_minutes, :opt_out_label_id
)
end
end
```
- [ ] **Step 4: Jbuilder partial**
Criar `enterprise/app/views/api/v1/models/captain/_lifecycle_config.json.jbuilder`:
```jbuilder
json.id resource.id
json.account_id resource.account_id
json.quiet_hours_enabled resource.quiet_hours_enabled
json.quiet_hours_from resource.quiet_hours_from&.strftime('%H:%M')
json.quiet_hours_to resource.quiet_hours_to&.strftime('%H:%M')
json.min_interval_minutes resource.min_interval_minutes
json.pause_on_customer_reply resource.pause_on_customer_reply
json.pause_on_customer_reply_within_minutes resource.pause_on_customer_reply_within_minutes
json.opt_out_label_id resource.opt_out_label_id
json.max_per_reservation 5
```
- [ ] **Step 5: Templates**
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_configs/show.json.jbuilder`:
```jbuilder
json.partial! 'api/v1/models/captain/lifecycle_config', resource: @config
```
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_configs/update.json.jbuilder`:
```jbuilder
json.partial! 'api/v1/models/captain/lifecycle_config', resource: @config
```
- [ ] **Step 6: Rodar e passar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_configs_controller_spec.rb
```
Esperado: PASS (3 examples).
- [ ] **Step 7: Commit**
```bash
git add enterprise/app/controllers/api/v1/accounts/captain/lifecycle_configs_controller.rb \
enterprise/app/views/api/v1/models/captain/_lifecycle_config.json.jbuilder \
enterprise/app/views/api/v1/accounts/captain/lifecycle_configs \
spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_configs_controller_spec.rb
git commit -m "feat(lifecycle): REST endpoint for lifecycle config singleton"
```
---
## Task 6: LifecycleDeliveriesController
**Files:**
- Create: `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller.rb`
- Create: `enterprise/app/views/api/v1/models/captain/_lifecycle_delivery.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_deliveries/index.json.jbuilder`
- Create: `enterprise/app/views/api/v1/accounts/captain/lifecycle_deliveries/show.json.jbuilder`
- Create: `spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller_spec.rb`
- Create: `spec/factories/captain/lifecycle/delivery.rb`
- [ ] **Step 1: Factory**
Criar `spec/factories/captain/lifecycle/delivery.rb`:
```ruby
FactoryBot.define do
factory :captain_lifecycle_delivery, class: 'Captain::Lifecycle::Delivery' do
account
captain_reservation
fire_at { 10.minutes.from_now }
status { 'scheduled' }
origin { 'scheduled_lifecycle' }
end
end
```
- [ ] **Step 2: Spec falhando**
Criar `spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller_spec.rb`:
```ruby
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::Captain::LifecycleDeliveries', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:unit) { create(:captain_unit, account: account) }
let(:reservation) { create(:captain_reservation, account: account, unit: unit) }
def json_response
JSON.parse(response.body, symbolize_names: true)
end
describe 'GET /api/v1/accounts/:account_id/captain/lifecycle_deliveries' do
before do
allow(Captain::Lifecycle::Scheduler).to receive(:schedule_for)
end
it 'returns deliveries of the account, paginated' do
create_list(:captain_lifecycle_delivery, 3, account: account, captain_reservation: reservation)
get "/api/v1/accounts/#{account.id}/captain/lifecycle_deliveries",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:payload].length).to eq(3)
expect(json_response[:meta][:total_count]).to eq(3)
end
it 'filters by status' do
create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation, status: 'sent', sent_at: Time.current)
create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation, status: 'skipped', skip_reason: 'quiet_hours')
get "/api/v1/accounts/#{account.id}/captain/lifecycle_deliveries?status=skipped",
headers: admin.create_new_auth_token, as: :json
expect(json_response[:payload].length).to eq(1)
expect(json_response[:payload].first[:status]).to eq('skipped')
end
end
describe 'GET /api/v1/accounts/:account_id/captain/lifecycle_deliveries/:id' do
before { allow(Captain::Lifecycle::Scheduler).to receive(:schedule_for) }
it 'returns the rendered_body' do
delivery = create(:captain_lifecycle_delivery, account: account, captain_reservation: reservation,
rendered_body: 'Oi João!')
get "/api/v1/accounts/#{account.id}/captain/lifecycle_deliveries/#{delivery.id}",
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
expect(json_response[:rendered_body]).to eq('Oi João!')
end
end
end
```
- [ ] **Step 3: Rodar e falhar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller_spec.rb
```
- [ ] **Step 4: Controller**
Criar `enterprise/app/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller.rb`:
```ruby
class Api::V1::Accounts::Captain::LifecycleDeliveriesController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 25
MAX_RESULTS_PER_PAGE = 100
before_action :current_account
before_action -> { check_authorization(Captain::Lifecycle::Delivery) }
before_action :set_page, only: [:index]
before_action :set_delivery, only: [:show]
def index
scope = Current.account.captain_lifecycle_deliveries
.includes(:lifecycle_rule, captain_reservation: :contact)
.order(created_at: :desc)
scope = scope.where(status: params[:status]) if params[:status].present?
scope = scope.where(lifecycle_rule_id: params[:rule_id]) if params[:rule_id].present?
scope = scope.where(captain_reservation_id: params[:reservation_id]) if params[:reservation_id].present?
scope = scope.where('fire_at >= ?', params[:from]) if params[:from].present?
scope = scope.where('fire_at <= ?', params[:to]) if params[:to].present?
@total_count = scope.count
@deliveries = scope.page(@page).per(@per_page)
end
def show; end
private
def set_page
@page = (params[:page] || 1).to_i
@per_page = [(params[:per_page] || RESULTS_PER_PAGE).to_i, MAX_RESULTS_PER_PAGE].min
end
def set_delivery
@delivery = Current.account.captain_lifecycle_deliveries.find(params[:id])
end
end
```
- [ ] **Step 5: Jbuilder partial**
Criar `enterprise/app/views/api/v1/models/captain/_lifecycle_delivery.json.jbuilder`:
```jbuilder
json.id resource.id
json.account_id resource.account_id
json.lifecycle_rule_id resource.lifecycle_rule_id
json.lifecycle_rule_name resource.lifecycle_rule&.name
json.captain_reservation_id resource.captain_reservation_id
json.conversation_id resource.conversation_id
json.inbox_id resource.inbox_id
json.fire_at resource.fire_at&.iso8601
json.sent_at resource.sent_at&.iso8601
json.status resource.status
json.skip_reason resource.skip_reason
json.failure_reason resource.failure_reason
json.rendered_body resource.rendered_body
json.origin resource.origin
if resource.captain_reservation
json.reservation do
json.id resource.captain_reservation.id
json.suite_identifier resource.captain_reservation.suite_identifier
contact = resource.captain_reservation.contact
json.customer_name contact&.name
json.customer_phone contact&.phone_number
end
end
```
- [ ] **Step 6: Templates**
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_deliveries/index.json.jbuilder`:
```jbuilder
json.payload do
json.array! @deliveries do |delivery|
json.partial! 'api/v1/models/captain/lifecycle_delivery', resource: delivery
end
end
json.meta do
json.total_count @total_count
json.page @page
json.per_page @per_page
end
```
Criar `enterprise/app/views/api/v1/accounts/captain/lifecycle_deliveries/show.json.jbuilder`:
```jbuilder
json.partial! 'api/v1/models/captain/lifecycle_delivery', resource: @delivery
```
- [ ] **Step 7: Rodar e passar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller_spec.rb
```
Esperado: PASS (3 examples).
- [ ] **Step 8: Commit**
```bash
git add enterprise/app/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller.rb \
enterprise/app/views/api/v1/models/captain/_lifecycle_delivery.json.jbuilder \
enterprise/app/views/api/v1/accounts/captain/lifecycle_deliveries \
spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller_spec.rb \
spec/factories/captain/lifecycle/delivery.rb
git commit -m "feat(lifecycle): REST endpoint for lifecycle deliveries audit log"
```
---
## Task 7: Estender UnitsController com `update_concierge`
**Files:**
- Modify: `enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb`
- Modify: `enterprise/app/views/api/v1/models/captain/_unit.json.jbuilder` (adicionar campos concierge, se ainda não existirem)
- Create: `enterprise/app/views/api/v1/accounts/captain/units/concierge.json.jbuilder`
- Create/Modify: `spec/enterprise/controllers/api/v1/accounts/captain/units_controller_spec.rb` (adicionar contexto `update_concierge`)
- [ ] **Step 1: Ler controller atual**
Run: `cat enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb` pra saber o formato do arquivo antes de editar.
- [ ] **Step 2: Spec falhando**
Adicionar ao final do describe principal em `spec/enterprise/controllers/api/v1/accounts/captain/units_controller_spec.rb` (criar o arquivo se não existir, seguindo o padrão do rules spec):
```ruby
describe 'PATCH /api/v1/accounts/:account_id/captain/units/:id/concierge' do
let(:unit) { create(:captain_unit, account: account) }
let(:inbox) { create(:inbox, account: account) }
it 'blocks agents' do
patch "/api/v1/accounts/#{account.id}/captain/units/#{unit.id}/concierge",
params: { unit: { concierge_inbox_id: inbox.id } },
headers: agent.create_new_auth_token, as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'updates concierge fields for admin' do
patch "/api/v1/accounts/#{account.id}/captain/units/#{unit.id}/concierge",
params: {
unit: {
concierge_inbox_id: inbox.id,
concierge_config: {
persona_name: 'Sofia',
knowledge: '# Hotel\n',
variables: { wifi_password: 'abc123' }
}
}
},
headers: admin.create_new_auth_token, as: :json
expect(response).to have_http_status(:success)
unit.reload
expect(unit.concierge_inbox_id).to eq(inbox.id)
expect(unit.concierge_config['persona_name']).to eq('Sofia')
expect(unit.concierge_config.dig('variables', 'wifi_password')).to eq('abc123')
end
end
```
- [ ] **Step 3: Rodar e falhar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/units_controller_spec.rb
```
- [ ] **Step 4: Implementar action no controller**
Adicionar ao `enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb`:
```ruby
def update_concierge
@unit.update!(concierge_params)
render 'api/v1/accounts/captain/units/concierge'
end
private
def concierge_params
params.require(:unit).permit(
:concierge_inbox_id,
concierge_config: [:persona_name, :knowledge, { variables: {} }]
)
end
```
Certifique-se que `before_action :set_unit, only: [:show, :update, :destroy, :update_concierge]` está na lista, e que a authorization do update cobre `update_concierge` (provavelmente `check_authorization(Captain::Unit)` já cobre porque checka `update?`).
- [ ] **Step 5: Jbuilder da action concierge**
Criar `enterprise/app/views/api/v1/accounts/captain/units/concierge.json.jbuilder`:
```jbuilder
json.id @unit.id
json.name @unit.name
json.concierge_inbox_id @unit.concierge_inbox_id
json.concierge_config @unit.concierge_config || {}
```
- [ ] **Step 6: Atualizar partial de unit**
Em `enterprise/app/views/api/v1/models/captain/_unit.json.jbuilder`, garantir que existem:
```jbuilder
json.concierge_inbox_id resource.concierge_inbox_id
json.concierge_config resource.concierge_config || {}
```
(se os campos não existirem no partial, adicionar).
- [ ] **Step 7: Rodar e passar**
```bash
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/captain/units_controller_spec.rb
```
- [ ] **Step 8: Commit**
```bash
git add enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb \
enterprise/app/views/api/v1/accounts/captain/units \
enterprise/app/views/api/v1/models/captain/_unit.json.jbuilder \
spec/enterprise/controllers/api/v1/accounts/captain/units_controller_spec.rb
git commit -m "feat(lifecycle): expose concierge config update on UnitsController"
```
---
## Task 8: i18n scaffolding (pt_BR + en)
**Files:**
- Modify: `app/javascript/dashboard/i18n/locale/pt_BR/captain.json`
- Modify: `app/javascript/dashboard/i18n/locale/en/captain.json`
- Modify: `app/javascript/dashboard/i18n/locale/pt_BR/settings.json` (chave `SIDEBAR.CAPTAIN_LIFECYCLE`)
- Modify: `app/javascript/dashboard/i18n/locale/en/settings.json`
- [ ] **Step 1: Adicionar bloco `CAPTAIN_LIFECYCLE` em pt_BR/captain.json**
Adicionar antes do último `}` do JSON (fechando o objeto raiz):
```json
"CAPTAIN_LIFECYCLE": {
"HEADER": "Jornada do Cliente",
"SUBTITLE": "Automação de mensagens WhatsApp no ciclo de vida da reserva",
"TABS": {
"RULES": "Regras",
"SETTINGS": "Configurações",
"HISTORY": "Histórico"
},
"RULES": {
"EMPTY": "Nenhuma regra configurada ainda.",
"CREATE": "Nova regra",
"TEMPLATES_TITLE": "Templates prontos",
"COLUMNS": {
"NAME": "Nome",
"EVENT": "Evento",
"OFFSET": "Offset",
"FILTER": "Filtro",
"STATUS": "Status",
"ACTIONS": "Ações"
},
"STATUS": {
"ENABLED": "Ativo",
"DISABLED": "Desativado"
},
"ACTIONS": {
"EDIT": "Editar",
"DUPLICATE": "Duplicar",
"TOGGLE": "Ativar/Desativar",
"DELETE": "Excluir"
},
"DELETE_CONFIRM": "Tem certeza que deseja excluir esta regra?",
"TOAST": {
"CREATED": "Regra criada com sucesso.",
"UPDATED": "Regra atualizada.",
"DELETED": "Regra excluída."
},
"WIZARD": {
"TITLE_CREATE": "Nova regra",
"TITLE_EDIT": "Editar regra",
"STEP_WHEN": "Quando?",
"STEP_WHO": "Pra quem?",
"STEP_WHAT": "O quê?",
"STEP_REVIEW": "Revisão",
"NEXT": "Próximo",
"BACK": "Voltar",
"SAVE": "Salvar",
"FIELDS": {
"NAME": "Nome da regra",
"DESCRIPTION": "Descrição",
"EVENT": "Evento gatilho",
"OFFSET_VALUE": "Valor",
"OFFSET_UNIT": "Unidade",
"OFFSET_DIRECTION": "Direção",
"UNITS": "Unidades",
"CATEGORIAS": "Categorias de suíte",
"PERMANENCIAS": "Tipos de permanência",
"MESSAGE_TYPE": "Tipo de mensagem",
"MESSAGE_BODY": "Texto da mensagem",
"PRIORITY": "Prioridade",
"ENABLED": "Regra ativa"
},
"OFFSET_UNITS": {
"MINUTES": "Minutos",
"HOURS": "Horas",
"DAYS": "Dias"
},
"OFFSET_DIRECTIONS": {
"BEFORE": "Antes",
"AFTER": "Depois"
},
"EVENTS": {
"RESERVATION_CONFIRMED": "Reserva confirmada (Pix pago)",
"CHECKIN_SCHEDULED_AT": "Horário de check-in",
"CHECKOUT_SCHEDULED_AT": "Horário de check-out",
"RESERVATION_CANCELLED": "Reserva cancelada",
"RESERVATION_NO_SHOW": "No-show"
},
"MESSAGE_TYPES": {
"TEXT": "Texto simples",
"BUTTONS": "Texto com botões",
"LIST": "Menu de lista",
"URL_BUTTON": "Botão de link"
}
}
},
"SETTINGS": {
"GUARDS_TITLE": "Guards anti-spam",
"QUIET_HOURS_ENABLED": "Ativar quiet hours",
"QUIET_HOURS_FROM": "De",
"QUIET_HOURS_TO": "Até",
"MIN_INTERVAL": "Intervalo mínimo entre mensagens (min)",
"MIN_INTERVAL_HELP": "0 desativa",
"PAUSE_ON_REPLY": "Pausar se o cliente respondeu",
"PAUSE_ON_REPLY_WINDOW": "Janela (min)",
"OPT_OUT_LABEL": "Label de opt-out",
"MAX_PER_RESERVATION_INFO": "Máximo de 5 mensagens por reserva (não configurável)",
"CONCIERGE_TITLE": "Concierge (Sofia) por Unidade",
"CONCIERGE_INBOX": "Inbox WhatsApp",
"CONCIERGE_PERSONA": "Nome da persona",
"CONCIERGE_KNOWLEDGE": "Base de conhecimento (markdown)",
"CONCIERGE_VARIABLES": "Variáveis da unidade",
"CONCIERGE_VARIABLE_KEY": "Chave",
"CONCIERGE_VARIABLE_VALUE": "Valor",
"CONCIERGE_ADD_VARIABLE": "Adicionar variável",
"SAVE": "Salvar alterações",
"TOAST": {
"SAVED": "Configurações salvas.",
"CONCIERGE_SAVED": "Concierge da unidade atualizado."
}
},
"HISTORY": {
"EMPTY": "Nenhuma entrega registrada.",
"COLUMNS": {
"RULE": "Regra",
"CUSTOMER": "Cliente",
"RESERVATION": "Reserva",
"STATUS": "Status",
"FIRE_AT": "Disparado em",
"REASON": "Motivo",
"ACTIONS": ""
},
"STATUS": {
"SCHEDULED": "Agendada",
"SENT": "Enviada",
"SKIPPED": "Pulada",
"FAILED": "Falhou",
"CANCELLED": "Cancelada"
},
"FILTERS": {
"STATUS": "Status",
"RULE": "Regra",
"FROM": "De",
"TO": "Até",
"ALL": "Todas"
},
"PREVIEW": "Preview",
"MODAL": {
"TITLE": "Preview da mensagem",
"CLOSE": "Fechar"
}
}
}
```
- [ ] **Step 2: Duplicar em en/captain.json traduzido pra inglês**
Mesma estrutura com valores em inglês ("Customer Journey", "Rules", "Settings", "History", etc). Preservar as **keys** exatas.
- [ ] **Step 3: Adicionar chave de sidebar**
Em `app/javascript/dashboard/i18n/locale/pt_BR/settings.json`, procurar `"CAPTAIN_RESERVATIONS"` dentro de `SIDEBAR` e adicionar logo após:
```json
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
```
Mesma coisa em `en/settings.json` com valor `"Customer Journey"`.
- [ ] **Step 4: Sync i18n**
```bash
pnpm run sync:i18n
```
(Não deve acusar erro. Se acusar key faltando em outro locale, ignorar se não for en/pt_BR — o projeto só mantém esses dois em dia.)
- [ ] **Step 5: Commit**
```bash
git add app/javascript/dashboard/i18n/locale/pt_BR/captain.json \
app/javascript/dashboard/i18n/locale/en/captain.json \
app/javascript/dashboard/i18n/locale/pt_BR/settings.json \
app/javascript/dashboard/i18n/locale/en/settings.json
git commit -m "feat(lifecycle): i18n keys for Jornada do Cliente UI"
```
---
## Task 9: API clients (Vue)
**Files:**
- Create: `app/javascript/dashboard/api/captain/lifecycleRules.js`
- Create: `app/javascript/dashboard/api/captain/lifecycleConfig.js`
- Create: `app/javascript/dashboard/api/captain/lifecycleDeliveries.js`
- [ ] **Step 1: Criar `lifecycleRules.js`**
```javascript
/* global axios */
import ApiClient from '../ApiClient';
class CaptainLifecycleRules extends ApiClient {
constructor() {
super('captain/lifecycle_rules', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
create(data) {
return axios.post(this.url, { rule: data });
}
update(id, data) {
return axios.patch(`${this.url}/${id}`, { rule: data });
}
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
}
export default new CaptainLifecycleRules();
```
- [ ] **Step 2: Criar `lifecycleConfig.js`**
```javascript
/* global axios */
import ApiClient from '../ApiClient';
class CaptainLifecycleConfig extends ApiClient {
constructor() {
super('captain/lifecycle_config', { accountScoped: true });
}
show() {
return axios.get(this.url);
}
update(data) {
return axios.patch(this.url, { config: data });
}
updateConcierge(unitId, payload) {
const accountId = this.options.accountScoped
? `accounts/${window.authAPI.accountId}`
: '';
return axios.patch(
`/api/v1/${accountId}/captain/units/${unitId}/concierge`,
{ unit: payload }
);
}
}
export default new CaptainLifecycleConfig();
```
**Nota:** verificar como `ApiClient` expõe `accountId` — se houver método `urlFor`, usar ele. Pode precisar de `import AuthAPI` ou usar `this.baseUrl()`. Se não ficar claro, fazer chamada diretamente via `axios.patch(\`/api/v1/accounts/${window.bus.$store.getters.getCurrentAccountId}/...\`)` ou simplesmente colocar o método `updateConcierge` no client `CaptainUnitsAPI` existente.
- [ ] **Step 3: Criar `lifecycleDeliveries.js`**
```javascript
/* global axios */
import ApiClient from '../ApiClient';
class CaptainLifecycleDeliveries extends ApiClient {
constructor() {
super('captain/lifecycle_deliveries', { accountScoped: true });
}
get(params = {}) {
return axios.get(this.url, { params });
}
show(id) {
return axios.get(`${this.url}/${id}`);
}
}
export default new CaptainLifecycleDeliveries();
```
- [ ] **Step 4: Rodar eslint**
```bash
pnpm run eslint app/javascript/dashboard/api/captain/lifecycleRules.js app/javascript/dashboard/api/captain/lifecycleConfig.js app/javascript/dashboard/api/captain/lifecycleDeliveries.js
```
- [ ] **Step 5: Commit**
```bash
git add app/javascript/dashboard/api/captain/lifecycleRules.js \
app/javascript/dashboard/api/captain/lifecycleConfig.js \
app/javascript/dashboard/api/captain/lifecycleDeliveries.js
git commit -m "feat(lifecycle): API clients for rules/config/deliveries"
```
---
## Task 10: Pinia stores + registro
**Files:**
- Create: `app/javascript/dashboard/store/captain/lifecycleRules.js`
- Create: `app/javascript/dashboard/store/captain/lifecycleConfig.js`
- Create: `app/javascript/dashboard/store/captain/lifecycleDeliveries.js`
- Modify: `app/javascript/dashboard/store/index.js`
- [ ] **Step 1: Store `lifecycleRules.js` (factory completa)**
```javascript
import CaptainLifecycleRulesAPI from 'dashboard/api/captain/lifecycleRules';
import { createStore } from '../storeFactory';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainLifecycleRule',
API: CaptainLifecycleRulesAPI,
actions: mutations => ({
create: async ({ commit }, data) => {
commit(mutations.SET_UI_FLAG, { creatingItem: true });
try {
const response = await CaptainLifecycleRulesAPI.create(data);
commit(mutations.ADD, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutations.SET_UI_FLAG, { creatingItem: false });
}
},
update: async ({ commit }, { id, ...data }) => {
commit(mutations.SET_UI_FLAG, { updatingItem: true });
try {
const response = await CaptainLifecycleRulesAPI.update(id, data);
commit(mutations.EDIT, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutations.SET_UI_FLAG, { updatingItem: false });
}
},
delete: async ({ commit }, id) => {
commit(mutations.SET_UI_FLAG, { deletingItem: true });
try {
await CaptainLifecycleRulesAPI.delete(id);
commit(mutations.DELETE, id);
return id;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(mutations.SET_UI_FLAG, { deletingItem: false });
}
},
}),
});
```
- [ ] **Step 2: Store `lifecycleConfig.js` (singleton)**
O factory assume lista de registros. Pro config (singleton), escrevemos um módulo Vuex manual:
```javascript
import CaptainLifecycleConfigAPI from 'dashboard/api/captain/lifecycleConfig';
import { throwErrorMessage } from 'dashboard/store/utils/api';
export const types = {
SET_CONFIG: 'SET_LIFECYCLE_CONFIG',
SET_UI_FLAG: 'SET_LIFECYCLE_CONFIG_UI_FLAG',
};
const state = {
config: {},
uiFlags: {
fetching: false,
updating: false,
},
};
const getters = {
getConfig: $state => $state.config,
getUIFlags: $state => $state.uiFlags,
};
const actions = {
fetch: async ({ commit }) => {
commit(types.SET_UI_FLAG, { fetching: true });
try {
const response = await CaptainLifecycleConfigAPI.show();
commit(types.SET_CONFIG, response.data);
} catch (error) {
throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { fetching: false });
}
},
update: async ({ commit }, data) => {
commit(types.SET_UI_FLAG, { updating: true });
try {
const response = await CaptainLifecycleConfigAPI.update(data);
commit(types.SET_CONFIG, response.data);
return response.data;
} catch (error) {
return throwErrorMessage(error);
} finally {
commit(types.SET_UI_FLAG, { updating: false });
}
},
};
const mutations = {
[types.SET_CONFIG]($state, config) {
$state.config = config;
},
[types.SET_UI_FLAG]($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};
```
- [ ] **Step 3: Store `lifecycleDeliveries.js`**
```javascript
import CaptainLifecycleDeliveriesAPI from 'dashboard/api/captain/lifecycleDeliveries';
import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainLifecycleDelivery',
API: CaptainLifecycleDeliveriesAPI,
});
```
- [ ] **Step 4: Registrar no `store/index.js`**
Em `app/javascript/dashboard/store/index.js`, localizar a linha `import captainReservations from './captain/reservations';` e adicionar abaixo:
```javascript
import captainLifecycleRules from './captain/lifecycleRules';
import captainLifecycleConfig from './captain/lifecycleConfig';
import captainLifecycleDeliveries from './captain/lifecycleDeliveries';
```
E no objeto `modules:` (onde está `captainReservations,`) adicionar:
```javascript
captainLifecycleRules,
captainLifecycleConfig,
captainLifecycleDeliveries,
```
- [ ] **Step 5: Rodar eslint**
```bash
pnpm run eslint app/javascript/dashboard/store/captain/lifecycleRules.js \
app/javascript/dashboard/store/captain/lifecycleConfig.js \
app/javascript/dashboard/store/captain/lifecycleDeliveries.js \
app/javascript/dashboard/store/index.js
```
- [ ] **Step 6: Commit**
```bash
git add app/javascript/dashboard/store/captain/lifecycleRules.js \
app/javascript/dashboard/store/captain/lifecycleConfig.js \
app/javascript/dashboard/store/captain/lifecycleDeliveries.js \
app/javascript/dashboard/store/index.js
git commit -m "feat(lifecycle): Pinia/Vuex stores for rules/config/deliveries"
```
---
## Task 11: Rotas frontend + parent view com TabBar
**Files:**
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Index.vue`
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Rules.vue` (stub)
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Settings.vue` (stub)
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/History.vue` (stub)
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/constants.js`
- Modify: `app/javascript/dashboard/routes/dashboard/captain/captain.routes.js`
- [ ] **Step 1: constants.js**
```javascript
export const EVENTS = [
{ value: 'reservation.confirmed', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.EVENTS.RESERVATION_CONFIRMED' },
{ value: 'checkin.scheduled_at', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.EVENTS.CHECKIN_SCHEDULED_AT' },
{ value: 'checkout.scheduled_at', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.EVENTS.CHECKOUT_SCHEDULED_AT' },
{ value: 'reservation.cancelled', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.EVENTS.RESERVATION_CANCELLED' },
{ value: 'reservation.no_show', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.EVENTS.RESERVATION_NO_SHOW' },
];
export const MESSAGE_TYPES = [
{ value: 'text', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.MESSAGE_TYPES.TEXT' },
{ value: 'buttons', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.MESSAGE_TYPES.BUTTONS' },
{ value: 'list', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.MESSAGE_TYPES.LIST' },
{ value: 'url_button', labelKey: 'CAPTAIN_LIFECYCLE.RULES.WIZARD.MESSAGE_TYPES.URL_BUTTON' },
];
export const OFFSET_UNITS = [
{ value: 'minutes', factor: 1 },
{ value: 'hours', factor: 60 },
{ value: 'days', factor: 1440 },
];
export const AVAILABLE_VARIABLES = [
{ key: 'customer.first_name', descKey: 'Primeiro nome do cliente' },
{ key: 'customer.name', descKey: 'Nome completo' },
{ key: 'customer.phone', descKey: 'Telefone' },
{ key: 'reservation.suite', descKey: 'Suíte' },
{ key: 'reservation.unit_name', descKey: 'Nome da unidade' },
{ key: 'reservation.check_in_at', descKey: 'Check-in' },
{ key: 'reservation.check_out_at', descKey: 'Check-out' },
{ key: 'reservation.amount', descKey: 'Valor' },
{ key: 'hotel.wifi_password', descKey: 'Senha do WiFi' },
{ key: 'hotel.menu_link', descKey: 'Link do cardápio' },
{ key: 'hotel.google_review_link', descKey: 'Link de review' },
{ key: 'hotel.address', descKey: 'Endereço' },
];
export const RULE_TEMPLATES = [
{
id: 'precheckin_reminder',
name: 'Lembrete 10min antes do check-in',
event: 'checkin.scheduled_at',
offset_minutes: -10,
message_type: 'text',
message_body:
'Oi {{ customer.first_name }}! Seu check-in é em 10 minutos na suíte {{ reservation.suite }}. Wifi: {{ hotel.wifi_password }}',
},
{
id: 'welcome_instay',
name: 'Boas-vindas após check-in',
event: 'checkin.scheduled_at',
offset_minutes: 15,
message_type: 'text',
message_body:
'Seja bem-vindo(a), {{ customer.first_name }}! Qualquer coisa, só chamar. Cardápio: {{ hotel.menu_link }}',
},
{
id: 'review_request',
name: 'Pedido de review no Google',
event: 'checkout.scheduled_at',
offset_minutes: 120,
message_type: 'url_button',
message_body:
'{{ customer.first_name }}, adoraríamos saber como foi sua estadia. Se puder, deixa um review pra gente: {{ hotel.google_review_link }}',
},
];
```
- [ ] **Step 2: `Index.vue` (parent com TabBar)**
```vue
<script setup>
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const tabs = computed(() => [
{ name: 'captain_lifecycle_rules', label: t('CAPTAIN_LIFECYCLE.TABS.RULES') },
{ name: 'captain_lifecycle_settings', label: t('CAPTAIN_LIFECYCLE.TABS.SETTINGS') },
{ name: 'captain_lifecycle_history', label: t('CAPTAIN_LIFECYCLE.TABS.HISTORY') },
]);
const activeIndex = computed(() =>
Math.max(0, tabs.value.findIndex(t2 => t2.name === route.name))
);
const handleTabChanged = tab => {
router.push({ name: tab.name, params: route.params });
};
</script>
<template>
<PageLayout
:header-title="t('CAPTAIN_LIFECYCLE.HEADER')"
:header-subtitle="t('CAPTAIN_LIFECYCLE.SUBTITLE')"
>
<div class="flex flex-col gap-4">
<TabBar
:tabs="tabs"
:initial-active-tab="activeIndex"
@tab-changed="handleTabChanged"
/>
<router-view />
</div>
</PageLayout>
</template>
```
- [ ] **Step 3: Stubs dos 3 filhos**
Criar `Rules.vue`, `Settings.vue`, `History.vue` com o mesmo esqueleto mínimo:
```vue
<script setup>
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
<div class="p-6">
<h2 class="text-lg font-semibold">{{ t('CAPTAIN_LIFECYCLE.TABS.RULES') }}</h2>
<!-- TODO Task 14 -->
</div>
</template>
```
(Ajustar o `t(...)` pra `SETTINGS` e `HISTORY` nos respectivos.)
- [ ] **Step 4: Registrar rotas**
Em `app/javascript/dashboard/routes/dashboard/captain/captain.routes.js`, adicionar imports no topo:
```javascript
import LifecycleIndex from './lifecycle/Index.vue';
import LifecycleRules from './lifecycle/Rules.vue';
import LifecycleSettings from './lifecycle/Settings.vue';
import LifecycleHistory from './lifecycle/History.vue';
```
E adicionar ao array `routes` (após a entrada de `captain_reservations_index`):
```javascript
{
path: frontendURL('accounts/:accountId/captain/lifecycle'),
component: LifecycleIndex,
meta,
redirect: { name: 'captain_lifecycle_rules' },
children: [
{
path: 'rules',
component: LifecycleRules,
name: 'captain_lifecycle_rules',
meta,
},
{
path: 'settings',
component: LifecycleSettings,
name: 'captain_lifecycle_settings',
meta,
},
{
path: 'history',
component: LifecycleHistory,
name: 'captain_lifecycle_history',
meta,
},
],
},
```
- [ ] **Step 5: Subir dev e testar navegação**
```bash
pnpm run dev
```
Abrir `http://localhost:3000/app/accounts/<id>/captain/lifecycle` → deve redirecionar pra `/rules`, exibir TabBar com 3 abas e clicar em cada uma navega entre stubs. Verificar console sem erros.
- [ ] **Step 6: Commit**
```bash
git add app/javascript/dashboard/routes/dashboard/captain/lifecycle \
app/javascript/dashboard/routes/dashboard/captain/captain.routes.js
git commit -m "feat(lifecycle): parent view with TabBar + 3 stub children routes"
```
---
## Task 12: Sidebar — entrada "Jornada do Cliente"
**Files:**
- Modify: `app/javascript/dashboard/components-next/sidebar/Sidebar.vue`
- [ ] **Step 1: Adicionar entrada no grupo Captain**
Localizar o bloco que contém `CAPTAIN_RESERVATIONS` (linha ~406). Adicionar **antes** dele:
```javascript
{
name: 'Lifecycle',
label: t('SIDEBAR.CAPTAIN_LIFECYCLE'),
activeOn: [
'captain_lifecycle_rules',
'captain_lifecycle_settings',
'captain_lifecycle_history',
],
to: accountScopedRoute('captain_lifecycle_rules'),
},
```
- [ ] **Step 2: Recarregar dev e verificar sidebar**
Abrir o app, expandir menu Captain no sidebar — deve aparecer "Jornada do Cliente" e levar pra `/rules`.
- [ ] **Step 3: Commit**
```bash
git add app/javascript/dashboard/components-next/sidebar/Sidebar.vue
git commit -m "feat(lifecycle): sidebar entry for Jornada do Cliente"
```
---
## Task 13: Tab Histórico — tabela + modal preview
**Files:**
- Modify: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/History.vue` (implementação completa)
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/DeliveryPreviewModal.vue`
- [ ] **Step 1: DeliveryPreviewModal.vue**
```vue
<script setup>
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
delivery: { type: Object, default: null },
});
const emit = defineEmits(['close']);
const { t } = useI18n();
</script>
<template>
<div
v-if="delivery"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="emit('close')"
>
<div class="bg-n-solid-1 rounded-xl p-6 w-[560px] max-h-[80vh] overflow-auto shadow-xl">
<h3 class="text-lg font-semibold mb-4">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.TITLE') }}
</h3>
<div class="space-y-3 text-sm">
<div><strong>Regra:</strong> {{ delivery.lifecycle_rule_name || '—' }}</div>
<div><strong>Status:</strong> {{ delivery.status }}</div>
<div v-if="delivery.skip_reason"><strong>Motivo:</strong> {{ delivery.skip_reason }}</div>
<div v-if="delivery.failure_reason"><strong>Erro:</strong> {{ delivery.failure_reason }}</div>
<div><strong>Fire at:</strong> {{ delivery.fire_at }}</div>
<div v-if="delivery.sent_at"><strong>Sent at:</strong> {{ delivery.sent_at }}</div>
<div>
<strong>Rendered:</strong>
<pre class="mt-1 p-3 bg-n-alpha-2 rounded whitespace-pre-wrap">{{ delivery.rendered_body || '—' }}</pre>
</div>
</div>
<div class="flex justify-end mt-6">
<Button variant="outline" @click="emit('close')">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.MODAL.CLOSE') }}
</Button>
</div>
</div>
</div>
</template>
```
- [ ] **Step 2: History.vue implementação**
```vue
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import DeliveryPreviewModal from './components/DeliveryPreviewModal.vue';
const store = useStore();
const { t } = useI18n();
const deliveries = useMapGetter('captainLifecycleDeliveries/getRecords');
const meta = useMapGetter('captainLifecycleDeliveries/getMeta');
const uiFlags = useMapGetter('captainLifecycleDeliveries/getUIFlags');
const status = ref('');
const page = ref(1);
const selectedDelivery = ref(null);
const STATUS_OPTIONS = [
{ value: '', key: 'ALL' },
{ value: 'scheduled', key: 'SCHEDULED' },
{ value: 'sent', key: 'SENT' },
{ value: 'skipped', key: 'SKIPPED' },
{ value: 'failed', key: 'FAILED' },
{ value: 'cancelled', key: 'CANCELLED' },
];
const fetchDeliveries = () => {
store.dispatch('captainLifecycleDeliveries/get', {
page: page.value,
...(status.value ? { status: status.value } : {}),
});
};
onMounted(fetchDeliveries);
watch([status, page], fetchDeliveries);
const isLoading = computed(() => uiFlags.value.fetchingList);
const totalCount = computed(() => meta.value.total_count || 0);
</script>
<template>
<div class="p-6">
<div class="flex items-center gap-4 mb-4">
<label class="flex items-center gap-2 text-sm">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.FILTERS.STATUS') }}:
<select v-model="status" class="border rounded px-2 py-1">
<option
v-for="opt in STATUS_OPTIONS"
:key="opt.value"
:value="opt.value"
>
{{ opt.value ? t(`CAPTAIN_LIFECYCLE.HISTORY.STATUS.${opt.key}`) : t('CAPTAIN_LIFECYCLE.HISTORY.FILTERS.ALL') }}
</option>
</select>
</label>
<span class="text-sm text-n-slate-11">{{ totalCount }} total</span>
</div>
<div v-if="isLoading" class="flex justify-center py-8"><Spinner /></div>
<div v-else-if="deliveries.length === 0" class="text-center py-8 text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.EMPTY') }}
</div>
<table v-else class="w-full text-sm">
<thead class="text-left text-n-slate-11">
<tr>
<th class="py-2">{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.RULE') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.CUSTOMER') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.RESERVATION') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.STATUS') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.FIRE_AT') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.HISTORY.COLUMNS.REASON') }}</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="d in deliveries" :key="d.id" class="border-t border-n-slate-4">
<td class="py-2">{{ d.lifecycle_rule_name || '—' }}</td>
<td>{{ d.reservation?.customer_name || '—' }}</td>
<td>#{{ d.captain_reservation_id }}</td>
<td>{{ t(`CAPTAIN_LIFECYCLE.HISTORY.STATUS.${d.status.toUpperCase()}`) }}</td>
<td>{{ new Date(d.fire_at).toLocaleString('pt-BR') }}</td>
<td>{{ d.skip_reason || d.failure_reason || '' }}</td>
<td>
<Button size="sm" variant="ghost" @click="selectedDelivery = d">
{{ t('CAPTAIN_LIFECYCLE.HISTORY.PREVIEW') }}
</Button>
</td>
</tr>
</tbody>
</table>
<div class="flex justify-center gap-2 mt-4">
<Button :disabled="page <= 1" @click="page -= 1">«</Button>
<span class="text-sm">{{ page }}</span>
<Button :disabled="deliveries.length < 25" @click="page += 1">»</Button>
</div>
<DeliveryPreviewModal
:delivery="selectedDelivery"
@close="selectedDelivery = null"
/>
</div>
</template>
```
- [ ] **Step 3: Testar manualmente**
Ainda com `pnpm run dev` rodando, navegar até `/captain/lifecycle/history`. Sem dados deve mostrar empty state. Via console Rails (`bundle exec rails console`), criar uma `Captain::Lifecycle::Delivery` de teste. Recarregar e ver entrada; clicar em "Preview" abre modal com `rendered_body`.
- [ ] **Step 4: Eslint**
```bash
pnpm run eslint app/javascript/dashboard/routes/dashboard/captain/lifecycle/History.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/DeliveryPreviewModal.vue
```
- [ ] **Step 5: Commit**
```bash
git add app/javascript/dashboard/routes/dashboard/captain/lifecycle/History.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/DeliveryPreviewModal.vue
git commit -m "feat(lifecycle): history tab with paginated list and preview modal"
```
---
## Task 14: Tab Configurações — guards form + Sofia por unidade
**Files:**
- Modify: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Settings.vue`
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/ConciergeUnitCard.vue`
- [ ] **Step 1: ConciergeUnitCard.vue**
```vue
<script setup>
import { ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CaptainLifecycleConfigAPI from 'dashboard/api/captain/lifecycleConfig';
const props = defineProps({ unit: { type: Object, required: true } });
const { t } = useI18n();
const inboxes = useMapGetter('inboxes/getWhatsAppInboxes');
const expanded = ref(false);
const conciergeInboxId = ref(props.unit.concierge_inbox_id || null);
const personaName = ref(props.unit.concierge_config?.persona_name || 'Sofia');
const knowledge = ref(props.unit.concierge_config?.knowledge || '');
const variables = ref(
Object.entries(props.unit.concierge_config?.variables || {}).map(([k, v]) => ({ k, v }))
);
const addVariable = () => variables.value.push({ k: '', v: '' });
const removeVariable = i => variables.value.splice(i, 1);
const save = async () => {
try {
const varsObj = Object.fromEntries(variables.value.filter(x => x.k).map(x => [x.k, x.v]));
await CaptainLifecycleConfigAPI.updateConcierge(props.unit.id, {
concierge_inbox_id: conciergeInboxId.value,
concierge_config: {
persona_name: personaName.value,
knowledge: knowledge.value,
variables: varsObj,
},
});
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.CONCIERGE_SAVED'));
} catch (e) {
useAlert(e.message || 'Erro ao salvar concierge');
}
};
</script>
<template>
<div class="border border-n-slate-4 rounded-lg p-4">
<div class="flex justify-between items-center cursor-pointer" @click="expanded = !expanded">
<div>
<div class="font-medium">{{ unit.name }}</div>
<div class="text-xs text-n-slate-11">
{{ unit.concierge_inbox_id ? '✓ configurado' : 'não configurado' }}
</div>
</div>
<span>{{ expanded ? '▾' : '▸' }}</span>
</div>
<div v-if="expanded" class="mt-4 space-y-3">
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_INBOX') }}
<select v-model="conciergeInboxId" class="w-full border rounded px-2 py-1">
<option :value="null"></option>
<option v-for="ib in inboxes" :key="ib.id" :value="ib.id">{{ ib.name }}</option>
</select>
</label>
<Input v-model="personaName" :label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_PERSONA')" />
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_KNOWLEDGE') }}
<textarea
v-model="knowledge"
rows="8"
class="w-full border rounded p-2 font-mono text-xs"
/>
</label>
<div>
<div class="text-sm font-medium mb-2">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLES') }}
</div>
<div v-for="(v, i) in variables" :key="i" class="flex gap-2 mb-2">
<input
v-model="v.k"
:placeholder="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_KEY')"
class="border rounded px-2 py-1 w-1/3"
/>
<input
v-model="v.v"
:placeholder="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_VALUE')"
class="border rounded px-2 py-1 flex-1"
/>
<Button variant="ghost" size="sm" @click="removeVariable(i)">×</Button>
</div>
<Button variant="outline" size="sm" @click="addVariable">
+ {{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_ADD_VARIABLE') }}
</Button>
</div>
<Button @click="save">{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}</Button>
</div>
</div>
</template>
```
**Nota:** verificar o nome exato do getter de inboxes WhatsApp. Se `inboxes/getWhatsAppInboxes` não existir, usar `inboxes/getInboxes` e filtrar por `channel_type === 'Channel::Whatsapp'`.
- [ ] **Step 2: Settings.vue (guards + lista de unidades)**
```vue
<script setup>
import { computed, onMounted, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConciergeUnitCard from './components/ConciergeUnitCard.vue';
const store = useStore();
const { t } = useI18n();
const config = useMapGetter('captainLifecycleConfig/getConfig');
const uiFlags = useMapGetter('captainLifecycleConfig/getUIFlags');
const units = useMapGetter('captainUnits/getUnits');
const labels = useMapGetter('labels/getLabels');
const form = ref({});
const syncForm = () => {
form.value = { ...config.value };
};
watch(config, syncForm, { immediate: true });
onMounted(() => {
store.dispatch('captainLifecycleConfig/fetch');
store.dispatch('captainUnits/get');
store.dispatch('labels/get');
});
const save = async () => {
try {
await store.dispatch('captainLifecycleConfig/update', {
quiet_hours_enabled: form.value.quiet_hours_enabled,
quiet_hours_from: form.value.quiet_hours_from,
quiet_hours_to: form.value.quiet_hours_to,
min_interval_minutes: Number(form.value.min_interval_minutes),
pause_on_customer_reply: form.value.pause_on_customer_reply,
pause_on_customer_reply_within_minutes: Number(form.value.pause_on_customer_reply_within_minutes),
opt_out_label_id: form.value.opt_out_label_id || null,
});
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.SAVED'));
} catch (e) {
useAlert(e.message || 'Erro');
}
};
</script>
<template>
<div class="p-6 space-y-8">
<section>
<h3 class="text-base font-semibold mb-3">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.GUARDS_TITLE') }}
</h3>
<div v-if="uiFlags.fetching"><Spinner /></div>
<div v-else class="space-y-3 max-w-xl">
<Checkbox
v-model="form.quiet_hours_enabled"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_ENABLED')"
/>
<div v-if="form.quiet_hours_enabled" class="flex gap-3">
<label class="flex-1">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_FROM') }}
<input v-model="form.quiet_hours_from" type="time" class="w-full border rounded px-2 py-1" />
</label>
<label class="flex-1">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_TO') }}
<input v-model="form.quiet_hours_to" type="time" class="w-full border rounded px-2 py-1" />
</label>
</div>
<Input
v-model="form.min_interval_minutes"
type="number"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL')"
:help-text="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL_HELP')"
/>
<Checkbox
v-model="form.pause_on_customer_reply"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY')"
/>
<Input
v-if="form.pause_on_customer_reply"
v-model="form.pause_on_customer_reply_within_minutes"
type="number"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY_WINDOW')"
/>
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.OPT_OUT_LABEL') }}
<select v-model="form.opt_out_label_id" class="w-full border rounded px-2 py-1">
<option :value="null"></option>
<option v-for="l in labels" :key="l.id" :value="l.id">{{ l.title }}</option>
</select>
</label>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.MAX_PER_RESERVATION_INFO') }}
</p>
<Button :disabled="uiFlags.updating" @click="save">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
</Button>
</div>
</section>
<section>
<h3 class="text-base font-semibold mb-3">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_TITLE') }}
</h3>
<div class="space-y-3">
<ConciergeUnitCard v-for="u in units" :key="u.id" :unit="u" />
</div>
</section>
</div>
</template>
```
**Nota:** confirmar os nomes dos stores/getters (`captainUnits/get`, `labels/get`, etc). Se um getter não existir, ajustar pro que existe (checar `store/modules/inboxes.js`, `store/modules/labels.js`).
- [ ] **Step 3: Testar manualmente**
- Abrir `/captain/lifecycle/settings`
- Primeiro acesso cria config default (max_per_reservation 5 aparece, quiet_hours desligado)
- Mudar valores e salvar → toast de sucesso
- Recarregar a página → valores persistidos
- Expandir um card de unidade, preencher inbox + persona + knowledge + 1 variável, salvar → checar via console Rails: `account.captain_units.first.concierge_config`
- [ ] **Step 4: Eslint**
```bash
pnpm run eslint app/javascript/dashboard/routes/dashboard/captain/lifecycle/Settings.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/ConciergeUnitCard.vue
```
- [ ] **Step 5: Commit**
```bash
git add app/javascript/dashboard/routes/dashboard/captain/lifecycle/Settings.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/ConciergeUnitCard.vue
git commit -m "feat(lifecycle): settings tab with guards form and concierge per unit"
```
---
## Task 15: Tab Regras — lista + templates prontos + wizard
**Files:**
- Modify: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/Rules.vue`
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/RuleWizardDialog.vue`
- Create: `app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/MessageEditor.vue`
- [ ] **Step 1: MessageEditor.vue — textarea com autocomplete de variáveis**
```vue
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { AVAILABLE_VARIABLES } from '../constants';
const props = defineProps({
modelValue: { type: String, default: '' },
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const textareaRef = ref(null);
const showAutocomplete = ref(false);
const autocompleteFilter = ref('');
const filteredVariables = computed(() => {
const q = autocompleteFilter.value.toLowerCase();
if (!q) return AVAILABLE_VARIABLES;
return AVAILABLE_VARIABLES.filter(v => v.key.toLowerCase().includes(q));
});
const onInput = e => {
const val = e.target.value;
emit('update:modelValue', val);
const caret = e.target.selectionStart;
const before = val.slice(0, caret);
const match = before.match(/\{\{\s*([a-zA-Z0-9_.]*)$/);
if (match) {
showAutocomplete.value = true;
autocompleteFilter.value = match[1];
} else {
showAutocomplete.value = false;
}
};
const insertVariable = key => {
const ta = textareaRef.value;
if (!ta) return;
const val = props.modelValue;
const caret = ta.selectionStart;
const before = val.slice(0, caret).replace(/\{\{\s*[a-zA-Z0-9_.]*$/, '');
const after = val.slice(caret);
const inserted = `{{ ${key} }}`;
emit('update:modelValue', before + inserted + after);
showAutocomplete.value = false;
};
</script>
<template>
<div class="relative">
<textarea
ref="textareaRef"
:value="modelValue"
rows="6"
class="w-full border rounded p-2 font-mono text-sm"
:placeholder="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.MESSAGE_BODY')"
@input="onInput"
/>
<div
v-if="showAutocomplete && filteredVariables.length"
class="absolute z-20 mt-1 bg-n-solid-1 border border-n-slate-4 rounded shadow-lg max-h-60 overflow-auto w-80"
>
<button
v-for="v in filteredVariables"
:key="v.key"
type="button"
class="w-full text-left px-3 py-2 hover:bg-n-alpha-2 text-xs"
@click="insertVariable(v.key)"
>
<span class="font-mono">{{ v.key }}</span>
<span class="block text-n-slate-11">{{ v.descKey }}</span>
</button>
</div>
</div>
</template>
```
- [ ] **Step 2: RuleWizardDialog.vue — 4 passos**
```vue
<script setup>
import { computed, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import MessageEditor from './MessageEditor.vue';
import { EVENTS, MESSAGE_TYPES, OFFSET_UNITS } from '../constants';
const props = defineProps({
rule: { type: Object, default: null },
});
const emit = defineEmits(['close', 'saved']);
const { t } = useI18n();
const store = useStore();
const units = useMapGetter('captainUnits/getUnits');
const step = ref(0);
const form = ref({
name: '',
description: '',
event: 'checkin.scheduled_at',
offset_value: 10,
offset_unit: 'minutes',
offset_direction: 'before',
enabled: true,
unit_ids: [],
categorias: [],
permanencias: [],
message_type: 'text',
message_body: '',
priority: 50,
});
watch(() => props.rule, r => {
if (!r) return;
const direction = r.offset_minutes < 0 ? 'before' : 'after';
const absMin = Math.abs(r.offset_minutes);
const unit = absMin % 1440 === 0 ? 'days' : absMin % 60 === 0 ? 'hours' : 'minutes';
const factor = OFFSET_UNITS.find(u => u.value === unit).factor;
form.value = {
name: r.name,
description: r.description || '',
event: r.event,
offset_value: absMin / factor,
offset_unit: unit,
offset_direction: direction,
enabled: r.enabled,
unit_ids: r.filters?.unit_ids || [],
categorias: r.filters?.categorias || [],
permanencias: r.filters?.permanencias || [],
message_type: r.message_type,
message_body: r.message_body,
priority: r.priority,
};
}, { immediate: true });
const offsetMinutes = computed(() => {
const factor = OFFSET_UNITS.find(u => u.value === form.value.offset_unit).factor;
const sign = form.value.offset_direction === 'before' ? -1 : 1;
return sign * Number(form.value.offset_value) * factor;
});
const canNext = computed(() => {
if (step.value === 0) return !!form.value.event && !!form.value.offset_value;
if (step.value === 1) return form.value.unit_ids.length > 0;
if (step.value === 2) return !!form.value.name && !!form.value.message_body;
return true;
});
const next = () => step.value < 3 && (step.value += 1);
const back = () => step.value > 0 && (step.value -= 1);
const save = async () => {
const payload = {
name: form.value.name,
description: form.value.description,
event: form.value.event,
offset_minutes: offsetMinutes.value,
enabled: form.value.enabled,
filters: {
unit_ids: form.value.unit_ids,
categorias: form.value.categorias,
permanencias: form.value.permanencias,
},
message_type: form.value.message_type,
message_body: form.value.message_body,
priority: Number(form.value.priority),
};
try {
if (props.rule?.id) {
await store.dispatch('captainLifecycleRules/update', { id: props.rule.id, ...payload });
useAlert(t('CAPTAIN_LIFECYCLE.RULES.TOAST.UPDATED'));
} else {
await store.dispatch('captainLifecycleRules/create', payload);
useAlert(t('CAPTAIN_LIFECYCLE.RULES.TOAST.CREATED'));
}
emit('saved');
} catch (e) {
useAlert(e.message || 'Erro');
}
};
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/40" @click.self="emit('close')">
<div class="bg-n-solid-1 rounded-xl p-6 w-[680px] max-h-[90vh] overflow-auto shadow-xl">
<h3 class="text-lg font-semibold mb-4">
{{ props.rule ? t('CAPTAIN_LIFECYCLE.RULES.WIZARD.TITLE_EDIT') : t('CAPTAIN_LIFECYCLE.RULES.WIZARD.TITLE_CREATE') }}
</h3>
<ol class="flex gap-2 mb-6 text-xs">
<li :class="step === 0 ? 'font-bold' : ''">1. {{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.STEP_WHEN') }}</li>
<li :class="step === 1 ? 'font-bold' : ''">2. {{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.STEP_WHO') }}</li>
<li :class="step === 2 ? 'font-bold' : ''">3. {{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.STEP_WHAT') }}</li>
<li :class="step === 3 ? 'font-bold' : ''">4. {{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.STEP_REVIEW') }}</li>
</ol>
<!-- Step 0: When -->
<div v-if="step === 0" class="space-y-3">
<label class="block">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.EVENT') }}
<select v-model="form.event" class="w-full border rounded px-2 py-1">
<option v-for="e in EVENTS" :key="e.value" :value="e.value">{{ t(e.labelKey) }}</option>
</select>
</label>
<div class="grid grid-cols-3 gap-3">
<Input v-model="form.offset_value" type="number" :label="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.OFFSET_VALUE')" />
<label class="block">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.OFFSET_UNIT') }}
<select v-model="form.offset_unit" class="w-full border rounded px-2 py-1">
<option v-for="u in OFFSET_UNITS" :key="u.value" :value="u.value">
{{ t(`CAPTAIN_LIFECYCLE.RULES.WIZARD.OFFSET_UNITS.${u.value.toUpperCase()}`) }}
</option>
</select>
</label>
<label class="block">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.OFFSET_DIRECTION') }}
<select v-model="form.offset_direction" class="w-full border rounded px-2 py-1">
<option value="before">{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.OFFSET_DIRECTIONS.BEFORE') }}</option>
<option value="after">{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.OFFSET_DIRECTIONS.AFTER') }}</option>
</select>
</label>
</div>
</div>
<!-- Step 1: Who -->
<div v-else-if="step === 1" class="space-y-3">
<label class="block">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.UNITS') }}
<div class="space-y-1 mt-2">
<label v-for="u in units" :key="u.id" class="flex items-center gap-2 text-sm">
<input type="checkbox" :value="u.id" v-model="form.unit_ids" />
{{ u.name }}
</label>
</div>
</label>
<Input
v-model="form.categorias"
:label="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.CATEGORIAS')"
placeholder="Alexa, Stilo (separar por vírgula)"
@update:model-value="v => form.categorias = typeof v === 'string' ? v.split(',').map(s => s.trim()).filter(Boolean) : v"
/>
<Input
v-model="form.permanencias"
:label="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.PERMANENCIAS')"
placeholder="Pernoite, 2hrs"
@update:model-value="v => form.permanencias = typeof v === 'string' ? v.split(',').map(s => s.trim()).filter(Boolean) : v"
/>
</div>
<!-- Step 2: What -->
<div v-else-if="step === 2" class="space-y-3">
<Input v-model="form.name" :label="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.NAME')" />
<Input v-model="form.description" :label="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.DESCRIPTION')" />
<label class="block">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.MESSAGE_TYPE') }}
<select v-model="form.message_type" class="w-full border rounded px-2 py-1">
<option v-for="m in MESSAGE_TYPES" :key="m.value" :value="m.value">{{ t(m.labelKey) }}</option>
</select>
</label>
<div>
<div class="text-sm mb-1">{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.MESSAGE_BODY') }}</div>
<MessageEditor v-model="form.message_body" />
</div>
<Checkbox v-model="form.enabled" :label="t('CAPTAIN_LIFECYCLE.RULES.WIZARD.FIELDS.ENABLED')" />
</div>
<!-- Step 3: Review -->
<div v-else class="space-y-2 text-sm">
<div><strong>Nome:</strong> {{ form.name }}</div>
<div><strong>Evento:</strong> {{ form.event }}</div>
<div><strong>Offset (min):</strong> {{ offsetMinutes }}</div>
<div><strong>Unidades:</strong> {{ form.unit_ids.join(', ') }}</div>
<div><strong>Mensagem:</strong></div>
<pre class="p-3 bg-n-alpha-2 rounded whitespace-pre-wrap">{{ form.message_body }}</pre>
</div>
<div class="flex justify-between mt-6">
<Button variant="outline" :disabled="step === 0" @click="back">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.BACK') }}
</Button>
<div class="flex gap-2">
<Button variant="outline" @click="emit('close')">Cancelar</Button>
<Button v-if="step < 3" :disabled="!canNext" @click="next">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.NEXT') }}
</Button>
<Button v-else @click="save">
{{ t('CAPTAIN_LIFECYCLE.RULES.WIZARD.SAVE') }}
</Button>
</div>
</div>
</div>
</div>
</template>
```
- [ ] **Step 3: Rules.vue — lista + templates + botão**
```vue
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import RuleWizardDialog from './components/RuleWizardDialog.vue';
import { RULE_TEMPLATES } from './constants';
const store = useStore();
const { t } = useI18n();
const rules = useMapGetter('captainLifecycleRules/getRecords');
const uiFlags = useMapGetter('captainLifecycleRules/getUIFlags');
const showWizard = ref(false);
const editing = ref(null);
onMounted(() => {
store.dispatch('captainLifecycleRules/get');
store.dispatch('captainUnits/get');
});
const openCreate = () => {
editing.value = null;
showWizard.value = true;
};
const openEdit = rule => {
editing.value = rule;
showWizard.value = true;
};
const openFromTemplate = tpl => {
editing.value = {
id: null,
name: tpl.name,
event: tpl.event,
offset_minutes: tpl.offset_minutes,
message_type: tpl.message_type,
message_body: tpl.message_body,
enabled: true,
filters: {},
priority: 50,
};
showWizard.value = true;
};
const onSaved = () => {
showWizard.value = false;
store.dispatch('captainLifecycleRules/get');
};
const toggle = async rule => {
await store.dispatch('captainLifecycleRules/update', { id: rule.id, enabled: !rule.enabled });
};
const remove = async rule => {
if (!confirm(t('CAPTAIN_LIFECYCLE.RULES.DELETE_CONFIRM'))) return;
await store.dispatch('captainLifecycleRules/delete', rule.id);
useAlert(t('CAPTAIN_LIFECYCLE.RULES.TOAST.DELETED'));
};
const isLoading = computed(() => uiFlags.value.fetchingList);
</script>
<template>
<div class="p-6 space-y-6">
<section>
<h3 class="text-sm font-semibold mb-3 text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.RULES.TEMPLATES_TITLE') }}
</h3>
<div class="grid grid-cols-3 gap-3">
<button
v-for="tpl in RULE_TEMPLATES"
:key="tpl.id"
type="button"
class="text-left p-3 border border-n-slate-4 rounded-lg hover:border-n-iris-9"
@click="openFromTemplate(tpl)"
>
<div class="font-medium text-sm">{{ tpl.name }}</div>
<div class="text-xs text-n-slate-11 mt-1">{{ tpl.event }} {{ tpl.offset_minutes >= 0 ? '+' : '' }}{{ tpl.offset_minutes }}min</div>
</button>
</div>
</section>
<section>
<div class="flex justify-between items-center mb-3">
<h3 class="text-base font-semibold">Regras</h3>
<Button @click="openCreate">{{ t('CAPTAIN_LIFECYCLE.RULES.CREATE') }}</Button>
</div>
<div v-if="isLoading"><Spinner /></div>
<div v-else-if="rules.length === 0" class="text-center py-8 text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.RULES.EMPTY') }}
</div>
<table v-else class="w-full text-sm">
<thead class="text-left text-n-slate-11">
<tr>
<th class="py-2">{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.NAME') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.EVENT') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.OFFSET') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.STATUS') }}</th>
<th>{{ t('CAPTAIN_LIFECYCLE.RULES.COLUMNS.ACTIONS') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="r in rules" :key="r.id" class="border-t border-n-slate-4">
<td class="py-2">{{ r.name }}</td>
<td>{{ r.event }}</td>
<td>{{ r.offset_minutes }}min</td>
<td>
{{ r.enabled
? t('CAPTAIN_LIFECYCLE.RULES.STATUS.ENABLED')
: t('CAPTAIN_LIFECYCLE.RULES.STATUS.DISABLED') }}
</td>
<td class="flex gap-2">
<Button size="sm" variant="ghost" @click="openEdit(r)">
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.EDIT') }}
</Button>
<Button size="sm" variant="ghost" @click="toggle(r)">
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.TOGGLE') }}
</Button>
<Button size="sm" variant="ghost" @click="remove(r)">
{{ t('CAPTAIN_LIFECYCLE.RULES.ACTIONS.DELETE') }}
</Button>
</td>
</tr>
</tbody>
</table>
</section>
<RuleWizardDialog
v-if="showWizard"
:rule="editing"
@close="showWizard = false"
@saved="onSaved"
/>
</div>
</template>
```
- [ ] **Step 4: Testar manualmente ponta-a-ponta**
- `/captain/lifecycle/rules` → vazia, mostra templates + botão "Nova regra"
- Clicar em um template → wizard abre com campos pré-preenchidos no passo 3 (mas começa no passo 0 de qualquer forma)
- Preencher wizard, chegar no review, salvar → toast criado, regra aparece na lista
- Editar → wizard abre com os valores, alterar nome, salvar → toast atualizado
- No campo de mensagem, digitar `{{` → autocomplete aparece, clicar numa variável → texto preenchido corretamente
- Excluir → confirmação, toast, some da lista
- Refresh → persiste
- [ ] **Step 5: Eslint**
```bash
pnpm run eslint app/javascript/dashboard/routes/dashboard/captain/lifecycle/Rules.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/RuleWizardDialog.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/MessageEditor.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/constants.js
```
- [ ] **Step 6: Commit**
```bash
git add app/javascript/dashboard/routes/dashboard/captain/lifecycle/Rules.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/RuleWizardDialog.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/components/MessageEditor.vue \
app/javascript/dashboard/routes/dashboard/captain/lifecycle/constants.js
git commit -m "feat(lifecycle): rules tab with templates, wizard and variable autocomplete"
```
---
## Task 16: Validação final — specs + lint + smoke manual
**Files:** nenhum novo.
- [ ] **Step 1: Rodar suite lifecycle (backend + novos controllers)**
```bash
eval "$(rbenv init - zsh)" && rbenv shell 3.4.4 && bundle exec rspec \
spec/models/account_spec.rb \
spec/routing/captain_lifecycle_routes_spec.rb \
spec/enterprise/policies/captain/lifecycle \
spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_rules_controller_spec.rb \
spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_configs_controller_spec.rb \
spec/enterprise/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller_spec.rb \
spec/enterprise/controllers/api/v1/accounts/captain/units_controller_spec.rb \
spec/enterprise/models/captain/lifecycle \
spec/enterprise/services/captain/lifecycle \
spec/enterprise/jobs/captain/lifecycle
```
Esperado: 0 failures.
- [ ] **Step 2: Regression gate**
```bash
bundle exec rspec spec/enterprise/models/captain/reservation_spec.rb \
spec/models/account_spec.rb
bundle exec rubocop app/models/account.rb \
enterprise/app/controllers/api/v1/accounts/captain/lifecycle_rules_controller.rb \
enterprise/app/controllers/api/v1/accounts/captain/lifecycle_configs_controller.rb \
enterprise/app/controllers/api/v1/accounts/captain/lifecycle_deliveries_controller.rb \
enterprise/app/controllers/api/v1/accounts/captain/units_controller.rb \
enterprise/app/policies/captain/lifecycle
```
Esperado: green.
- [ ] **Step 3: Lint frontend completo da nova área**
```bash
pnpm run eslint app/javascript/dashboard/routes/dashboard/captain/lifecycle \
app/javascript/dashboard/api/captain/lifecycleRules.js \
app/javascript/dashboard/api/captain/lifecycleConfig.js \
app/javascript/dashboard/api/captain/lifecycleDeliveries.js \
app/javascript/dashboard/store/captain/lifecycleRules.js \
app/javascript/dashboard/store/captain/lifecycleConfig.js \
app/javascript/dashboard/store/captain/lifecycleDeliveries.js \
app/javascript/dashboard/components-next/sidebar/Sidebar.vue
```
Esperado: 0 errors.
- [ ] **Step 4: Smoke end-to-end manual**
Com `pnpm run dev` rodando e uma conta de teste:
1. Sidebar → Captain → "Jornada do Cliente" → abre na tab Regras
2. Criar regra de template "Lembrete pré check-in" → preencher wizard → salvar
3. Tab Configurações → habilitar quiet hours 22:00-08:00, salvar → toast
4. Expandir card da primeira unit → setar inbox, persona "Sofia", adicionar 1 variável wifi_password → salvar → toast
5. Via console Rails: `Captain::Reservation.create!(account: ..., unit: ..., contact: ..., check_in_at: 15.minutes.from_now, ...)` — delivery é agendada automaticamente
6. Tab Histórico → ver delivery com status `scheduled`, clicar "Preview" → modal com rendered body vazio (ainda não disparou)
7. Voltar no console: `Captain::Lifecycle::DispatcherJob.perform_now(delivery.id)` → delivery passa pra `sent` ou `skipped` (conforme guards)
8. Refresh tab Histórico → status atualizado, preview mostra rendered body com variáveis substituídas
9. Voltar em Regras → desativar a regra pelo botão "Ativar/Desativar" → status muda pra "Desativado"
10. Excluir a regra → confirmação, some da lista
Se algum passo falhar, diagnosticar via:
- DevTools console do browser
- `tail -f log/development.log` no Rails
- Checar delivery row direto via console: `Captain::Lifecycle::Delivery.last`
- [ ] **Step 5: Commit final (se necessário) + handoff**
Se tudo verde, sem commits extras. Caso haja pequenas correções durante smoke:
```bash
git add -u
git commit -m "fix(lifecycle): smoke test adjustments"
```
**Entregar pro user:** "Fase B (UI Jornada do Cliente) completa. Rodei specs + lint + smoke ponta-a-ponta. [Listar ajustes feitos durante smoke, se algum]."
---
## Notas finais
- **Accessibilidade:** o plano usa `<select>`, `<textarea>`, `<input type="checkbox">` nativos pra simplicidade. Pode ser melhorado depois pra componentes acessíveis do design system.
- **Dark mode:** as classes `bg-n-solid-1`, `text-n-slate-11`, `border-n-slate-4` são tokens do Chatwoot e já cobrem light/dark. Não inventar cores hex.
- **`fazer.ai` minúsculo:** nenhuma copy introduzida no plano menciona a marca, mas se surgir no smoke ou em toasts: sempre `fazer.ai`.
- **Tests Vitest:** não há specs de component Vue neste plano porque o projeto não tem cobertura histórica de components Captain. Se o reviewer quiser, um spec mínimo pra `MessageEditor` (insertVariable unit test) é viável — fora do escopo por decisão de pragmatismo.
- **Nomes de getters de unit/label/inbox:** verificar os exatos (`captainUnits/getUnits`, `labels/getLabels`, `inboxes/getWhatsAppInboxes`) durante implementação; se divergirem, ajustar inline sem reescrever o plano.