feat(captain): permite criacao manual de reserva via painel e conversa

This commit is contained in:
Rodrigo Borba 2026-03-01 03:07:44 -03:00
parent cbb39a4db5
commit 7108bb135e
11 changed files with 370 additions and 5 deletions

View File

@ -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}`);
}

View File

@ -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",

View File

@ -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",

View File

@ -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(() => {
<template>
<PageLayout
:header-title="$t('CAPTAIN_RESERVATIONS.HEADER')"
:button-label="$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.TITLE')"
:feature-flag="FEATURE_FLAGS.CAPTAIN"
:is-fetching="isPageFetching"
:is-empty="isRevenueView ? !hasRevenueData : !reservations.length"
@ -295,6 +298,7 @@ onMounted(() => {
:current-page="isRevenueView ? 1 : reservationsMeta.page"
:show-know-more="false"
:show-assistant-switcher="false"
@click="showNewReservationModal = true"
@update:current-page="onPageChange"
>
<template #controls>
@ -634,4 +638,9 @@ onMounted(() => {
</div>
</template>
</PageLayout>
<NewReservationModal
v-if="showNewReservationModal"
@close="showNewReservationModal = false"
@success="fetchReservations(1)"
/>
</template>

View File

@ -0,0 +1,198 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
const props = defineProps({
prefilledContactId: {
type: [Number, String],
default: '',
},
prefilledInboxId: {
type: [Number, String],
default: '',
},
});
const emit = defineEmits(['close', 'success']);
const store = useStore();
const { t } = useI18n();
const units = useMapGetter('captainUnits/getUnits');
const inboxes = useMapGetter('inboxes/getInboxes');
const isLoading = ref(false);
const form = ref({
contact_id: '',
inbox_id: '',
captain_unit_id: '',
suite_identifier: '',
check_in_at: '',
check_out_at: '',
total_amount: '',
});
watch(
() => props.prefilledContactId,
val => {
if (val) form.value.contact_id = val;
},
{ immediate: true }
);
watch(
() => props.prefilledInboxId,
val => {
if (val) form.value.inbox_id = val;
},
{ immediate: true }
);
const unitOptions = computed(() => {
return (units.value || []).map(unit => ({
label: unit.name,
value: unit.id,
}));
});
const inboxOptions = computed(() => {
return (inboxes.value || []).map(inbox => ({
label: inbox.name,
value: inbox.id,
}));
});
const closeModal = () => {
emit('close');
};
const submitReservation = async () => {
const payload = { ...form.value };
// Convert empty values to null or appropriate types if needed
if (!payload.total_amount) payload.total_amount = 0;
isLoading.value = true;
try {
await store.dispatch('captainReservations/create', payload);
useAlert(t('CAPTAIN_RESERVATIONS.CREATE_SUCCESS'));
emit('success');
closeModal();
} catch (error) {
useAlert(error.message || t('CAPTAIN_RESERVATIONS.CREATE_ERROR'));
} finally {
isLoading.value = false;
}
};
</script>
<template>
<Dialog
:title="$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.TITLE')"
:confirm-button-label="
$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.CONFIRM')
"
:cancel-button-label="
$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.CANCEL')
"
:is-loading="isLoading"
@confirm="submitReservation"
@close="closeModal"
>
<div class="space-y-4">
<!-- Contact ID -->
<Input
v-model="form.contact_id"
type="number"
:label="
$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.CONTACT_ID')
"
:placeholder="
$t(
'CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.CONTACT_ID_PLACEHOLDER'
)
"
:disabled="!!props.prefilledContactId"
/>
<!-- Inbox -->
<div>
<label class="block mb-1 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.INBOX') }}
</label>
<ComboBox
v-model="form.inbox_id"
:options="inboxOptions"
:disabled="!!props.prefilledInboxId"
:placeholder="
$t(
'CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.INBOX_PLACEHOLDER'
)
"
/>
</div>
<!-- Unit -->
<div>
<label class="block mb-1 text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.UNIT') }}
</label>
<ComboBox
v-model="form.captain_unit_id"
:options="unitOptions"
:placeholder="
$t(
'CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.UNIT_PLACEHOLDER'
)
"
/>
</div>
<!-- Suite Identifier -->
<Input
v-model="form.suite_identifier"
type="text"
:label="
$t(
'CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.SUITE_IDENTIFIER'
)
"
/>
<!-- Check In / Check Out -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<Input
v-model="form.check_in_at"
type="datetime-local"
:label="
$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.CHECK_IN')
"
/>
<Input
v-model="form.check_out_at"
type="datetime-local"
:label="
$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.CHECK_OUT')
"
/>
</div>
<!-- Total Amount -->
<Input
v-model="form.total_amount"
type="number"
step="0.01"
:label="
$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.FIELDS.TOTAL_AMOUNT')
"
/>
</div>
</Dialog>
</template>

View File

@ -232,7 +232,11 @@ onMounted(() => {
toggleSidebarUIState('is_reservation_summary_open', value)
"
>
<ReservationSummary :marker="reservationMarker" />
<ReservationSummary
:marker="reservationMarker"
:contact-id="contactId"
:inbox-id="inboxId"
/>
</AccordionItem>
</div>
<div v-else-if="element.name === 'contact_attributes'">

View File

@ -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 () => {
<template>
<div class="flex flex-col gap-2 text-sm">
<p v-if="!hasMarker" class="text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.SIDEBAR.NO_RESERVATION') }}
</p>
<div v-if="!hasMarker" class="flex flex-col gap-3 py-1">
<p class="text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.SIDEBAR.NO_RESERVATION') }}
</p>
<Button
size="xs"
variant="outline"
icon="plus"
:label="$t('CAPTAIN_RESERVATIONS.NEW_RESERVATION_MODAL.TITLE')"
@click="showNewReservationModal = true"
/>
</div>
<div v-else-if="isLoading" class="text-n-slate-11">
{{ $t('CAPTAIN_RESERVATIONS.SIDEBAR.LOADING') }}
@ -177,4 +196,12 @@ const onCopyPix = async () => {
</div>
</template>
</div>
<NewReservationModal
v-if="showNewReservationModal"
:prefilled-contact-id="contactId"
:prefilled-inbox-id="inboxId"
@close="showNewReservationModal = false"
@success="showNewReservationModal = false"
/>
</template>

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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).