From 7108bb135e9d1955d56451a273bcc979ece00288 Mon Sep 17 00:00:00 2001 From: Rodrigo Borba Date: Sun, 1 Mar 2026 03:07:44 -0300 Subject: [PATCH] feat(captain): permite criacao manual de reserva via painel e conversa --- .../dashboard/api/captain/reservations.js | 4 + .../dashboard/i18n/locale/en/captain.json | 19 ++ .../dashboard/i18n/locale/pt_BR/captain.json | 19 ++ .../dashboard/captain/reservations/Index.vue | 9 + .../components/NewReservationModal.vue | 198 ++++++++++++++++++ .../dashboard/conversation/ContactPanel.vue | 6 +- .../reservation/ReservationSummary.vue | 33 ++- .../dashboard/store/captain/reservations.js | 8 + config/routes.rb | 2 +- .../captain/reservations_controller.rb | 37 ++++ progresso/reserva_manual.md | 40 ++++ 11 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/captain/reservations/components/NewReservationModal.vue create mode 100644 progresso/reserva_manual.md diff --git a/app/javascript/dashboard/api/captain/reservations.js b/app/javascript/dashboard/api/captain/reservations.js index f37e78612..c2e616f94 100644 --- a/app/javascript/dashboard/api/captain/reservations.js +++ b/app/javascript/dashboard/api/captain/reservations.js @@ -10,6 +10,10 @@ class CaptainReservations extends ApiClient { return axios.get(this.url, { params }); } + create(data) { + return axios.post(this.url, { reservation: data }); + } + show(id) { return axios.get(`${this.url}/${id}`); } diff --git a/app/javascript/dashboard/i18n/locale/en/captain.json b/app/javascript/dashboard/i18n/locale/en/captain.json index e150633f9..683ce7bb6 100644 --- a/app/javascript/dashboard/i18n/locale/en/captain.json +++ b/app/javascript/dashboard/i18n/locale/en/captain.json @@ -2,6 +2,25 @@ "CAPTAIN_RESERVATIONS": { "HEADER": "Reservations", "EMPTY": "No reservations found.", + "CREATE_SUCCESS": "Reservation successfully created.", + "CREATE_ERROR": "Failed to create reservation.", + "NEW_RESERVATION_MODAL": { + "TITLE": "New Reservation", + "CONFIRM": "Create Reservation", + "CANCEL": "Cancel", + "FIELDS": { + "CONTACT_ID": "Contact ID", + "CONTACT_ID_PLACEHOLDER": "Enter the contact ID", + "INBOX": "Inbox", + "INBOX_PLACEHOLDER": "Select inbox", + "UNIT": "Unit", + "UNIT_PLACEHOLDER": "Select unit", + "SUITE_IDENTIFIER": "Suite Identifier", + "CHECK_IN": "Check-in", + "CHECK_OUT": "Check-out", + "TOTAL_AMOUNT": "Total Amount" + } + }, "VIEW": { "LIST": "List", "KANBAN": "Kanban", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json index e70033a3d..5a1d0e906 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json @@ -2,6 +2,25 @@ "CAPTAIN_RESERVATIONS": { "HEADER": "Reservas", "EMPTY": "Nenhuma reserva encontrada.", + "CREATE_SUCCESS": "Reserva criada com sucesso.", + "CREATE_ERROR": "Erro ao criar reserva.", + "NEW_RESERVATION_MODAL": { + "TITLE": "Nova Reserva", + "CONFIRM": "Criar Reserva", + "CANCEL": "Cancelar", + "FIELDS": { + "CONTACT_ID": "ID do Contato", + "CONTACT_ID_PLACEHOLDER": "Digite o ID do contato", + "INBOX": "Canal (Caixa de Entrada)", + "INBOX_PLACEHOLDER": "Selecione a caixa", + "UNIT": "Unidade", + "UNIT_PLACEHOLDER": "Selecione a unidade", + "SUITE_IDENTIFIER": "Identificador da Suíte", + "CHECK_IN": "Check-in", + "CHECK_OUT": "Check-out", + "TOTAL_AMOUNT": "Valor Total" + } + }, "VIEW": { "LIST": "Lista", "KANBAN": "Kanban", diff --git a/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue index 27758b40d..d1cc98020 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue @@ -12,6 +12,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Input from 'dashboard/components-next/input/Input.vue'; import Spinner from 'dashboard/components-next/spinner/Spinner.vue'; import BarChart from 'shared/components/charts/BarChart.vue'; +import NewReservationModal from './components/NewReservationModal.vue'; const store = useStore(); const route = useRoute(); @@ -34,6 +35,7 @@ const unitId = ref(''); const suite = ref(''); const sort = ref(''); const isFetchingRevenue = ref(false); +const showNewReservationModal = ref(false); const emptyRevenue = () => ({ summary: { @@ -281,6 +283,7 @@ onMounted(() => { diff --git a/app/javascript/dashboard/routes/dashboard/captain/reservations/components/NewReservationModal.vue b/app/javascript/dashboard/routes/dashboard/captain/reservations/components/NewReservationModal.vue new file mode 100644 index 000000000..a3cac16b5 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/captain/reservations/components/NewReservationModal.vue @@ -0,0 +1,198 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue index 415663858..ea1548a29 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue @@ -232,7 +232,11 @@ onMounted(() => { toggleSidebarUIState('is_reservation_summary_open', value) " > - +
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/reservation/ReservationSummary.vue b/app/javascript/dashboard/routes/dashboard/conversation/reservation/ReservationSummary.vue index fb20a7fd5..848dc987a 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/reservation/ReservationSummary.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/reservation/ReservationSummary.vue @@ -5,12 +5,21 @@ import { useI18n } from 'vue-i18n'; import { useAlert } from 'dashboard/composables'; import Button from 'dashboard/components-next/button/Button.vue'; import { copyTextToClipboard } from 'shared/helpers/clipboard'; +import NewReservationModal from 'dashboard/routes/dashboard/captain/reservations/components/NewReservationModal.vue'; const props = defineProps({ marker: { type: Object, default: () => ({}), }, + contactId: { + type: [Number, String], + default: null, + }, + inboxId: { + type: [Number, String], + default: null, + }, }); const store = useStore(); @@ -18,6 +27,7 @@ const { t } = useI18n(); const reservation = ref(null); const isLoading = ref(false); +const showNewReservationModal = ref(false); const reservationId = computed(() => props.marker?.reservation_id); const hasMarker = computed(() => !!props.marker?.visible); @@ -106,9 +116,18 @@ const onCopyPix = async () => {
+ + diff --git a/app/javascript/dashboard/store/captain/reservations.js b/app/javascript/dashboard/store/captain/reservations.js index ced5b0dd3..ffd3e188d 100644 --- a/app/javascript/dashboard/store/captain/reservations.js +++ b/app/javascript/dashboard/store/captain/reservations.js @@ -14,6 +14,14 @@ export default createStore({ return throwErrorMessage(error); } }, + create: async function create(_, data) { + try { + const response = await CaptainReservationsAPI.create(data); + return response.data; + } catch (error) { + return throwErrorMessage(error); + } + }, fetchPix: async function fetchPix({ commit }, reservationId) { commit(mutations.SET_UI_FLAG, { fetchingItem: true }); try { diff --git a/config/routes.rb b/config/routes.rb index f76ae4e4c..7ec4406df 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -75,7 +75,7 @@ Rails.application.routes.draw do resources :custom_tools resources :documents, only: [:index, :show, :create, :destroy] resources :gallery_items - resources :reservations, only: [:index, :show] do + resources :reservations, only: [:index, :show, :create] do collection do get :revenue end diff --git a/enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb index c267456dd..64427a1d4 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb @@ -27,6 +27,23 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba @marker = Captain::Reservations::MarkerBuilder.build_for(@reservation) end + def create + ActiveRecord::Base.transaction do + @reservation = @reservations_scope.new(create_params) + @reservation.created_by = current_user + @reservation.metadata ||= {} + + ensure_contact_inbox! + + if @reservation.save + @marker = Captain::Reservations::MarkerBuilder.build_for(@reservation) + render 'api/v1/accounts/captain/reservations/show' + else + render json: { error: @reservation.errors.full_messages.join(', ') }, status: :unprocessable_entity + end + end + end + def pix marker = Captain::Reservations::MarkerBuilder.build_for(@reservation) render json: { @@ -153,4 +170,24 @@ class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::Ba :page, :per_page, :sort, :direction ) end + + def create_params + params.require(:reservation).permit( + :contact_id, :inbox_id, :captain_unit_id, :suite_identifier, + :check_in_at, :check_out_at, :total_amount + ) + end + + def ensure_contact_inbox! + return if @reservation.contact_inbox_id.present? || @reservation.contact_id.blank? || @reservation.inbox_id.blank? + + contact_inbox = ContactInbox.find_or_create_by!( + contact_id: @reservation.contact_id, + inbox_id: @reservation.inbox_id + ) do |ci| + ci.source_id = SecureRandom.uuid + end + + @reservation.contact_inbox_id = contact_inbox.id + end end diff --git a/progresso/reserva_manual.md b/progresso/reserva_manual.md new file mode 100644 index 000000000..c36d89f8a --- /dev/null +++ b/progresso/reserva_manual.md @@ -0,0 +1,40 @@ +# Criação de Reservas Manuais + +**Objetivo:** +Permitir que as recepcionistas possam criar reservas manualmente (bypassando o comportamento automático da IA), incluindo a associação direta a uma caixa de entrada (inbox) e um contato, além de preencher dados como check-in, check-out e status. + +**Contexto:** +Antes, a rotina de criação de reservas ocorria exclusivamente de forma automatizada via `AiReservationMessageWorker` ou fluxos da IA. Havia necessidade de que, caso a IA não tenha executado a ação, a equipe pudesse criar a reserva pela própria UI. + +**Passos:** +1. **Model & Endpoint (Backend):** + - Confirmado que o model já possuía `inbox_id` (`belongs_to :inbox`). + - Criamos o método `create` no `Api::V1::Accounts::Captain::ReservationsController` para suportar `POST`, que além de tudo garante a criação de um `ContactInbox` na caixa de entrada fornecida se o contato não existisse ainda no pool da referida caixa. (Método privado `create_params` validando inputs usando Strong Parameters). + - Adicionamento de `post :create` no `config/routes.rb` para a rota namespace `captain/reservations`. + +2. **Store & API (Frontend):** + - No arquivo `captain/reservations.js` da API, introduzido método assíncrono genérico `.create(...)` postando ao endpoint. + - Adicionado no *Vuex Store* (`dashboard/store/captain/reservations.js`) a action `create` que despacha a requisição e sinaliza mensagens de erro/sucesso. + +3. **Componente de Modal & Tradução:** + - Adicionada as labels de tradução JSON de Reservas em PT e EN (Ex: `CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL`). + - Criado o arquivo `NewReservationModal.vue` usando componentes globais modulares (`Dialog`, `Input`, `ComboBox` e etc) e validando props de injeção direta de `inbox-id` e `contact-id`. + +4. **Integração nas Telas (Views):** + - **`Index.vue`** (Reservas em Lista Geral): Renderiza o subcomponente modal em overlay disparado pelo Header Button "Nova Reserva", onde recarrega as reservas (`fetchReservations(1)`) após inserção de sucesso. + - **`ReservationSummary.vue`** (Painel lateral das conversas): Disponibilizado um novo botão que repassa diretamente os identificadores do Contato Atual e a Inbox Atual para que o formulário da Reserva seja gerado já pré-povoado e associado devidamente, exibido caso `!hasMarker`. + +**Principais Códigos e Arquivos Alterados:** +- `app/javascript/dashboard/routes/dashboard/captain/reservations/components/NewReservationModal.vue` +- `app/javascript/dashboard/routes/dashboard/captain/reservations/Index.vue` +- `app/javascript/dashboard/routes/dashboard/conversation/reservation/ReservationSummary.vue` +- `app/javascript/dashboard/routes/dashboard/conversation/ContactPanel.vue` +- `enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb` +- `config/routes.rb` +- Arquivos de tradução (pt_BR, en `captain.json`) +- `app/javascript/dashboard/store/captain/reservations.js` +- `app/javascript/dashboard/api/captain/reservations.js` + +**Como validar ou reverter:** +1. **Validar:** Acessar a página global das IA Reservations ou o painel lateral de uma conversa e tocar em "Nova Reserva". Crie preenchendo as informações, e se aparecer as mensagens de confirmação sem console log error, a UI consumiu o Backend corretamente. +2. **Reverter:** Realizar um `$ git revert` desta feature ou dar Rollback / desfazer os commits. Nenhuma migração de database extra foi executada neste processo (portanto, safe revert).