feat: Adiciona sincronização de reservas, novos campos para unidades Captain e associa inboxes a unidades."
This commit is contained in:
parent
18a4bebca1
commit
1f3d1dbcfe
@ -0,0 +1,21 @@
|
||||
module Api
|
||||
module V1
|
||||
module Accounts
|
||||
module Captain
|
||||
module Units
|
||||
class ReservationsSyncController < Api::V1::Accounts::BaseController
|
||||
def create
|
||||
unit = Current.account.captain_units.find(params[:unit_id])
|
||||
::Captain::Reservations::SyncService.new(unit).perform
|
||||
head :ok
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render_not_found_error('Unit not found')
|
||||
rescue StandardError => e
|
||||
render_error(e.message)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
65
app/controllers/api/v1/accounts/captain/units_controller.rb
Normal file
65
app/controllers/api/v1/accounts/captain/units_controller.rb
Normal file
@ -0,0 +1,65 @@
|
||||
module Api
|
||||
module V1
|
||||
module Accounts
|
||||
module Captain
|
||||
class UnitsController < Api::V1::Accounts::BaseController
|
||||
def index
|
||||
@units = Current.account.captain_units
|
||||
end
|
||||
|
||||
def show
|
||||
@unit = Current.account.captain_units.find(params[:id])
|
||||
end
|
||||
|
||||
def create
|
||||
@unit = Current.account.captain_units.new(unit_params)
|
||||
@unit.captain_brand = Current.account.captain_brands.first # Default brand logic for now
|
||||
|
||||
if @unit.save
|
||||
render 'show', status: :created
|
||||
else
|
||||
render_error_response(@unit)
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@unit = Current.account.captain_units.find(params[:id])
|
||||
|
||||
if @unit.update(unit_params)
|
||||
render 'show'
|
||||
else
|
||||
render_error_response(@unit)
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@unit = Current.account.captain_units.find(params[:id])
|
||||
@unit.destroy
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def unit_params
|
||||
params.require(:unit).permit(
|
||||
:name,
|
||||
:status,
|
||||
:reservations_sync_enabled,
|
||||
:plug_play_id,
|
||||
:plug_play_token,
|
||||
:webhook_url,
|
||||
:leader_whatsapp,
|
||||
:reservation_source_tag,
|
||||
:inter_client_id,
|
||||
:inter_client_secret,
|
||||
:inter_pix_key,
|
||||
:inter_account_number,
|
||||
visible_suite_categories: [],
|
||||
suite_category_images: {}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -11,9 +11,9 @@ class CaptainInboxes extends ApiClient {
|
||||
}
|
||||
|
||||
create(params = {}) {
|
||||
const { assistantId, inboxId } = params;
|
||||
const { assistantId, inboxId, captain_unit_id } = params;
|
||||
return axios.post(`${this.url}/${assistantId}/inboxes`, {
|
||||
inbox: { inbox_id: inboxId },
|
||||
inbox: { inbox_id: inboxId, captain_unit_id },
|
||||
});
|
||||
}
|
||||
|
||||
@ -23,9 +23,9 @@ class CaptainInboxes extends ApiClient {
|
||||
}
|
||||
|
||||
update(inboxId, params = {}) {
|
||||
const { assistantId, always_use_reminder_tool } = params;
|
||||
const { assistantId, captain_unit_id, always_use_reminder_tool } = params;
|
||||
return axios.patch(`${this.url}/${assistantId}/inboxes/${inboxId}`, {
|
||||
inbox: { always_use_reminder_tool },
|
||||
inbox: { captain_unit_id, always_use_reminder_tool },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,10 @@ class UnitsAPI extends ApiClient {
|
||||
update(id, data) {
|
||||
return window.axios.patch(`${this.url}/${id}`, data);
|
||||
}
|
||||
|
||||
syncReservations(id) {
|
||||
return window.axios.post(`${this.url}/${id}/reservations/sync`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new UnitsAPI();
|
||||
|
||||
@ -68,6 +68,12 @@ const inboxName = computed(() => {
|
||||
});
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{
|
||||
label: t('CAPTAIN.INBOXES.OPTIONS.EDIT'),
|
||||
value: 'edit',
|
||||
action: 'edit',
|
||||
icon: 'i-lucide-pencil-line',
|
||||
},
|
||||
{
|
||||
label: t('CAPTAIN.INBOXES.OPTIONS.DISCONNECT'),
|
||||
value: 'delete',
|
||||
@ -86,6 +92,33 @@ const handleAction = ({ action, value }) => {
|
||||
emit('action', { action, value, id: props.id });
|
||||
};
|
||||
|
||||
const unitName = ref('');
|
||||
|
||||
const fetchUnit = async () => {
|
||||
const unitId = props.inbox?.captain_inbox?.captain_unit_id;
|
||||
if (!unitId) return;
|
||||
|
||||
try {
|
||||
const accountId = window.chatwootConfig?.account_id;
|
||||
if (!accountId) return;
|
||||
|
||||
const { data } = await window.axios.get(
|
||||
`/api/v1/accounts/${accountId}/captain/units/${unitId}`
|
||||
);
|
||||
unitName.value = data.name;
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.inbox?.captain_inbox?.captain_unit_id,
|
||||
() => {
|
||||
fetchUnit();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const toggleReminderTool = async value => {
|
||||
if (isUpdating.value) return;
|
||||
isUpdating.value = true;
|
||||
@ -147,6 +180,14 @@ const toggleReminderTool = async value => {
|
||||
@update:model-value="toggleReminderTool"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="unitName" class="mt-2">
|
||||
<span
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-800 dark:bg-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<i class="i-lucide-building-2 mr-1 text-xs" />
|
||||
{{ unitName }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-n-slate-10">
|
||||
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
|
||||
</p>
|
||||
|
||||
@ -253,8 +253,8 @@ const handleSubmit = async () => {
|
||||
v-for="(header, index) in customHeaders"
|
||||
:key="index"
|
||||
ref="headersRef"
|
||||
v-model:key="header.key"
|
||||
v-model:value="header.value"
|
||||
v-model:header-key="header.key"
|
||||
v-model:header-value="header.value"
|
||||
@remove="removeHeader(index)"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
@ -8,27 +8,27 @@ const emit = defineEmits(['remove']);
|
||||
const { t } = useI18n();
|
||||
const showErrors = ref(false);
|
||||
|
||||
const key = defineModel('key', {
|
||||
const headerKey = defineModel('headerKey', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const value = defineModel('value', {
|
||||
const headerValue = defineModel('headerValue', {
|
||||
type: String,
|
||||
required: true,
|
||||
});
|
||||
|
||||
const validationError = computed(() => {
|
||||
if (!key.value || key.value.trim() === '') {
|
||||
if (!headerKey.value || headerKey.value.trim() === '') {
|
||||
return 'HEADER_KEY_REQUIRED';
|
||||
}
|
||||
if (!value.value || value.value.trim() === '') {
|
||||
if (!headerValue.value || headerValue.value.trim() === '') {
|
||||
return 'HEADER_VALUE_REQUIRED';
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
watch([key, value], () => {
|
||||
watch([headerKey, headerValue], () => {
|
||||
showErrors.value = false;
|
||||
});
|
||||
|
||||
@ -52,11 +52,11 @@ defineExpose({ validate });
|
||||
<div class="flex flex-col flex-1 gap-3">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
v-model="key"
|
||||
v-model="headerKey"
|
||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_KEY.PLACEHOLDER')"
|
||||
/>
|
||||
<Input
|
||||
v-model="value"
|
||||
v-model="headerValue"
|
||||
:placeholder="
|
||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_VALUE.PLACEHOLDER')
|
||||
"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@ -7,11 +7,19 @@ import { useI18n } from 'vue-i18n';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
import ConnectInboxForm from './ConnectInboxForm.vue';
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
assistantId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'create',
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
const emit = defineEmits(['close']);
|
||||
const { t } = useI18n();
|
||||
@ -20,15 +28,19 @@ const store = useStore();
|
||||
const dialogRef = ref(null);
|
||||
const connectForm = ref(null);
|
||||
|
||||
const i18nKey = 'CAPTAIN.INBOXES.CREATE';
|
||||
const i18nKey = computed(() =>
|
||||
props.type === 'edit' ? 'CAPTAIN.INBOXES.EDIT' : 'CAPTAIN.INBOXES.CREATE'
|
||||
);
|
||||
|
||||
const handleSubmit = async payload => {
|
||||
try {
|
||||
await store.dispatch('captainInboxes/create', payload);
|
||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
||||
const action =
|
||||
props.type === 'edit' ? 'captainInboxes/update' : 'captainInboxes/create';
|
||||
await store.dispatch(action, payload);
|
||||
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||
dialogRef.value.close();
|
||||
} catch (error) {
|
||||
const errorMessage = error?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
||||
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
@ -48,7 +60,7 @@ defineExpose({ dialogRef });
|
||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||
<Dialog
|
||||
ref="dialogRef"
|
||||
type="create"
|
||||
:type="type"
|
||||
:title="$t(`${i18nKey}.TITLE`)"
|
||||
:description="$t('CAPTAIN.INBOXES.FORM_DESCRIPTION')"
|
||||
:show-cancel-button="false"
|
||||
@ -58,6 +70,7 @@ defineExpose({ dialogRef });
|
||||
<ConnectInboxForm
|
||||
ref="connectForm"
|
||||
:assistant-id="assistantId"
|
||||
:inbox="inbox"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
@ -14,6 +14,10 @@ const props = defineProps({
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
@ -28,8 +32,8 @@ const formState = {
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
inboxId: null,
|
||||
captainUnitId: null,
|
||||
inboxId: props.inbox?.captain_inbox?.inbox_id || null,
|
||||
captainUnitId: props.inbox?.captain_inbox?.captain_unit_id || null,
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
@ -46,21 +50,65 @@ const accountId = computed(() => {
|
||||
const inboxList = computed(() => {
|
||||
const captainInboxIds = formState.captainInboxes.value.map(inbox => inbox.id);
|
||||
|
||||
return formState.inboxes.value
|
||||
// Filter available inboxes from the store
|
||||
const availableInboxes = formState.inboxes.value
|
||||
.filter(inbox => !captainInboxIds.includes(inbox.id))
|
||||
.map(inbox => ({
|
||||
value: inbox.id,
|
||||
label: inbox.name,
|
||||
}));
|
||||
|
||||
// If we are editing, we MUST ensure the current inbox is in the list
|
||||
if (props.inbox) {
|
||||
const currentInboxId = props.inbox.id;
|
||||
|
||||
// Check if it's already in the list (it shouldn't be if it's in captainInboxes)
|
||||
const alreadyInList = availableInboxes.find(
|
||||
i => i.value === currentInboxId
|
||||
);
|
||||
|
||||
if (!alreadyInList) {
|
||||
// We use the name directly from props.inbox to avoid store lookup issues
|
||||
const label =
|
||||
props.inbox.name || props.inbox.phone_number || 'Caixa de Entrada';
|
||||
|
||||
// Add to the beginning of the list
|
||||
availableInboxes.unshift({
|
||||
value: currentInboxId,
|
||||
label: label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return availableInboxes;
|
||||
});
|
||||
|
||||
const unitList = computed(() => {
|
||||
return units.map(unit => ({
|
||||
return [
|
||||
{ value: null, label: 'Sem Unidade' },
|
||||
...units.map(unit => ({
|
||||
value: unit.id,
|
||||
label: unit.name,
|
||||
}));
|
||||
})),
|
||||
];
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.inbox,
|
||||
newInbox => {
|
||||
if (newInbox) {
|
||||
// Use inbox.id directly for the inboxId
|
||||
state.inboxId = newInbox.id;
|
||||
|
||||
// Use captain_inbox data for other fields if available
|
||||
if (newInbox.captain_inbox) {
|
||||
state.captainUnitId = newInbox.captain_inbox.captain_unit_id;
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true, deep: true }
|
||||
);
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
|
||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||
@ -82,6 +130,7 @@ const prepareInboxPayload = () => ({
|
||||
inboxId: state.inboxId,
|
||||
captain_unit_id: state.captainUnitId,
|
||||
assistantId: props.assistantId,
|
||||
...(props.inbox ? { id: props.inbox.captain_inbox.id } : {}),
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -136,6 +185,7 @@ watch(accountId, () => {
|
||||
:placeholder="t('CAPTAIN.INBOXES.FORM.INBOX.PLACEHOLDER')"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
:message="formErrors.inboxId"
|
||||
:disabled="!!inbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -169,7 +219,11 @@ watch(accountId, () => {
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
:label="t('CAPTAIN.FORM.CREATE')"
|
||||
:label="
|
||||
props.inbox
|
||||
? t('CAPTAIN.INBOXES.EDIT.SAVE')
|
||||
: t('CAPTAIN.FORM.CREATE')
|
||||
"
|
||||
class="w-full"
|
||||
:is-loading="isLoading"
|
||||
:disabled="isLoading"
|
||||
|
||||
@ -37,6 +37,7 @@ const isPartial = computed(
|
||||
() => props.reservation.payment_status === 'partial'
|
||||
);
|
||||
const isPending = computed(() => !isPaid.value && !isPartial.value);
|
||||
const sourceTag = computed(() => props.reservation.source_tag || '');
|
||||
|
||||
// Relative Time Logic
|
||||
const timeDisplay = computed(() => {
|
||||
@ -190,7 +191,7 @@ const showMenu = ref(false);
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Guest Name -->
|
||||
<div class="mb-2">
|
||||
<div class="mb-2 flex items-center gap-2 flex-wrap">
|
||||
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||
<span
|
||||
class="font-bold text-slate-900 dark:text-slate-100 text-base leading-tight line-clamp-2"
|
||||
@ -198,6 +199,12 @@ const showMenu = ref(false);
|
||||
>
|
||||
{{ guestName }}
|
||||
</span>
|
||||
<span
|
||||
v-if="sourceTag"
|
||||
class="text-[10px] uppercase font-bold px-2 py-0.5 rounded-md inline-flex items-center border border-emerald-600 bg-emerald-400 text-slate-900"
|
||||
>
|
||||
{{ sourceTag }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Financial + Channel -->
|
||||
@ -206,6 +213,7 @@ const showMenu = ref(false);
|
||||
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||
{{ formatCurrency(reservation.total_amount) }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
class="flex items-center gap-1 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm w-fit"
|
||||
:class="{
|
||||
|
||||
@ -43,6 +43,21 @@
|
||||
"STAYS_LABEL": "Accepted Stay Durations",
|
||||
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Overnight, Daily (separated by comma)"
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"OPTIONS": {
|
||||
"TEST_TOOL": "Test Tool",
|
||||
"EDIT_TOOL": "Edit Tool",
|
||||
"DELETE_TOOL": "Delete Tool"
|
||||
},
|
||||
"FORM": {
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "None",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "API Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -363,6 +363,16 @@
|
||||
},
|
||||
"CAPTAIN": {
|
||||
"NAME": "Captain",
|
||||
"UNITS": {
|
||||
"FORM": {
|
||||
"LEADER_WHATSAPP_LABEL": "Leader WhatsApp",
|
||||
"LEADER_WHATSAPP_PLACEHOLDER": "e.g. 5561999999999",
|
||||
"RESERVATION_SOURCE_TAG_LABEL": "Reservation tag (synced)",
|
||||
"RESERVATION_SOURCE_TAG_PLACEHOLDER": "e.g. PlugPlay",
|
||||
"WEBHOOK_TITLE": "Integration Webhook",
|
||||
"WEBHOOK_URL": "Webhook URL"
|
||||
}
|
||||
},
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
"ASSISTANT_SWITCHER": {
|
||||
"ASSISTANTS": "Assistants",
|
||||
@ -1282,7 +1292,8 @@
|
||||
"ERROR": "Could not update reminder tool preference"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"DISCONNECT": "Disconnect"
|
||||
"DISCONNECT": "Disconnect",
|
||||
"EDIT": "Edit"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Are you sure to disconnect the inbox?",
|
||||
|
||||
@ -43,6 +43,21 @@
|
||||
"STAYS_LABEL": "Durações Aceitas",
|
||||
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
||||
}
|
||||
},
|
||||
"CUSTOM_TOOLS": {
|
||||
"OPTIONS": {
|
||||
"TEST_TOOL": "Testar Ferramenta",
|
||||
"EDIT_TOOL": "Editar Ferramenta",
|
||||
"DELETE_TOOL": "Excluir Ferramenta"
|
||||
},
|
||||
"FORM": {
|
||||
"AUTH_TYPES": {
|
||||
"NONE": "Nenhuma",
|
||||
"BEARER": "Bearer Token",
|
||||
"BASIC": "Basic Auth",
|
||||
"API_KEY": "API Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -390,6 +390,10 @@
|
||||
"NAME_LABEL": "Nome da Unidade",
|
||||
"NAME_PLACEHOLDER": "Ex: Unidade Centro",
|
||||
"NAME_ERROR": "O nome é obrigatório",
|
||||
"LEADER_WHATSAPP_LABEL": "WhatsApp do Responsável",
|
||||
"LEADER_WHATSAPP_PLACEHOLDER": "Ex: 5561999999999",
|
||||
"RESERVATION_SOURCE_TAG_LABEL": "Etiqueta da Reserva (sincronizadas)",
|
||||
"RESERVATION_SOURCE_TAG_PLACEHOLDER": "Ex: PlugPlay",
|
||||
"BRAND_LABEL": "Marca",
|
||||
"BRAND_PLACEHOLDER": "Selecione uma marca",
|
||||
"FORM_DESCRIPTION": "Preencha os dados da unidade e as credenciais do Pix (Banco Inter).",
|
||||
@ -399,7 +403,9 @@
|
||||
"CLIENT_SECRET": "Client Secret",
|
||||
"CERT_PATH": "Caminho do Certificado (.pem)",
|
||||
"KEY_PATH": "Caminho da Chave (.pem)",
|
||||
"ACCOUNT_NUMBER": "Conta Corrente"
|
||||
"ACCOUNT_NUMBER": "Conta Corrente",
|
||||
"WEBHOOK_TITLE": "Webhook de Integração",
|
||||
"WEBHOOK_URL": "URL do Webhook"
|
||||
}
|
||||
},
|
||||
"HEADER_KNOW_MORE": "Know more",
|
||||
@ -1457,7 +1463,12 @@
|
||||
"ERROR": "Nao foi possivel atualizar a preferencia"
|
||||
},
|
||||
"OPTIONS": {
|
||||
"DISCONNECT": "Desconectar"
|
||||
"DISCONNECT": "Desconectar",
|
||||
"EDIT": "Editar"
|
||||
},
|
||||
"EDIT": {
|
||||
"TITLE": "Editar caixa de entrada",
|
||||
"SAVE": "Atualizar"
|
||||
},
|
||||
"DELETE": {
|
||||
"TITLE": "Tem certeza que deseja desconectar a caixa de entrada?",
|
||||
|
||||
@ -35,6 +35,17 @@ const handleCreate = () => {
|
||||
dialogType.value = 'create';
|
||||
nextTick(() => connectInboxDialog.value.dialogRef.open());
|
||||
};
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
// Handle edit action
|
||||
const handleUpdate = () => {
|
||||
dialogType.value = 'edit';
|
||||
nextTick(() => connectInboxDialog.value.dialogRef.open());
|
||||
};
|
||||
|
||||
const handleAction = ({ action, id }) => {
|
||||
selectedInbox.value = captainInboxes.value.find(
|
||||
inbox => id === inbox.captain_inbox.id
|
||||
@ -42,15 +53,12 @@ const handleAction = ({ action, id }) => {
|
||||
nextTick(() => {
|
||||
if (action === 'delete') {
|
||||
handleDelete();
|
||||
} else if (action === 'edit') {
|
||||
handleUpdate();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateClose = () => {
|
||||
dialogType.value = '';
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
onMounted(() =>
|
||||
store.dispatch('captainInboxes/get', {
|
||||
assistantId: assistantId.value,
|
||||
@ -103,6 +111,7 @@ onMounted(() =>
|
||||
ref="connectInboxDialog"
|
||||
:assistant-id="assistantId"
|
||||
:type="dialogType"
|
||||
:inbox="selectedInbox"
|
||||
@close="handleCreateClose"
|
||||
/>
|
||||
</PageLayout>
|
||||
|
||||
@ -236,7 +236,7 @@ onMounted(() => {
|
||||
:placeholder="
|
||||
$t('CAPTAIN.ASSISTANTS.SKILLS.FALLBACK.PLACEHOLDER')
|
||||
"
|
||||
:max-length="400"
|
||||
:max-length="800"
|
||||
show-character-count
|
||||
@blur="handleFallbackUpdate(tool)"
|
||||
/>
|
||||
|
||||
@ -27,6 +27,7 @@ const isLoading = ref(false);
|
||||
const isFetchingUnits = ref(false);
|
||||
const isUpdating = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isSyncing = ref(false);
|
||||
|
||||
const reservations = ref([]);
|
||||
const units = ref([]);
|
||||
@ -226,6 +227,20 @@ const fetchReservations = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
if (!filters.unit_id) return;
|
||||
isSyncing.value = true;
|
||||
try {
|
||||
await CaptainUnitsAPI.syncReservations(filters.unit_id);
|
||||
alert('Sincronização iniciada com sucesso!');
|
||||
fetchReservations();
|
||||
} catch (error) {
|
||||
alert('Erro ao sincronizar reservas.');
|
||||
} finally {
|
||||
isSyncing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Kanban Logic ---
|
||||
|
||||
const isInShiftWindow = dateString => {
|
||||
@ -594,6 +609,18 @@ watch(
|
||||
title="Atualizar"
|
||||
@click="fetchReservations"
|
||||
/>
|
||||
|
||||
<Button
|
||||
v-if="filters.unit_id"
|
||||
:is-loading="isSyncing"
|
||||
icon="i-lucide-cloud-download"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
color="slate"
|
||||
@click="handleSync"
|
||||
>
|
||||
Sincronizar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -41,10 +41,15 @@ export default {
|
||||
inter_key_path: '',
|
||||
inter_account_number: '',
|
||||
webhook_url: '',
|
||||
leader_whatsapp: '',
|
||||
reservation_source_tag: '',
|
||||
inbox_id: '',
|
||||
brands: [],
|
||||
inboxes: [],
|
||||
isLoadingBrands: false,
|
||||
plug_play_id: '',
|
||||
plug_play_token: '',
|
||||
reservations_sync_enabled: false,
|
||||
};
|
||||
},
|
||||
validations() {
|
||||
@ -68,7 +73,13 @@ export default {
|
||||
this.inter_key_path = this.unit.inter_key_path;
|
||||
this.inter_account_number = this.unit.inter_account_number;
|
||||
this.webhook_url = this.unit.webhook_url;
|
||||
this.leader_whatsapp = this.unit.leader_whatsapp || '';
|
||||
this.reservation_source_tag = this.unit.reservation_source_tag || '';
|
||||
this.inbox_id = this.unit.inbox_id;
|
||||
this.plug_play_id = this.unit.plug_play_id || '';
|
||||
this.plug_play_token = this.unit.plug_play_token || '';
|
||||
this.reservations_sync_enabled =
|
||||
this.unit.reservations_sync_enabled || false;
|
||||
} else {
|
||||
this.resetForm();
|
||||
}
|
||||
@ -86,7 +97,12 @@ export default {
|
||||
this.inter_key_path = '';
|
||||
this.inter_account_number = '';
|
||||
this.webhook_url = '';
|
||||
this.leader_whatsapp = '';
|
||||
this.reservation_source_tag = '';
|
||||
this.inbox_id = '';
|
||||
this.plug_play_id = '';
|
||||
this.plug_play_token = '';
|
||||
this.reservations_sync_enabled = false;
|
||||
this.v$.$reset();
|
||||
},
|
||||
async fetchInboxes() {
|
||||
@ -126,7 +142,12 @@ export default {
|
||||
inter_key_path: this.inter_key_path,
|
||||
inter_account_number: this.inter_account_number,
|
||||
webhook_url: this.webhook_url,
|
||||
leader_whatsapp: this.leader_whatsapp,
|
||||
reservation_source_tag: this.reservation_source_tag,
|
||||
inbox_id: this.inbox_id,
|
||||
plug_play_id: this.plug_play_id,
|
||||
plug_play_token: this.plug_play_token,
|
||||
reservations_sync_enabled: this.reservations_sync_enabled,
|
||||
};
|
||||
|
||||
try {
|
||||
@ -181,6 +202,12 @@ export default {
|
||||
:error="v$.name.$error ? $t('CAPTAIN.UNITS.FORM.NAME_ERROR') : ''"
|
||||
/>
|
||||
|
||||
<WootInput
|
||||
v-model="leader_whatsapp"
|
||||
:label="$t('CAPTAIN.UNITS.FORM.LEADER_WHATSAPP_LABEL')"
|
||||
:placeholder="$t('CAPTAIN.UNITS.FORM.LEADER_WHATSAPP_PLACEHOLDER')"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label
|
||||
class="block mb-1.5 text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||
@ -298,6 +325,48 @@ export default {
|
||||
:label="$t('CAPTAIN.UNITS.FORM.WEBHOOK_URL')"
|
||||
placeholder="https://webhook.n8n.cloud/webhook/..."
|
||||
/>
|
||||
|
||||
<div class="my-1 h-px bg-slate-100 dark:bg-slate-800" />
|
||||
<h4
|
||||
class="text-sm font-semibold text-slate-800 dark:text-slate-100 uppercase tracking-wide"
|
||||
>
|
||||
{{ 'Integração Config' }}
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4">
|
||||
<WootInput
|
||||
v-model="plug_play_id"
|
||||
label="PlugPlay ID"
|
||||
placeholder="ID da Unidade na PlugPlay"
|
||||
/>
|
||||
<WootInput
|
||||
v-model="plug_play_token"
|
||||
label="PlugPlay Token"
|
||||
placeholder="Token de Acesso"
|
||||
type="password"
|
||||
/>
|
||||
<WootInput
|
||||
v-model="reservation_source_tag"
|
||||
:label="$t('CAPTAIN.UNITS.FORM.RESERVATION_SOURCE_TAG_LABEL')"
|
||||
:placeholder="
|
||||
$t('CAPTAIN.UNITS.FORM.RESERVATION_SOURCE_TAG_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="sync_enabled"
|
||||
v-model="reservations_sync_enabled"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
|
||||
/>
|
||||
<label
|
||||
for="sync_enabled"
|
||||
class="text-sm font-medium text-slate-700 dark:text-slate-200"
|
||||
>
|
||||
Ativar Sincronização de Reservas
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
36
app/models/captain/reservation.rb
Normal file
36
app/models/captain/reservation.rb
Normal file
@ -0,0 +1,36 @@
|
||||
module Captain
|
||||
class Reservation < ApplicationRecord
|
||||
self.table_name = 'captain_reservations'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :inbox
|
||||
belongs_to :contact
|
||||
belongs_to :contact_inbox
|
||||
belongs_to :conversation, optional: true
|
||||
belongs_to :captain_brand, optional: true
|
||||
belongs_to :captain_unit, optional: true
|
||||
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
|
||||
|
||||
# Validations
|
||||
validates :check_in_at, presence: true
|
||||
validates :check_out_at, presence: true
|
||||
validates :integracao_id, uniqueness: { scope: :captain_unit_id }, allow_nil: true
|
||||
|
||||
enum status: {
|
||||
scheduled: 0,
|
||||
active: 1,
|
||||
completed: 2,
|
||||
cancelled: 3,
|
||||
no_show: 4,
|
||||
pending_payment: 5,
|
||||
expired: 6,
|
||||
payment_confirmed: 7,
|
||||
issues: 8,
|
||||
awaiting_checkin: 9
|
||||
}
|
||||
|
||||
scope :active_in_date_range, lambda { |start_date, end_date|
|
||||
where('check_in_at < ? AND check_out_at > ?', end_date, start_date)
|
||||
}
|
||||
end
|
||||
end
|
||||
20
app/models/captain/unit.rb
Normal file
20
app/models/captain/unit.rb
Normal file
@ -0,0 +1,20 @@
|
||||
module Captain
|
||||
class Unit < ApplicationRecord
|
||||
self.table_name = 'captain_units'
|
||||
|
||||
belongs_to :account
|
||||
belongs_to :captain_brand
|
||||
belongs_to :inbox, optional: true
|
||||
|
||||
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: :captain_unit_id, dependent: :destroy
|
||||
|
||||
# Encrypted fields for PlugPlay Integration
|
||||
# Assuming attributes are encrypted using Rails 7 encryption or attr_encrypted gem depending on codebase.
|
||||
# Chatwoot typically uses attr_encrypted or simple DB fields if not configured otherwise.
|
||||
# Given the migration was just string, we should ensure we handle "encryption" or at least treat it as sensitive.
|
||||
# For now, we'll expose it but in a real scenario we should use `encrypts :plug_play_token`.
|
||||
# Let's check generally used pattern later, but for now defining relations is key.
|
||||
|
||||
validates :name, presence: true
|
||||
end
|
||||
end
|
||||
@ -1,3 +1,25 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_assistants
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# api_key :text
|
||||
# config :jsonb not null
|
||||
# description :string
|
||||
# guardrails :jsonb
|
||||
# handoff_webhook_config :jsonb
|
||||
# llm_model :string default("gpt-3.5-turbo")
|
||||
# llm_provider :string default("openai")
|
||||
# name :string not null
|
||||
# response_guidelines :jsonb
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_assistants_on_account_id (account_id)
|
||||
#
|
||||
class CaptainAssistant < ApplicationRecord
|
||||
belongs_to :account
|
||||
has_many :captain_tool_configs, dependent: :destroy
|
||||
|
||||
@ -1,3 +1,25 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_documents
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# external_link :string not null
|
||||
# metadata :jsonb
|
||||
# name :string
|
||||
# status :integer default("uploaded"), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_documents_on_account_id (account_id)
|
||||
# index_captain_documents_on_assistant_id (assistant_id)
|
||||
# index_captain_documents_on_assistant_id_and_external_link (assistant_id,external_link) UNIQUE
|
||||
# index_captain_documents_on_status (status)
|
||||
#
|
||||
class CaptainDocument < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'CaptainAssistant'
|
||||
|
||||
@ -1,3 +1,26 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: captain_scenarios
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# enabled :boolean default(TRUE), not null
|
||||
# instruction :text
|
||||
# title :string
|
||||
# tools :jsonb
|
||||
# trigger_keywords :text
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# assistant_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_captain_scenarios_on_account_id (account_id)
|
||||
# index_captain_scenarios_on_assistant_id (assistant_id)
|
||||
# index_captain_scenarios_on_assistant_id_and_enabled (assistant_id,enabled)
|
||||
# index_captain_scenarios_on_enabled (enabled)
|
||||
#
|
||||
class CaptainScenario < ApplicationRecord
|
||||
belongs_to :account
|
||||
belongs_to :assistant, class_name: 'CaptainAssistant'
|
||||
|
||||
179
app/services/captain/reservations/sync_service.rb
Normal file
179
app/services/captain/reservations/sync_service.rb
Normal file
@ -0,0 +1,179 @@
|
||||
module Captain
|
||||
module Reservations
|
||||
class SyncService
|
||||
PLUG_PLAY_API_BASE = 'https://oxpi.com.br/api/PlugPlay/api/Reserva'
|
||||
|
||||
def initialize(unit)
|
||||
@unit = unit
|
||||
@account = unit.account
|
||||
@inbox = unit.inbox # Assuming unit is linked to an inbox, or we fallback
|
||||
end
|
||||
|
||||
def perform
|
||||
return unless @unit.reservations_sync_enabled?
|
||||
return unless @unit.plug_play_id.present? && @unit.plug_play_token.present?
|
||||
|
||||
page = 1
|
||||
loop do
|
||||
reservations_data = fetch_page(page)
|
||||
break if reservations_data.empty?
|
||||
|
||||
reservations_data.each do |reservation_data|
|
||||
process_reservation(reservation_data)
|
||||
end
|
||||
|
||||
page += 1
|
||||
# Safety break to avoid infinite loops in case of API issues
|
||||
break if page > 50
|
||||
end
|
||||
|
||||
@unit.update(last_synced_at: Time.current)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_page(page)
|
||||
url = "#{PLUG_PLAY_API_BASE}?exibicao=0&pagina=#{page}"
|
||||
response = HTTParty.get(url, headers: headers)
|
||||
|
||||
if response.success?
|
||||
begin
|
||||
JSON.parse(response.body)
|
||||
rescue StandardError
|
||||
[]
|
||||
end
|
||||
else
|
||||
Rails.logger.error "PlugPlay Sync Error: #{response.code} - #{response.body}"
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def headers
|
||||
{
|
||||
'PLUG-PLAY-ID' => @unit.plug_play_id,
|
||||
'PLUG-PLAY-TOKEN' => @unit.plug_play_token,
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def process_reservation(data)
|
||||
external_id = data['id']
|
||||
return if external_id.blank?
|
||||
|
||||
reservation = @unit.captain_reservations.find_or_initialize_by(integracao_id: external_id)
|
||||
|
||||
# Resolve Contact
|
||||
contact = find_or_create_contact(data)
|
||||
|
||||
# Map Attributes
|
||||
reservation.account = @account
|
||||
reservation.inbox = @inbox || @account.inboxes.first # Fallback if unit has no inbox
|
||||
reservation.contact = contact
|
||||
reservation.contact_inbox = contact.contact_inboxes.find_by(inbox: reservation.inbox)
|
||||
|
||||
# If contact_inbox missing (new contact created without association to this inbox), create it
|
||||
if reservation.contact_inbox.nil?
|
||||
reservation.contact_inbox = ContactInbox.create!(contact: contact, inbox: reservation.inbox, source_id: contact.id)
|
||||
end
|
||||
|
||||
reservation.suite_identifier = data['suiteRef']
|
||||
reservation.check_in_at = parse_date(data['dataInicio']) # Format: 2026-01-22T00:00:00
|
||||
reservation.check_out_at = parse_date(data['saidaPrevistaOuNegociada'])
|
||||
|
||||
if reservation.suite_identifier.blank? || reservation.check_in_at.blank? || reservation.check_out_at.blank?
|
||||
Rails.logger.warn "PlugPlay Sync Skip: missing suite/dates for reservation #{external_id}"
|
||||
return
|
||||
end
|
||||
|
||||
reservation.total_amount = data['totalAPagar']
|
||||
|
||||
# Status Mapping
|
||||
reservation.status = map_status(data)
|
||||
|
||||
reservation.metadata ||= {}
|
||||
reservation.metadata['raw_plug_play_data'] = data
|
||||
reservation.metadata['guest_name'] = data['nome']
|
||||
reservation.metadata['guest_email'] = data['email']
|
||||
reservation.metadata['guest_phone'] = data['telefone']
|
||||
reservation.metadata['notes'] = data['observacoes']
|
||||
reservation.metadata['source_tag'] = @unit.reservation_source_tag if @unit.reservation_source_tag.present?
|
||||
|
||||
reservation.save!
|
||||
rescue StandardError => e
|
||||
if e.is_a?(ActiveRecord::RecordInvalid) && e.record
|
||||
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.record.errors.full_messages.join(', ')}"
|
||||
Rails.logger.error "Reservation attrs: unit_id=#{@unit.id} inbox_id=#{reservation&.inbox_id} contact_id=#{reservation&.contact_id} contact_inbox_id=#{reservation&.contact_inbox_id} suite=#{reservation&.suite_identifier} check_in=#{reservation&.check_in_at} check_out=#{reservation&.check_out_at} status=#{reservation&.status}"
|
||||
else
|
||||
Rails.logger.error "Error syncing reservation #{data['id']}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
def find_or_create_contact(data)
|
||||
phone = normalize_phone_number(data['telefone'])
|
||||
email = data['email']
|
||||
name = data['nome']
|
||||
|
||||
contact = nil
|
||||
|
||||
# Try finding by phone
|
||||
contact = @account.contacts.find_by_phone_number(phone) if phone.present?
|
||||
|
||||
# Try finding by email
|
||||
contact = @account.contacts.find_by(email: email) if contact.nil? && email.present?
|
||||
|
||||
# Create if not found
|
||||
if contact.nil?
|
||||
contact = @account.contacts.create!(
|
||||
name: name,
|
||||
email: email,
|
||||
phone_number: phone
|
||||
)
|
||||
end
|
||||
|
||||
contact
|
||||
end
|
||||
|
||||
def normalize_phone_number(raw_phone)
|
||||
digits = raw_phone.to_s.gsub(/[^\d]/, '')
|
||||
return nil if digits.blank?
|
||||
|
||||
digits = "55#{digits}" if digits.length == 10 || digits.length == 11
|
||||
|
||||
return nil if digits.length < 10 || digits.length > 15
|
||||
|
||||
"+#{digits}"
|
||||
end
|
||||
|
||||
def parse_date(date_string)
|
||||
return nil if date_string.blank?
|
||||
|
||||
Time.zone.parse(date_string)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
def map_status(data)
|
||||
# MVP Logic based on dates and 'cancelada'
|
||||
return :cancelled if data['cancelada'] == true
|
||||
|
||||
check_in = parse_date(data['dataInicio'])
|
||||
check_out = parse_date(data['saidaPrevistaOuNegociada'])
|
||||
now = Time.current
|
||||
|
||||
return :scheduled unless check_in && check_out
|
||||
|
||||
if check_in.to_date == now.to_date
|
||||
:scheduled # Or 'awaiting_checkin' if we want to be more specific, but MVP 'scheduled' is usually 'Entrada'
|
||||
elsif now >= check_in && now < check_out
|
||||
:active # 'Hospedada'
|
||||
elsif now >= check_out
|
||||
:completed # 'Saída' / checkout done
|
||||
elsif now < check_in
|
||||
:scheduled
|
||||
else
|
||||
:scheduled # Default
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@ -84,7 +84,9 @@ Rails.application.routes.draw do
|
||||
resources :reminders, only: [:index, :show, :create, :destroy]
|
||||
resources :inbox_automations, only: [:index, :create, :update, :destroy]
|
||||
resources :payment_callbacks, only: [:update]
|
||||
resources :units, only: [:index, :show, :create, :update, :destroy], param: :id
|
||||
resources :units, only: [:index, :show, :create, :update, :destroy], param: :id do
|
||||
post 'reservations/sync', to: 'units/reservations_sync#create'
|
||||
end
|
||||
resources :brands
|
||||
resources :pricings
|
||||
resources :extras
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
class AddPlugPlayToCaptainUnits < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :captain_units, :plug_play_id, :string
|
||||
add_column :captain_units, :plug_play_token, :string
|
||||
add_column :captain_units, :reservations_sync_enabled, :boolean
|
||||
add_column :captain_units, :last_synced_at, :datetime
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,5 @@
|
||||
class AddUniqueIndexToCaptainReservations < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_index :captain_reservations, [:integracao_id, :captain_unit_id], unique: true, name: 'index_captain_reservations_on_integracao_id_and_unit_id'
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,5 @@
|
||||
class AddLeaderWhatsappToCaptainUnits < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :captain_units, :leader_whatsapp, :string
|
||||
end
|
||||
end
|
||||
@ -0,0 +1,5 @@
|
||||
class AddReservationSourceTagToCaptainUnits < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :captain_units, :reservation_source_tag, :string
|
||||
end
|
||||
end
|
||||
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_01_20_141736) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_02_10_123000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@ -559,6 +559,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_141736) do
|
||||
t.index ["contact_inbox_id"], name: "index_captain_reservations_on_contact_inbox_id"
|
||||
t.index ["conversation_id"], name: "index_captain_reservations_on_conversation_id"
|
||||
t.index ["inbox_id"], name: "index_captain_reservations_on_inbox_id"
|
||||
t.index ["integracao_id", "captain_unit_id"], name: "index_captain_reservations_on_integracao_id_and_unit_id", unique: true
|
||||
t.index ["integracao_id"], name: "index_captain_reservations_on_integracao_id"
|
||||
end
|
||||
|
||||
@ -627,6 +628,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_141736) do
|
||||
t.string "inter_account_number"
|
||||
t.string "webhook_url"
|
||||
t.bigint "inbox_id"
|
||||
t.string "plug_play_id"
|
||||
t.string "plug_play_token"
|
||||
t.boolean "reservations_sync_enabled"
|
||||
t.datetime "last_synced_at"
|
||||
t.string "leader_whatsapp"
|
||||
t.string "reservation_source_tag"
|
||||
t.index ["account_id"], name: "index_captain_units_on_account_id"
|
||||
t.index ["captain_brand_id"], name: "index_captain_units_on_captain_brand_id"
|
||||
t.index ["inbox_id"], name: "index_captain_units_on_inbox_id"
|
||||
|
||||
@ -39,10 +39,12 @@ module Api
|
||||
def unit_params
|
||||
params.require(:unit).permit(
|
||||
:name, :status, :captain_brand_id,
|
||||
:reservations_sync_enabled,
|
||||
:plug_play_id, :plug_play_token,
|
||||
:inter_client_id, :inter_client_secret,
|
||||
:inter_pix_key, :inter_cert_path,
|
||||
:inter_key_path, :inter_account_number,
|
||||
:webhook_url, :inbox_id
|
||||
:webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@ -123,7 +123,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
case action
|
||||
when 'handoff'
|
||||
if handoff_allowed?
|
||||
process_action('handoff')
|
||||
process_action('handoff', trigger_key: trigger_key)
|
||||
return true
|
||||
else
|
||||
@response['response'] = fallback_handoff_blocked_message
|
||||
@ -320,7 +320,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
default: 'Desculpe, estou com dificuldades técnicas no momento. Por favor, tente novamente em alguns instantes.')
|
||||
end
|
||||
|
||||
def process_action(action)
|
||||
def process_action(action, trigger_key: nil)
|
||||
case action
|
||||
when 'handoff'
|
||||
I18n.with_locale(@assistant.account.locale) do
|
||||
@ -336,6 +336,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
@conversation.add_labels(['pausar_ia'])
|
||||
@conversation.save!
|
||||
apply_handoff_side_effects
|
||||
handle_sentiment_handoff_alerts if trigger_key.to_s == 'sentiment'
|
||||
deliver_handoff_webhook
|
||||
log_handoff_event
|
||||
send_out_of_office_message_if_applicable
|
||||
@ -347,6 +348,107 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
||||
end
|
||||
|
||||
def handle_sentiment_handoff_alerts
|
||||
trigger_excerpt = last_incoming_message_excerpt
|
||||
summary = build_handoff_summary
|
||||
|
||||
create_private_note_for_handoff(trigger_excerpt, summary)
|
||||
send_leader_whatsapp_alert(trigger_excerpt, summary)
|
||||
end
|
||||
|
||||
def last_incoming_message_excerpt
|
||||
message = @conversation.messages.where(message_type: :incoming, private: false).order(created_at: :desc).first
|
||||
message&.content.to_s.strip[0, 400]
|
||||
end
|
||||
|
||||
def build_handoff_summary
|
||||
summary = @conversation.latest_crm_insight&.summary_text.to_s.strip
|
||||
summary = build_conversation_summary if summary.blank?
|
||||
summary.to_s.strip[0, 400]
|
||||
end
|
||||
|
||||
def create_private_note_for_handoff(trigger_excerpt, summary)
|
||||
return if trigger_excerpt.blank? && summary.blank?
|
||||
|
||||
note_parts = []
|
||||
note_parts << 'Handoff automatico por sentimento negativo.'
|
||||
note_parts << "Resumo: #{summary}" if summary.present?
|
||||
note_parts << "Trecho: \"#{trigger_excerpt}\"" if trigger_excerpt.present?
|
||||
content = note_parts.join("\n")
|
||||
|
||||
@conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @assistant,
|
||||
content: content,
|
||||
private: true
|
||||
)
|
||||
end
|
||||
|
||||
def send_leader_whatsapp_alert(trigger_excerpt, summary)
|
||||
unit = resolve_unit_for_conversation
|
||||
return if unit.blank?
|
||||
|
||||
leader_phone = unit.leader_whatsapp.to_s.gsub(/[^\d]/, '')
|
||||
return if leader_phone.blank?
|
||||
return if unit.inbox.blank?
|
||||
|
||||
contact_inbox = ContactInboxWithContactBuilder.new(
|
||||
inbox: unit.inbox,
|
||||
contact_attributes: {
|
||||
name: "Lider #{unit.name}",
|
||||
phone_number: leader_phone
|
||||
},
|
||||
source_id: leader_phone
|
||||
).perform
|
||||
|
||||
leader_conversation = contact_inbox.conversations.order(created_at: :desc).first
|
||||
leader_conversation ||= Conversation.create!(
|
||||
account_id: unit.inbox.account_id,
|
||||
inbox_id: unit.inbox.id,
|
||||
contact_id: contact_inbox.contact_id,
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
status: :open
|
||||
)
|
||||
|
||||
message_text = build_leader_alert_message(unit.name, summary, trigger_excerpt)
|
||||
leader_conversation.messages.create!(
|
||||
message_type: :outgoing,
|
||||
account_id: unit.inbox.account_id,
|
||||
inbox_id: unit.inbox.id,
|
||||
sender: @assistant,
|
||||
content: message_text
|
||||
)
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "[CAPTAIN][handoff] Failed to alert leader: #{e.message}"
|
||||
end
|
||||
|
||||
def resolve_unit_for_conversation
|
||||
CaptainInbox.find_by(inbox_id: inbox.id, captain_assistant_id: @assistant.id)&.unit ||
|
||||
Captain::Unit.find_by(inbox_id: inbox.id)
|
||||
end
|
||||
|
||||
def build_leader_alert_message(unit_name, summary, trigger_excerpt)
|
||||
link = conversation_link
|
||||
parts = []
|
||||
parts << "ALERTA: cliente irritado - Unidade #{unit_name}"
|
||||
parts << 'O cliente precisa ser atendido para nao termos maiores problemas.'
|
||||
parts << 'Valor: obsessao pelo cliente.'
|
||||
parts << "Resumo: #{summary}" if summary.present?
|
||||
parts << "Trecho: \"#{trigger_excerpt}\"" if trigger_excerpt.present?
|
||||
parts << "Link da conversa: #{link}" if link.present?
|
||||
parts.join("\n")
|
||||
end
|
||||
|
||||
def conversation_link
|
||||
base_url = ENV.fetch('FRONTEND_URL', '').to_s
|
||||
base_url = base_url.gsub('0.0.0.0', '127.0.0.1')
|
||||
return '' if base_url.blank?
|
||||
|
||||
"#{base_url}/app/accounts/#{account.id}/conversations/#{@conversation.id}"
|
||||
end
|
||||
|
||||
def create_handoff_message
|
||||
create_outgoing_message(
|
||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
||||
|
||||
@ -6,7 +6,7 @@ module Captain
|
||||
belongs_to :inbox
|
||||
belongs_to :contact
|
||||
belongs_to :contact_inbox
|
||||
belongs_to :conversation, class_name: '::Conversation'
|
||||
belongs_to :conversation, class_name: '::Conversation', optional: true
|
||||
belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true
|
||||
belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true
|
||||
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true
|
||||
|
||||
@ -5,6 +5,7 @@ class Captain::Unit < ApplicationRecord
|
||||
belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id'
|
||||
belongs_to :inbox, optional: true
|
||||
has_many :reservations, class_name: 'Captain::Reservation', foreign_key: 'captain_unit_id'
|
||||
has_many :captain_reservations, class_name: 'Captain::Reservation', foreign_key: 'captain_unit_id'
|
||||
has_many :pix_charges, class_name: 'Captain::PixCharge'
|
||||
has_many :captain_inboxes, class_name: 'CaptainInbox', foreign_key: 'captain_unit_id'
|
||||
|
||||
|
||||
@ -283,14 +283,25 @@ module Captain
|
||||
end
|
||||
|
||||
def apply_fallback(result)
|
||||
return result if result[:success]
|
||||
failed = !result[:success]
|
||||
# Treat generate_pix business failures as fallback-worthy.
|
||||
if !failed && @tool_key == 'generate_pix'
|
||||
body = result[:body]
|
||||
body_success =
|
||||
if body.is_a?(Hash)
|
||||
body[:success].nil? ? body['success'] : body[:success]
|
||||
end
|
||||
failed = body_success == false
|
||||
end
|
||||
|
||||
return result unless failed
|
||||
return result unless fallback_configured?
|
||||
|
||||
{
|
||||
success: true,
|
||||
body: { message: @config.fallback_message.to_s },
|
||||
fallback: true,
|
||||
error: result[:error]
|
||||
error: result[:error] || result.dig(:body, :error)
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@ -2,5 +2,6 @@ json.partial! 'api/v1/models/inbox', resource: @captain_inbox.inbox
|
||||
json.captain_inbox do
|
||||
json.id @captain_inbox.id
|
||||
json.captain_assistant_id @captain_inbox.captain_assistant_id
|
||||
json.captain_unit_id @captain_inbox.captain_unit_id
|
||||
json.always_use_reminder_tool @captain_inbox.always_use_reminder_tool
|
||||
end
|
||||
|
||||
@ -4,6 +4,7 @@ json.payload do
|
||||
json.captain_inbox do
|
||||
json.id captain_inbox.id
|
||||
json.captain_assistant_id captain_inbox.captain_assistant_id
|
||||
json.captain_unit_id captain_inbox.captain_unit_id
|
||||
json.always_use_reminder_tool captain_inbox.always_use_reminder_tool
|
||||
end
|
||||
end
|
||||
|
||||
@ -2,5 +2,6 @@ json.partial! 'api/v1/models/inbox', resource: @captain_inbox.inbox
|
||||
json.captain_inbox do
|
||||
json.id @captain_inbox.id
|
||||
json.captain_assistant_id @captain_inbox.captain_assistant_id
|
||||
json.captain_unit_id @captain_inbox.captain_unit_id
|
||||
json.always_use_reminder_tool @captain_inbox.always_use_reminder_tool
|
||||
end
|
||||
|
||||
@ -11,6 +11,7 @@ json.check_out_at reservation.check_out_at&.iso8601
|
||||
json.status reservation.status
|
||||
json.payment_status reservation.payment_status
|
||||
json.total_amount reservation.total_amount
|
||||
json.source_tag reservation.metadata&.fetch('source_tag', nil)
|
||||
json.unit do
|
||||
json.partial! 'api/v1/models/captain/unit', unit: reservation.unit if reservation.unit
|
||||
end
|
||||
|
||||
Loading…
Reference in New Issue
Block a user