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 = {}) {
|
create(params = {}) {
|
||||||
const { assistantId, inboxId } = params;
|
const { assistantId, inboxId, captain_unit_id } = params;
|
||||||
return axios.post(`${this.url}/${assistantId}/inboxes`, {
|
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 = {}) {
|
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}`, {
|
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) {
|
update(id, data) {
|
||||||
return window.axios.patch(`${this.url}/${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();
|
export default new UnitsAPI();
|
||||||
|
|||||||
@ -68,6 +68,12 @@ const inboxName = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const menuItems = 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'),
|
label: t('CAPTAIN.INBOXES.OPTIONS.DISCONNECT'),
|
||||||
value: 'delete',
|
value: 'delete',
|
||||||
@ -86,6 +92,33 @@ const handleAction = ({ action, value }) => {
|
|||||||
emit('action', { action, value, id: props.id });
|
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 => {
|
const toggleReminderTool = async value => {
|
||||||
if (isUpdating.value) return;
|
if (isUpdating.value) return;
|
||||||
isUpdating.value = true;
|
isUpdating.value = true;
|
||||||
@ -147,6 +180,14 @@ const toggleReminderTool = async value => {
|
|||||||
@update:model-value="toggleReminderTool"
|
@update:model-value="toggleReminderTool"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<p class="mt-1 text-xs text-n-slate-10">
|
||||||
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
|
{{ t('CAPTAIN.INBOXES.REMINDER_TOOL.HELP') }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -253,8 +253,8 @@ const handleSubmit = async () => {
|
|||||||
v-for="(header, index) in customHeaders"
|
v-for="(header, index) in customHeaders"
|
||||||
:key="index"
|
:key="index"
|
||||||
ref="headersRef"
|
ref="headersRef"
|
||||||
v-model:key="header.key"
|
v-model:header-key="header.key"
|
||||||
v-model:value="header.value"
|
v-model:header-value="header.value"
|
||||||
@remove="removeHeader(index)"
|
@remove="removeHeader(index)"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -8,27 +8,27 @@ const emit = defineEmits(['remove']);
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const showErrors = ref(false);
|
const showErrors = ref(false);
|
||||||
|
|
||||||
const key = defineModel('key', {
|
const headerKey = defineModel('headerKey', {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const value = defineModel('value', {
|
const headerValue = defineModel('headerValue', {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const validationError = computed(() => {
|
const validationError = computed(() => {
|
||||||
if (!key.value || key.value.trim() === '') {
|
if (!headerKey.value || headerKey.value.trim() === '') {
|
||||||
return 'HEADER_KEY_REQUIRED';
|
return 'HEADER_KEY_REQUIRED';
|
||||||
}
|
}
|
||||||
if (!value.value || value.value.trim() === '') {
|
if (!headerValue.value || headerValue.value.trim() === '') {
|
||||||
return 'HEADER_VALUE_REQUIRED';
|
return 'HEADER_VALUE_REQUIRED';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([key, value], () => {
|
watch([headerKey, headerValue], () => {
|
||||||
showErrors.value = false;
|
showErrors.value = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -52,11 +52,11 @@ defineExpose({ validate });
|
|||||||
<div class="flex flex-col flex-1 gap-3">
|
<div class="flex flex-col flex-1 gap-3">
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<Input
|
<Input
|
||||||
v-model="key"
|
v-model="headerKey"
|
||||||
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_KEY.PLACEHOLDER')"
|
:placeholder="t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_KEY.PLACEHOLDER')"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
v-model="value"
|
v-model="headerValue"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_VALUE.PLACEHOLDER')
|
t('CAPTAIN.CUSTOM_TOOLS.FORM.HEADER_VALUE.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { ref, computed } from 'vue';
|
||||||
import { useStore } from 'dashboard/composables/store';
|
import { useStore } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
@ -7,11 +7,19 @@ import { useI18n } from 'vue-i18n';
|
|||||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import ConnectInboxForm from './ConnectInboxForm.vue';
|
import ConnectInboxForm from './ConnectInboxForm.vue';
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
assistantId: {
|
assistantId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'create',
|
||||||
|
},
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['close']);
|
const emit = defineEmits(['close']);
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@ -20,15 +28,19 @@ const store = useStore();
|
|||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
const connectForm = 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 => {
|
const handleSubmit = async payload => {
|
||||||
try {
|
try {
|
||||||
await store.dispatch('captainInboxes/create', payload);
|
const action =
|
||||||
useAlert(t(`${i18nKey}.SUCCESS_MESSAGE`));
|
props.type === 'edit' ? 'captainInboxes/update' : 'captainInboxes/create';
|
||||||
|
await store.dispatch(action, payload);
|
||||||
|
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));
|
||||||
dialogRef.value.close();
|
dialogRef.value.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error?.message || t(`${i18nKey}.ERROR_MESSAGE`);
|
const errorMessage = error?.message || t(`${i18nKey.value}.ERROR_MESSAGE`);
|
||||||
useAlert(errorMessage);
|
useAlert(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -48,7 +60,7 @@ defineExpose({ dialogRef });
|
|||||||
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
<!-- eslint-disable vue/no-bare-strings-in-template -->
|
||||||
<Dialog
|
<Dialog
|
||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
type="create"
|
:type="type"
|
||||||
:title="$t(`${i18nKey}.TITLE`)"
|
:title="$t(`${i18nKey}.TITLE`)"
|
||||||
:description="$t('CAPTAIN.INBOXES.FORM_DESCRIPTION')"
|
:description="$t('CAPTAIN.INBOXES.FORM_DESCRIPTION')"
|
||||||
:show-cancel-button="false"
|
:show-cancel-button="false"
|
||||||
@ -58,6 +70,7 @@ defineExpose({ dialogRef });
|
|||||||
<ConnectInboxForm
|
<ConnectInboxForm
|
||||||
ref="connectForm"
|
ref="connectForm"
|
||||||
:assistant-id="assistantId"
|
:assistant-id="assistantId"
|
||||||
|
:inbox="inbox"
|
||||||
@submit="handleSubmit"
|
@submit="handleSubmit"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -14,6 +14,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
type: Object,
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
@ -28,8 +32,8 @@ const formState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
inboxId: null,
|
inboxId: props.inbox?.captain_inbox?.inbox_id || null,
|
||||||
captainUnitId: null,
|
captainUnitId: props.inbox?.captain_inbox?.captain_unit_id || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
@ -46,21 +50,65 @@ const accountId = computed(() => {
|
|||||||
const inboxList = computed(() => {
|
const inboxList = computed(() => {
|
||||||
const captainInboxIds = formState.captainInboxes.value.map(inbox => inbox.id);
|
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))
|
.filter(inbox => !captainInboxIds.includes(inbox.id))
|
||||||
.map(inbox => ({
|
.map(inbox => ({
|
||||||
value: inbox.id,
|
value: inbox.id,
|
||||||
label: inbox.name,
|
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(() => {
|
const unitList = computed(() => {
|
||||||
return units.map(unit => ({
|
return [
|
||||||
value: unit.id,
|
{ value: null, label: 'Sem Unidade' },
|
||||||
label: unit.name,
|
...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 v$ = useVuelidate(validationRules, state);
|
||||||
|
|
||||||
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
|
||||||
@ -82,6 +130,7 @@ const prepareInboxPayload = () => ({
|
|||||||
inboxId: state.inboxId,
|
inboxId: state.inboxId,
|
||||||
captain_unit_id: state.captainUnitId,
|
captain_unit_id: state.captainUnitId,
|
||||||
assistantId: props.assistantId,
|
assistantId: props.assistantId,
|
||||||
|
...(props.inbox ? { id: props.inbox.captain_inbox.id } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
@ -136,6 +185,7 @@ watch(accountId, () => {
|
|||||||
:placeholder="t('CAPTAIN.INBOXES.FORM.INBOX.PLACEHOLDER')"
|
: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"
|
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"
|
:message="formErrors.inboxId"
|
||||||
|
:disabled="!!inbox"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -169,7 +219,11 @@ watch(accountId, () => {
|
|||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
:label="t('CAPTAIN.FORM.CREATE')"
|
:label="
|
||||||
|
props.inbox
|
||||||
|
? t('CAPTAIN.INBOXES.EDIT.SAVE')
|
||||||
|
: t('CAPTAIN.FORM.CREATE')
|
||||||
|
"
|
||||||
class="w-full"
|
class="w-full"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
|
|||||||
@ -37,6 +37,7 @@ const isPartial = computed(
|
|||||||
() => props.reservation.payment_status === 'partial'
|
() => props.reservation.payment_status === 'partial'
|
||||||
);
|
);
|
||||||
const isPending = computed(() => !isPaid.value && !isPartial.value);
|
const isPending = computed(() => !isPaid.value && !isPartial.value);
|
||||||
|
const sourceTag = computed(() => props.reservation.source_tag || '');
|
||||||
|
|
||||||
// Relative Time Logic
|
// Relative Time Logic
|
||||||
const timeDisplay = computed(() => {
|
const timeDisplay = computed(() => {
|
||||||
@ -190,7 +191,7 @@ const showMenu = ref(false);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Row 2: Guest Name -->
|
<!-- 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 -->
|
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
|
||||||
<span
|
<span
|
||||||
class="font-bold text-slate-900 dark:text-slate-100 text-base leading-tight line-clamp-2"
|
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 }}
|
{{ guestName }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Row 3: Financial + Channel -->
|
<!-- 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">
|
<span class="text-sm font-bold text-slate-900 dark:text-slate-100">
|
||||||
{{ formatCurrency(reservation.total_amount) }}
|
{{ formatCurrency(reservation.total_amount) }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center gap-1 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm w-fit"
|
class="flex items-center gap-1 text-[10px] uppercase font-bold px-1.5 py-0.5 rounded-sm w-fit"
|
||||||
:class="{
|
:class="{
|
||||||
|
|||||||
@ -43,6 +43,21 @@
|
|||||||
"STAYS_LABEL": "Accepted Stay Durations",
|
"STAYS_LABEL": "Accepted Stay Durations",
|
||||||
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Overnight, Daily (separated by comma)"
|
"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": {
|
"CAPTAIN": {
|
||||||
"NAME": "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",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
"ASSISTANT_SWITCHER": {
|
"ASSISTANT_SWITCHER": {
|
||||||
"ASSISTANTS": "Assistants",
|
"ASSISTANTS": "Assistants",
|
||||||
@ -1282,7 +1292,8 @@
|
|||||||
"ERROR": "Could not update reminder tool preference"
|
"ERROR": "Could not update reminder tool preference"
|
||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"DISCONNECT": "Disconnect"
|
"DISCONNECT": "Disconnect",
|
||||||
|
"EDIT": "Edit"
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Are you sure to disconnect the inbox?",
|
"TITLE": "Are you sure to disconnect the inbox?",
|
||||||
|
|||||||
@ -43,6 +43,21 @@
|
|||||||
"STAYS_LABEL": "Durações Aceitas",
|
"STAYS_LABEL": "Durações Aceitas",
|
||||||
"STAYS_PLACEHOLDER": "Ex: 2h, 4h, Pernoite, Diária (separados por vírgula)"
|
"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_LABEL": "Nome da Unidade",
|
||||||
"NAME_PLACEHOLDER": "Ex: Unidade Centro",
|
"NAME_PLACEHOLDER": "Ex: Unidade Centro",
|
||||||
"NAME_ERROR": "O nome é obrigatório",
|
"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_LABEL": "Marca",
|
||||||
"BRAND_PLACEHOLDER": "Selecione uma marca",
|
"BRAND_PLACEHOLDER": "Selecione uma marca",
|
||||||
"FORM_DESCRIPTION": "Preencha os dados da unidade e as credenciais do Pix (Banco Inter).",
|
"FORM_DESCRIPTION": "Preencha os dados da unidade e as credenciais do Pix (Banco Inter).",
|
||||||
@ -399,7 +403,9 @@
|
|||||||
"CLIENT_SECRET": "Client Secret",
|
"CLIENT_SECRET": "Client Secret",
|
||||||
"CERT_PATH": "Caminho do Certificado (.pem)",
|
"CERT_PATH": "Caminho do Certificado (.pem)",
|
||||||
"KEY_PATH": "Caminho da Chave (.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",
|
"HEADER_KNOW_MORE": "Know more",
|
||||||
@ -1457,7 +1463,12 @@
|
|||||||
"ERROR": "Nao foi possivel atualizar a preferencia"
|
"ERROR": "Nao foi possivel atualizar a preferencia"
|
||||||
},
|
},
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"DISCONNECT": "Desconectar"
|
"DISCONNECT": "Desconectar",
|
||||||
|
"EDIT": "Editar"
|
||||||
|
},
|
||||||
|
"EDIT": {
|
||||||
|
"TITLE": "Editar caixa de entrada",
|
||||||
|
"SAVE": "Atualizar"
|
||||||
},
|
},
|
||||||
"DELETE": {
|
"DELETE": {
|
||||||
"TITLE": "Tem certeza que deseja desconectar a caixa de entrada?",
|
"TITLE": "Tem certeza que deseja desconectar a caixa de entrada?",
|
||||||
|
|||||||
@ -35,6 +35,17 @@ const handleCreate = () => {
|
|||||||
dialogType.value = 'create';
|
dialogType.value = 'create';
|
||||||
nextTick(() => connectInboxDialog.value.dialogRef.open());
|
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 }) => {
|
const handleAction = ({ action, id }) => {
|
||||||
selectedInbox.value = captainInboxes.value.find(
|
selectedInbox.value = captainInboxes.value.find(
|
||||||
inbox => id === inbox.captain_inbox.id
|
inbox => id === inbox.captain_inbox.id
|
||||||
@ -42,15 +53,12 @@ const handleAction = ({ action, id }) => {
|
|||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (action === 'delete') {
|
if (action === 'delete') {
|
||||||
handleDelete();
|
handleDelete();
|
||||||
|
} else if (action === 'edit') {
|
||||||
|
handleUpdate();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateClose = () => {
|
|
||||||
dialogType.value = '';
|
|
||||||
selectedInbox.value = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() =>
|
onMounted(() =>
|
||||||
store.dispatch('captainInboxes/get', {
|
store.dispatch('captainInboxes/get', {
|
||||||
assistantId: assistantId.value,
|
assistantId: assistantId.value,
|
||||||
@ -103,6 +111,7 @@ onMounted(() =>
|
|||||||
ref="connectInboxDialog"
|
ref="connectInboxDialog"
|
||||||
:assistant-id="assistantId"
|
:assistant-id="assistantId"
|
||||||
:type="dialogType"
|
:type="dialogType"
|
||||||
|
:inbox="selectedInbox"
|
||||||
@close="handleCreateClose"
|
@close="handleCreateClose"
|
||||||
/>
|
/>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|||||||
@ -236,7 +236,7 @@ onMounted(() => {
|
|||||||
:placeholder="
|
:placeholder="
|
||||||
$t('CAPTAIN.ASSISTANTS.SKILLS.FALLBACK.PLACEHOLDER')
|
$t('CAPTAIN.ASSISTANTS.SKILLS.FALLBACK.PLACEHOLDER')
|
||||||
"
|
"
|
||||||
:max-length="400"
|
:max-length="800"
|
||||||
show-character-count
|
show-character-count
|
||||||
@blur="handleFallbackUpdate(tool)"
|
@blur="handleFallbackUpdate(tool)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ const isLoading = ref(false);
|
|||||||
const isFetchingUnits = ref(false);
|
const isFetchingUnits = ref(false);
|
||||||
const isUpdating = ref(false);
|
const isUpdating = ref(false);
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
|
const isSyncing = ref(false);
|
||||||
|
|
||||||
const reservations = ref([]);
|
const reservations = ref([]);
|
||||||
const units = 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 ---
|
// --- Kanban Logic ---
|
||||||
|
|
||||||
const isInShiftWindow = dateString => {
|
const isInShiftWindow = dateString => {
|
||||||
@ -594,6 +609,18 @@ watch(
|
|||||||
title="Atualizar"
|
title="Atualizar"
|
||||||
@click="fetchReservations"
|
@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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -41,10 +41,15 @@ export default {
|
|||||||
inter_key_path: '',
|
inter_key_path: '',
|
||||||
inter_account_number: '',
|
inter_account_number: '',
|
||||||
webhook_url: '',
|
webhook_url: '',
|
||||||
|
leader_whatsapp: '',
|
||||||
|
reservation_source_tag: '',
|
||||||
inbox_id: '',
|
inbox_id: '',
|
||||||
brands: [],
|
brands: [],
|
||||||
inboxes: [],
|
inboxes: [],
|
||||||
isLoadingBrands: false,
|
isLoadingBrands: false,
|
||||||
|
plug_play_id: '',
|
||||||
|
plug_play_token: '',
|
||||||
|
reservations_sync_enabled: false,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
validations() {
|
validations() {
|
||||||
@ -68,7 +73,13 @@ export default {
|
|||||||
this.inter_key_path = this.unit.inter_key_path;
|
this.inter_key_path = this.unit.inter_key_path;
|
||||||
this.inter_account_number = this.unit.inter_account_number;
|
this.inter_account_number = this.unit.inter_account_number;
|
||||||
this.webhook_url = this.unit.webhook_url;
|
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.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 {
|
} else {
|
||||||
this.resetForm();
|
this.resetForm();
|
||||||
}
|
}
|
||||||
@ -86,7 +97,12 @@ export default {
|
|||||||
this.inter_key_path = '';
|
this.inter_key_path = '';
|
||||||
this.inter_account_number = '';
|
this.inter_account_number = '';
|
||||||
this.webhook_url = '';
|
this.webhook_url = '';
|
||||||
|
this.leader_whatsapp = '';
|
||||||
|
this.reservation_source_tag = '';
|
||||||
this.inbox_id = '';
|
this.inbox_id = '';
|
||||||
|
this.plug_play_id = '';
|
||||||
|
this.plug_play_token = '';
|
||||||
|
this.reservations_sync_enabled = false;
|
||||||
this.v$.$reset();
|
this.v$.$reset();
|
||||||
},
|
},
|
||||||
async fetchInboxes() {
|
async fetchInboxes() {
|
||||||
@ -126,7 +142,12 @@ export default {
|
|||||||
inter_key_path: this.inter_key_path,
|
inter_key_path: this.inter_key_path,
|
||||||
inter_account_number: this.inter_account_number,
|
inter_account_number: this.inter_account_number,
|
||||||
webhook_url: this.webhook_url,
|
webhook_url: this.webhook_url,
|
||||||
|
leader_whatsapp: this.leader_whatsapp,
|
||||||
|
reservation_source_tag: this.reservation_source_tag,
|
||||||
inbox_id: this.inbox_id,
|
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 {
|
try {
|
||||||
@ -181,6 +202,12 @@ export default {
|
|||||||
:error="v$.name.$error ? $t('CAPTAIN.UNITS.FORM.NAME_ERROR') : ''"
|
: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>
|
<div>
|
||||||
<label
|
<label
|
||||||
class="block mb-1.5 text-sm font-medium text-slate-700 dark:text-slate-200"
|
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')"
|
:label="$t('CAPTAIN.UNITS.FORM.WEBHOOK_URL')"
|
||||||
placeholder="https://webhook.n8n.cloud/webhook/..."
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
<!-- 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
|
class CaptainAssistant < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_many :captain_tool_configs, dependent: :destroy
|
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
|
class CaptainDocument < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :assistant, class_name: 'CaptainAssistant'
|
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
|
class CaptainScenario < ApplicationRecord
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
belongs_to :assistant, class_name: 'CaptainAssistant'
|
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 :reminders, only: [:index, :show, :create, :destroy]
|
||||||
resources :inbox_automations, only: [:index, :create, :update, :destroy]
|
resources :inbox_automations, only: [:index, :create, :update, :destroy]
|
||||||
resources :payment_callbacks, only: [:update]
|
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 :brands
|
||||||
resources :pricings
|
resources :pricings
|
||||||
resources :extras
|
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.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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 ["contact_inbox_id"], name: "index_captain_reservations_on_contact_inbox_id"
|
||||||
t.index ["conversation_id"], name: "index_captain_reservations_on_conversation_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 ["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"
|
t.index ["integracao_id"], name: "index_captain_reservations_on_integracao_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -627,6 +628,12 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_20_141736) do
|
|||||||
t.string "inter_account_number"
|
t.string "inter_account_number"
|
||||||
t.string "webhook_url"
|
t.string "webhook_url"
|
||||||
t.bigint "inbox_id"
|
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 ["account_id"], name: "index_captain_units_on_account_id"
|
||||||
t.index ["captain_brand_id"], name: "index_captain_units_on_captain_brand_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"
|
t.index ["inbox_id"], name: "index_captain_units_on_inbox_id"
|
||||||
|
|||||||
@ -39,10 +39,12 @@ module Api
|
|||||||
def unit_params
|
def unit_params
|
||||||
params.require(:unit).permit(
|
params.require(:unit).permit(
|
||||||
:name, :status, :captain_brand_id,
|
:name, :status, :captain_brand_id,
|
||||||
|
:reservations_sync_enabled,
|
||||||
|
:plug_play_id, :plug_play_token,
|
||||||
:inter_client_id, :inter_client_secret,
|
:inter_client_id, :inter_client_secret,
|
||||||
:inter_pix_key, :inter_cert_path,
|
:inter_pix_key, :inter_cert_path,
|
||||||
:inter_key_path, :inter_account_number,
|
:inter_key_path, :inter_account_number,
|
||||||
:webhook_url, :inbox_id
|
:webhook_url, :inbox_id, :leader_whatsapp, :reservation_source_tag
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -123,7 +123,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
case action
|
case action
|
||||||
when 'handoff'
|
when 'handoff'
|
||||||
if handoff_allowed?
|
if handoff_allowed?
|
||||||
process_action('handoff')
|
process_action('handoff', trigger_key: trigger_key)
|
||||||
return true
|
return true
|
||||||
else
|
else
|
||||||
@response['response'] = fallback_handoff_blocked_message
|
@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.')
|
default: 'Desculpe, estou com dificuldades técnicas no momento. Por favor, tente novamente em alguns instantes.')
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_action(action)
|
def process_action(action, trigger_key: nil)
|
||||||
case action
|
case action
|
||||||
when 'handoff'
|
when 'handoff'
|
||||||
I18n.with_locale(@assistant.account.locale) do
|
I18n.with_locale(@assistant.account.locale) do
|
||||||
@ -336,6 +336,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
@conversation.add_labels(['pausar_ia'])
|
@conversation.add_labels(['pausar_ia'])
|
||||||
@conversation.save!
|
@conversation.save!
|
||||||
apply_handoff_side_effects
|
apply_handoff_side_effects
|
||||||
|
handle_sentiment_handoff_alerts if trigger_key.to_s == 'sentiment'
|
||||||
deliver_handoff_webhook
|
deliver_handoff_webhook
|
||||||
log_handoff_event
|
log_handoff_event
|
||||||
send_out_of_office_message_if_applicable
|
send_out_of_office_message_if_applicable
|
||||||
@ -347,6 +348,107 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
|||||||
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
::MessageTemplates::Template::OutOfOffice.perform_if_applicable(@conversation)
|
||||||
end
|
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
|
def create_handoff_message
|
||||||
create_outgoing_message(
|
create_outgoing_message(
|
||||||
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
@assistant.config['handoff_message'].presence || I18n.t('conversations.captain.handoff')
|
||||||
|
|||||||
@ -6,7 +6,7 @@ module Captain
|
|||||||
belongs_to :inbox
|
belongs_to :inbox
|
||||||
belongs_to :contact
|
belongs_to :contact
|
||||||
belongs_to :contact_inbox
|
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 :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 :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true
|
||||||
belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', 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 :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id'
|
||||||
belongs_to :inbox, optional: true
|
belongs_to :inbox, optional: true
|
||||||
has_many :reservations, class_name: 'Captain::Reservation', foreign_key: 'captain_unit_id'
|
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 :pix_charges, class_name: 'Captain::PixCharge'
|
||||||
has_many :captain_inboxes, class_name: 'CaptainInbox', foreign_key: 'captain_unit_id'
|
has_many :captain_inboxes, class_name: 'CaptainInbox', foreign_key: 'captain_unit_id'
|
||||||
|
|
||||||
|
|||||||
@ -283,14 +283,25 @@ module Captain
|
|||||||
end
|
end
|
||||||
|
|
||||||
def apply_fallback(result)
|
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?
|
return result unless fallback_configured?
|
||||||
|
|
||||||
{
|
{
|
||||||
success: true,
|
success: true,
|
||||||
body: { message: @config.fallback_message.to_s },
|
body: { message: @config.fallback_message.to_s },
|
||||||
fallback: true,
|
fallback: true,
|
||||||
error: result[:error]
|
error: result[:error] || result.dig(:body, :error)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -2,5 +2,6 @@ json.partial! 'api/v1/models/inbox', resource: @captain_inbox.inbox
|
|||||||
json.captain_inbox do
|
json.captain_inbox do
|
||||||
json.id @captain_inbox.id
|
json.id @captain_inbox.id
|
||||||
json.captain_assistant_id @captain_inbox.captain_assistant_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
|
json.always_use_reminder_tool @captain_inbox.always_use_reminder_tool
|
||||||
end
|
end
|
||||||
|
|||||||
@ -4,6 +4,7 @@ json.payload do
|
|||||||
json.captain_inbox do
|
json.captain_inbox do
|
||||||
json.id captain_inbox.id
|
json.id captain_inbox.id
|
||||||
json.captain_assistant_id captain_inbox.captain_assistant_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
|
json.always_use_reminder_tool captain_inbox.always_use_reminder_tool
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -2,5 +2,6 @@ json.partial! 'api/v1/models/inbox', resource: @captain_inbox.inbox
|
|||||||
json.captain_inbox do
|
json.captain_inbox do
|
||||||
json.id @captain_inbox.id
|
json.id @captain_inbox.id
|
||||||
json.captain_assistant_id @captain_inbox.captain_assistant_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
|
json.always_use_reminder_tool @captain_inbox.always_use_reminder_tool
|
||||||
end
|
end
|
||||||
|
|||||||
@ -11,6 +11,7 @@ json.check_out_at reservation.check_out_at&.iso8601
|
|||||||
json.status reservation.status
|
json.status reservation.status
|
||||||
json.payment_status reservation.payment_status
|
json.payment_status reservation.payment_status
|
||||||
json.total_amount reservation.total_amount
|
json.total_amount reservation.total_amount
|
||||||
|
json.source_tag reservation.metadata&.fetch('source_tag', nil)
|
||||||
json.unit do
|
json.unit do
|
||||||
json.partial! 'api/v1/models/captain/unit', unit: reservation.unit if reservation.unit
|
json.partial! 'api/v1/models/captain/unit', unit: reservation.unit if reservation.unit
|
||||||
end
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user