feat(captain): permite criacao manual de reserva via painel e conversa
This commit is contained in:
parent
cbb39a4db5
commit
7108bb135e
@ -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}`);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
@ -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'">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
40
progresso/reserva_manual.md
Normal file
40
progresso/reserva_manual.md
Normal 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).
|
||||
Loading…
Reference in New Issue
Block a user