refactor: move notification templates de units para inboxes

- Arquitetura corrigida: templates agora pertencem à inbox (WhatsApp),
  não à unidade PIX (que é uma config financeira, não de mensagens)
- Migration: troca FK captain_unit_id -> inbox_id (up/down explícito)
- Model: belongs_to :inbox; scope for_inbox
- Controller: escopo via account.inboxes.find(inbox_id)
- Rotas: move de captain/units/:id → inboxes/:id/notification_templates
- Scanner job: joins(:conversation).where(conversations: {inbox_id:})
- UI: página /captain/notifications com seletor de inbox no topo
  (chips clicáveis, templates carregam por watch no selectedInboxId)
- i18n PT/EN: novas keys INBOX_LABEL, SELECT_INBOX_HINT, EMPTY
This commit is contained in:
Rodrigo Borba 2026-03-01 22:17:27 -03:00
parent ce2904e57f
commit 84fff38d94
13 changed files with 372 additions and 279 deletions

View File

@ -1,30 +1,29 @@
/* global axios */
import ApiClient from '../ApiClient'; import ApiClient from '../ApiClient';
class CaptainNotificationTemplatesAPI extends ApiClient { class NotificationTemplatesAPI extends ApiClient {
constructor() { constructor() {
super('captain/units', { accountScoped: true }); super('inboxes', { accountScoped: true });
} }
getTemplates(unitId) { getAll(inboxId) {
return axios.get(`${this.url}/${unitId}/notification_templates`); return this.get(`${inboxId}/notification_templates`);
} }
createTemplate(unitId, data) { create(inboxId, data) {
return axios.post(`${this.url}/${unitId}/notification_templates`, { return this.post(`${inboxId}/notification_templates`, {
notification_template: data, notification_template: data,
}); });
} }
updateTemplate(unitId, id, data) { update(inboxId, id, data) {
return axios.patch(`${this.url}/${unitId}/notification_templates/${id}`, { return this.patch(`${inboxId}/notification_templates/${id}`, {
notification_template: data, notification_template: data,
}); });
} }
deleteTemplate(unitId, id) { delete(inboxId, id) {
return axios.delete(`${this.url}/${unitId}/notification_templates/${id}`); return this.delete(`${inboxId}/notification_templates/${id}`);
} }
} }
export default new CaptainNotificationTemplatesAPI(); export default new NotificationTemplatesAPI();

View File

@ -476,6 +476,13 @@
"DELETE": { "DELETE": {
"SUCCESS": "Notification removed.", "SUCCESS": "Notification removed.",
"ERROR": "Error removing notification." "ERROR": "Error removing notification."
},
"INBOX_LABEL": "Select inbox",
"NO_CAPTAIN_INBOXES": "No inboxes with Captain configured.",
"SELECT_INBOX_HINT": "Click an inbox above to view and configure its templates.",
"EMPTY": {
"TITLE": "No templates configured",
"DESC": "Create automatic message templates for this inbox."
} }
} }
} }

View File

@ -477,6 +477,13 @@
"DELETE": { "DELETE": {
"SUCCESS": "Notificação removida.", "SUCCESS": "Notificação removida.",
"ERROR": "Erro ao remover notificação." "ERROR": "Erro ao remover notificação."
},
"INBOX_LABEL": "Selecione a caixa de entrada",
"NO_CAPTAIN_INBOXES": "Nenhuma caixa de entrada com Captain configurado.",
"SELECT_INBOX_HINT": "Clique em uma caixa de entrada acima para ver e configurar os templates.",
"EMPTY": {
"TITLE": "Nenhum template configurado",
"DESC": "Crie templates de mensagem automática para esta caixa de entrada."
} }
} }
} }

View File

@ -79,7 +79,7 @@ export default {
}, },
}, },
{ {
path: 'units/:unitId/notifications', path: 'notifications',
name: 'captain_settings_notifications', name: 'captain_settings_notifications',
component: NotificationsIndex, component: NotificationsIndex,
meta: { meta: {

View File

@ -1,6 +1,5 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, watch, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@ -9,17 +8,24 @@ import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n(); const { t } = useI18n();
const route = useRoute();
const store = useStore(); const store = useStore();
const unitId = computed(() => route.params.unitId); const inboxes = useMapGetter('inboxes/getInboxes');
const templates = useMapGetter('captainNotificationTemplates/getRecords');
const templates = useMapGetter('captainNotificationTemplates/getTemplates');
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags'); const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
const selectedInboxId = ref(null);
const editingId = ref(null); const editingId = ref(null);
const showNewForm = ref(false); const showNewForm = ref(false);
// Inboxes com Captain assistant
const captainInboxes = computed(() =>
(inboxes.value || []).filter(i => i.captain_assistant_id)
);
const hasInboxes = computed(() => captainInboxes.value.length > 0);
// Formulários
const emptyForm = () => ({ const emptyForm = () => ({
label: '', label: '',
content: '', content: '',
@ -39,12 +45,20 @@ const VARIABLES = [
'{{unit_name}}', '{{unit_name}}',
]; ];
// Carregamento
onMounted(async () => { onMounted(async () => {
if (unitId.value) { await store.dispatch('inboxes/get');
await store.dispatch('captainNotificationTemplates/fetch', unitId.value); });
watch(selectedInboxId, async id => {
if (id) {
await store.dispatch('captainNotificationTemplates/fetch', id);
showNewForm.value = false;
editingId.value = null;
} }
}); });
// Novo template
const openNewForm = () => { const openNewForm = () => {
newForm.value = emptyForm(); newForm.value = emptyForm();
showNewForm.value = true; showNewForm.value = true;
@ -59,8 +73,8 @@ const saveNew = async () => {
if (!newForm.value.label || !newForm.value.content) return; if (!newForm.value.label || !newForm.value.content) return;
try { try {
await store.dispatch('captainNotificationTemplates/create', { await store.dispatch('captainNotificationTemplates/create', {
unitId: unitId.value, inboxId: selectedInboxId.value,
...newForm.value, payload: newForm.value,
}); });
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS')); useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
showNewForm.value = false; showNewForm.value = false;
@ -70,6 +84,7 @@ const saveNew = async () => {
} }
}; };
// Edição
const startEdit = template => { const startEdit = template => {
editingId.value = template.id; editingId.value = template.id;
editForm.value = { ...template }; editForm.value = { ...template };
@ -83,9 +98,9 @@ const cancelEdit = () => {
const saveEdit = async () => { const saveEdit = async () => {
try { try {
await store.dispatch('captainNotificationTemplates/update', { await store.dispatch('captainNotificationTemplates/update', {
unitId: unitId.value, inboxId: selectedInboxId.value,
id: editingId.value, id: editingId.value,
...editForm.value, payload: editForm.value,
}); });
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS')); useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
editingId.value = null; editingId.value = null;
@ -94,22 +109,24 @@ const saveEdit = async () => {
} }
}; };
// Toggle ativo
const toggleActive = async template => { const toggleActive = async template => {
try { try {
await store.dispatch('captainNotificationTemplates/update', { await store.dispatch('captainNotificationTemplates/update', {
unitId: unitId.value, inboxId: selectedInboxId.value,
id: template.id, id: template.id,
active: !template.active, payload: { active: !template.active },
}); });
} catch { } catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR')); useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
} }
}; };
// Exclusão
const deleteTemplate = async template => { const deleteTemplate = async template => {
try { try {
await store.dispatch('captainNotificationTemplates/delete', { await store.dispatch('captainNotificationTemplates/delete', {
unitId: unitId.value, inboxId: selectedInboxId.value,
id: template.id, id: template.id,
}); });
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS')); useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
@ -118,6 +135,7 @@ const deleteTemplate = async template => {
} }
}; };
// Variáveis
const insertVariable = (variable, target) => { const insertVariable = (variable, target) => {
if (target === 'new') { if (target === 'new') {
newForm.value.content += variable; newForm.value.content += variable;
@ -144,107 +162,221 @@ const timingDisplay = template =>
<BaseSettingsHeader <BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')" :title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
:description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')" :description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')"
/> >
<template #actions>
<Button
v-if="selectedInboxId && !showNewForm"
icon="i-lucide-plus"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
@click="openNewForm"
/>
</template>
</BaseSettingsHeader>
</template> </template>
<template #body> <template #body>
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-6 px-6 pb-8">
<!-- Template list --> <!-- Seletor de inbox -->
<div <div class="flex flex-col gap-2">
v-for="template in templates" <label class="text-sm font-medium text-n-slate-12">
:key="template.id" {{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.INBOX_LABEL') }}
class="rounded-lg border border-n-75 bg-white p-4" </label>
> <div v-if="!hasInboxes" class="text-sm text-n-slate-10">
<!-- View mode --> {{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.NO_CAPTAIN_INBOXES') }}
</div>
<div v-else class="flex flex-wrap gap-2">
<button
v-for="inbox in captainInboxes"
:key="inbox.id"
class="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm transition-colors"
:class="
selectedInboxId === inbox.id
? 'border-w-500 bg-w-50 text-w-700 font-medium'
: 'border-n-weak text-n-slate-11 hover:border-n-300'
"
@click="selectedInboxId = inbox.id"
>
<span class="i-lucide-message-circle w-4 h-4" />
{{ inbox.name }}
</button>
</div>
</div>
<!-- Conteúdo: aparece após selecionar inbox -->
<div v-if="selectedInboxId" class="flex flex-col gap-3">
<!-- Template list -->
<div <div
v-if="editingId !== template.id" v-for="template in templates"
class="flex items-start justify-between gap-3" :key="template.id"
class="rounded-lg border border-n-75 bg-white p-4"
> >
<div class="flex flex-col gap-1 flex-1 min-w-0"> <!-- View mode -->
<span class="text-sm font-semibold text-n-900">{{ <div
template.label v-if="editingId !== template.id"
}}</span> class="flex items-start justify-between gap-3"
<span class="text-sm text-n-600 whitespace-pre-line">{{ >
template.content <div class="flex flex-col gap-1 flex-1 min-w-0">
}}</span> <span class="text-sm font-semibold text-n-slate-12">{{
<span class="text-xs text-n-500 mt-1"> template.label
{{ timingDisplay(template) }} }}</span>
</span> <span class="text-sm text-n-slate-11 whitespace-pre-line">{{
template.content
}}</span>
<span class="text-xs text-n-slate-10 mt-1">
{{ timingDisplay(template) }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="text-xs px-2 py-1 rounded"
:class="
template.active
? 'bg-n-teal-2 text-n-teal-11'
: 'bg-n-slate-3 text-n-slate-11'
"
@click="toggleActive(template)"
>
{{
template.active
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
}}
</button>
<button
class="text-n-slate-10 hover:text-n-slate-12"
@click="startEdit(template)"
>
<span class="i-lucide-pencil w-4 h-4" />
</button>
<button
class="text-n-ruby-9 hover:text-n-ruby-11"
@click="deleteTemplate(template)"
>
<span class="i-lucide-trash-2 w-4 h-4" />
</button>
</div>
</div> </div>
<div class="flex items-center gap-2 shrink-0">
<button <!-- Edit mode -->
class="text-xs px-2 py-1 rounded" <div v-else class="flex flex-col gap-3">
:class=" <input
template.active ? 'bg-g-100 text-g-700' : 'bg-n-75 text-n-500' v-model="editForm.label"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
" "
@click="toggleActive(template)" class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
> />
{{ <textarea
template.active v-model="editForm.content"
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE') :placeholder="
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE') t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
}} "
</button> rows="3"
<button class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
class="text-n-500 hover:text-n-700" />
@click="startEdit(template)" <!-- Variable chips -->
> <div class="flex flex-wrap gap-1">
<span class="i-lucide-pencil w-4 h-4" /> <button
</button> v-for="v in VARIABLES"
<button :key="v"
class="text-r-500 hover:text-r-700" class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
@click="deleteTemplate(template)" @click="insertVariable(v, 'edit')"
> >
<span class="i-lucide-trash-2 w-4 h-4" /> {{ v }}
</button> </button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="editForm.timing_minutes"
type="number"
min="1"
class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="editForm.timing_direction"
class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
</option>
<option value="after">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelEdit"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isSaving"
@click="saveEdit"
/>
</div>
</div> </div>
</div> </div>
<!-- Edit mode --> <!-- New form -->
<div v-else class="flex flex-col gap-3"> <div
v-if="showNewForm"
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
>
<input <input
v-model="editForm.label" v-model="newForm.label"
:placeholder=" :placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER') t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
" "
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500" class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/> />
<textarea <textarea
v-model="editForm.content" v-model="newForm.content"
:placeholder=" :placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER') t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
" "
rows="3" rows="3"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none" class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
/> />
<!-- Variable chips --> <!-- Variable chips -->
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<button <button
v-for="v in VARIABLES" v-for="v in VARIABLES"
:key="v" :key="v"
class="text-xs bg-n-75 text-n-700 px-2 py-0.5 rounded hover:bg-n-100" class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
@click="insertVariable(v, 'edit')" @click="insertVariable(v, 'new')"
> >
{{ v }} {{ v }}
</button> </button>
</div> </div>
<!-- Timing row --> <!-- Timing row -->
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-n-600">{{ <span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND') t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span> }}</span>
<input <input
v-model.number="editForm.timing_minutes" v-model.number="newForm.timing_minutes"
type="number" type="number"
min="1" min="1"
class="w-16 rounded border border-n-200 px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500" class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/> />
<span class="text-n-600">{{ <span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES') t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span> }}</span>
<select <select
v-model="editForm.timing_direction" v-model="newForm.timing_direction"
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500" class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
> >
<option value="before"> <option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }} {{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
@ -253,7 +385,7 @@ const timingDisplay = template =>
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }} {{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option> </option>
</select> </select>
<span class="text-n-600">{{ <span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL') t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span> }}</span>
</div> </div>
@ -261,100 +393,52 @@ const timingDisplay = template =>
<Button <Button
variant="clear" variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')" :label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelEdit" @click="cancelNew"
/> />
<Button <Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')" :label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isUpdating" :is-loading="uiFlags.isSaving"
@click="saveEdit" @click="saveNew"
/> />
</div> </div>
</div> </div>
<!-- Empty state (sem templates, sem form aberto) -->
<div
v-if="!templates.length && !showNewForm"
class="flex flex-col items-center justify-center gap-4 py-16 text-center"
>
<div
class="size-14 rounded-full bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-bell w-6 h-6 text-n-slate-10" />
</div>
<div class="flex flex-col gap-1">
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.TITLE') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.DESC') }}
</p>
</div>
<Button
icon="i-lucide-plus"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
@click="openNewForm"
/>
</div>
</div> </div>
<!-- New form --> <!-- Estado inicial: nenhuma inbox selecionada -->
<div <div
v-if="showNewForm" v-else-if="hasInboxes"
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3" class="flex flex-col items-center justify-center gap-3 py-16 text-center"
> >
<input <span class="i-lucide-mouse-pointer-click w-8 h-8 text-n-slate-9" />
v-model="newForm.label" <p class="mb-0 text-sm text-n-slate-10">
:placeholder=" {{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.SELECT_INBOX_HINT') }}
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER') </p>
"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/>
<textarea
v-model="newForm.content"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
"
rows="3"
class="w-full rounded border border-n-200 px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
/>
<!-- Variable chips -->
<div class="flex flex-wrap gap-1">
<button
v-for="v in VARIABLES"
:key="v"
class="text-xs bg-n-75 text-n-700 px-2 py-0.5 rounded hover:bg-n-100"
@click="insertVariable(v, 'new')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm">
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="newForm.timing_minutes"
type="number"
min="1"
class="w-16 rounded border border-n-200 px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="newForm.timing_direction"
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
</option>
<option value="after">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-600">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelNew"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isCreating"
@click="saveNew"
/>
</div>
</div> </div>
<!-- Add button -->
<button
v-if="!showNewForm"
class="flex items-center justify-center gap-2 rounded-lg border-2 border-dashed border-n-200 py-4 text-sm text-n-500 hover:border-w-400 hover:text-w-600 transition-colors"
@click="openNewForm"
>
<span class="i-lucide-plus w-4 h-4" />
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD') }}
</button>
</div> </div>
</template> </template>
</SettingsLayout> </SettingsLayout>

View File

@ -1,97 +1,77 @@
import CaptainNotificationTemplatesAPI from 'dashboard/api/captain/notificationTemplates'; import notificationTemplatesAPI from '../../api/captain/notificationTemplates';
import { throwErrorMessage } from 'dashboard/store/utils/api';
const state = { const state = {
templates: [], records: [],
uiFlags: { uiFlags: {
isFetching: false, isFetching: false,
isCreating: false, isSaving: false,
isUpdating: false,
isDeleting: false,
}, },
}; };
const getters = { const getters = {
getTemplates: $state => $state.templates, getRecords: $state => $state.records,
getUIFlags: $state => $state.uiFlags, getUIFlags: $state => $state.uiFlags,
}; };
const mutations = {
SET_TEMPLATES($state, templates) {
$state.templates = templates;
},
ADD_TEMPLATE($state, template) {
$state.templates.push(template);
},
UPDATE_TEMPLATE($state, updated) {
const index = $state.templates.findIndex(t => t.id === updated.id);
if (index !== -1) $state.templates.splice(index, 1, updated);
},
DELETE_TEMPLATE($state, id) {
$state.templates = $state.templates.filter(t => t.id !== id);
},
SET_UI_FLAG($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
},
};
const actions = { const actions = {
fetch: async ({ commit }, unitId) => { async fetch({ commit }, inboxId) {
commit('SET_UI_FLAG', { isFetching: true }); commit('SET_UI_FLAG', { isFetching: true });
try { try {
const { data } = const { data } = await notificationTemplatesAPI.getAll(inboxId);
await CaptainNotificationTemplatesAPI.getTemplates(unitId); commit('SET_RECORDS', data);
commit('SET_TEMPLATES', data);
} catch (error) {
throwErrorMessage(error);
} finally { } finally {
commit('SET_UI_FLAG', { isFetching: false }); commit('SET_UI_FLAG', { isFetching: false });
} }
}, },
create: async ({ commit }, { unitId, ...templateData }) => { async create({ commit }, { inboxId, payload }) {
commit('SET_UI_FLAG', { isCreating: true }); commit('SET_UI_FLAG', { isSaving: true });
try { try {
const { data } = await CaptainNotificationTemplatesAPI.createTemplate( const { data } = await notificationTemplatesAPI.create(inboxId, payload);
unitId, commit('ADD_RECORD', data);
templateData
);
commit('ADD_TEMPLATE', data);
return data; return data;
} catch (error) {
return throwErrorMessage(error);
} finally { } finally {
commit('SET_UI_FLAG', { isCreating: false }); commit('SET_UI_FLAG', { isSaving: false });
} }
}, },
update: async ({ commit }, { unitId, id, ...templateData }) => { async update({ commit }, { inboxId, id, payload }) {
commit('SET_UI_FLAG', { isUpdating: true }); commit('SET_UI_FLAG', { isSaving: true });
try { try {
const { data } = await CaptainNotificationTemplatesAPI.updateTemplate( const { data } = await notificationTemplatesAPI.update(
unitId, inboxId,
id, id,
templateData payload
); );
commit('UPDATE_TEMPLATE', data); commit('UPDATE_RECORD', data);
return data; return data;
} catch (error) {
return throwErrorMessage(error);
} finally { } finally {
commit('SET_UI_FLAG', { isUpdating: false }); commit('SET_UI_FLAG', { isSaving: false });
} }
}, },
delete: async ({ commit }, { unitId, id }) => { async delete({ commit }, { inboxId, id }) {
commit('SET_UI_FLAG', { isDeleting: true }); await notificationTemplatesAPI.delete(inboxId, id);
try { commit('DELETE_RECORD', id);
await CaptainNotificationTemplatesAPI.deleteTemplate(unitId, id); },
commit('DELETE_TEMPLATE', id); };
} catch (error) {
throwErrorMessage(error); const mutations = {
} finally { SET_RECORDS($state, records) {
commit('SET_UI_FLAG', { isDeleting: false }); $state.records = records;
} },
ADD_RECORD($state, record) {
$state.records.push(record);
},
UPDATE_RECORD($state, record) {
const idx = $state.records.findIndex(r => r.id === record.id);
if (idx !== -1) $state.records.splice(idx, 1, record);
},
DELETE_RECORD($state, id) {
$state.records = $state.records.filter(r => r.id !== id);
},
SET_UI_FLAG($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
}, },
}; };
@ -99,6 +79,6 @@ export default {
namespaced: true, namespaced: true,
state, state,
getters, getters,
mutations,
actions, actions,
mutations,
}; };

View File

@ -90,9 +90,7 @@ Rails.application.routes.draw do
post :label_suggestion post :label_suggestion
post :follow_up post :follow_up
end end
resources :units do resources :units
resources :notification_templates, only: [:index, :create, :update, :destroy]
end
namespace :reports do namespace :reports do
resource :operational, only: [:show], controller: 'reports/operational' resource :operational, only: [:show], controller: 'reports/operational'
resources :insights, only: [:index, :show] do resources :insights, only: [:index, :show] do
@ -242,6 +240,8 @@ Rails.application.routes.draw do
post :sync_templates, on: :member post :sync_templates, on: :member
get :health, on: :member get :health, on: :member
post :on_whatsapp, on: :member post :on_whatsapp, on: :member
resources :notification_templates, only: [:index, :create, :update, :destroy],
module: 'captain'
if ChatwootApp.enterprise? if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member get :token, on: :member

View File

@ -0,0 +1,26 @@
class ChangeNotificationTemplatesToInbox < ActiveRecord::Migration[7.1]
def up
remove_index :captain_notification_templates,
column: %i[captain_unit_id active],
name: 'idx_notif_templates_unit_active',
if_exists: true
remove_column :captain_notification_templates, :captain_unit_id, :bigint
add_column :captain_notification_templates, :inbox_id, :bigint
add_foreign_key :captain_notification_templates, :inboxes, column: :inbox_id
add_index :captain_notification_templates, %i[inbox_id active],
name: 'idx_notif_templates_inbox_active'
end
def down
remove_index :captain_notification_templates,
name: 'idx_notif_templates_inbox_active',
if_exists: true
remove_foreign_key :captain_notification_templates, :inboxes
remove_column :captain_notification_templates, :inbox_id, :bigint
add_column :captain_notification_templates, :captain_unit_id, :bigint
add_index :captain_notification_templates, %i[captain_unit_id active],
name: 'idx_notif_templates_unit_active'
end
end

View File

@ -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_03_01_120000) do ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) 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"
@ -535,7 +535,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
end end
create_table "captain_notification_templates", force: :cascade do |t| create_table "captain_notification_templates", force: :cascade do |t|
t.bigint "captain_unit_id", null: false
t.string "label", null: false t.string "label", null: false
t.text "content", null: false t.text "content", null: false
t.integer "timing_minutes", default: 10, null: false t.integer "timing_minutes", default: 10, null: false
@ -544,8 +543,8 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
t.integer "position", default: 0, null: false t.integer "position", default: 0, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["captain_unit_id", "active"], name: "idx_notif_templates_unit_active" t.bigint "inbox_id", null: false
t.index ["captain_unit_id"], name: "index_captain_notification_templates_on_captain_unit_id" t.index ["inbox_id", "active"], name: "idx_notif_templates_inbox_active"
end end
create_table "captain_pix_charges", force: :cascade do |t| create_table "captain_pix_charges", force: :cascade do |t|
@ -1978,7 +1977,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
add_foreign_key "captain_inbox_reminder_settings", "accounts" add_foreign_key "captain_inbox_reminder_settings", "accounts"
add_foreign_key "captain_inbox_reminder_settings", "inboxes" add_foreign_key "captain_inbox_reminder_settings", "inboxes"
add_foreign_key "captain_inboxes", "captain_units" add_foreign_key "captain_inboxes", "captain_units"
add_foreign_key "captain_notification_templates", "captain_units" add_foreign_key "captain_notification_templates", "inboxes"
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id" add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id" add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
add_foreign_key "captain_pricings", "accounts" add_foreign_key "captain_pricings", "accounts"

View File

@ -1,23 +1,27 @@
class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Accounts::BaseController class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Accounts::BaseController
before_action :current_account before_action :set_inbox
before_action -> { check_authorization(Captain::Assistant) }
before_action :set_unit
before_action :set_template, only: [:update, :destroy] before_action :set_template, only: [:update, :destroy]
def index def index
@templates = @unit.notification_templates.ordered templates = @inbox.captain_notification_templates.ordered
render json: @templates render json: templates
end end
def create def create
@template = @unit.notification_templates.new(template_params) template = @inbox.captain_notification_templates.new(template_params)
@template.save! if template.save
render json: @template, status: :created render json: template, status: :created
else
render json: { error: template.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end end
def update def update
@template.update!(template_params) if @template.update(template_params)
render json: @template render json: @template
else
render json: { error: @template.errors.full_messages.join(', ') }, status: :unprocessable_entity
end
end end
def destroy def destroy
@ -27,26 +31,15 @@ class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Acc
private private
def set_unit def set_inbox
@unit = Current.account.captain_units.find(params[:unit_id]) @inbox = current_account.inboxes.find(params[:inbox_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Unidade não encontrada' }, status: :not_found
end end
def set_template def set_template
@template = @unit.notification_templates.find(params[:id]) @template = @inbox.captain_notification_templates.find(params[:id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'Template não encontrado' }, status: :not_found
end end
def template_params def template_params
params.require(:notification_template).permit( params.require(:notification_template).permit(:label, :content, :timing_minutes, :timing_direction, :active, :position)
:label,
:content,
:timing_minutes,
:timing_direction,
:active,
:position
)
end end
end end

View File

@ -20,12 +20,13 @@ class Captain::Notifications::NotificationScannerJob < ApplicationJob
window_end = target_time + WINDOW_MINUTES.minutes window_end = target_time + WINDOW_MINUTES.minutes
Captain::Reservation Captain::Reservation
.where(captain_unit_id: template.captain_unit_id) .joins(:conversation)
.where(conversations: { inbox_id: template.inbox_id })
.where(status: Captain::Reservation.statuses.slice(:confirmed, :active).values) .where(status: Captain::Reservation.statuses.slice(:confirmed, :active).values)
.where(check_in_at: window_start..window_end) .where(check_in_at: window_start..window_end)
.where.not(conversation_id: nil) .where.not(conversation_id: nil)
.where( .where(
"NOT (metadata->'notified_templates' @> ?::jsonb)", "NOT (captain_reservations.metadata->'notified_templates' @> ?::jsonb)",
"[#{template.id}]" "[#{template.id}]"
) )
end end

View File

@ -11,21 +11,20 @@
# timing_minutes :integer default(10), not null # timing_minutes :integer default(10), not null
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# captain_unit_id :bigint not null # inbox_id :bigint not null
# #
# Indexes # Indexes
# #
# idx_notif_templates_unit_active (captain_unit_id,active) # idx_notif_templates_inbox_active (inbox_id,active)
# index_captain_notification_templates_on_captain_unit_id (captain_unit_id)
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (captain_unit_id => captain_units.id) # fk_rails_... (inbox_id => inboxes.id)
# #
class Captain::NotificationTemplate < ApplicationRecord class Captain::NotificationTemplate < ApplicationRecord
self.table_name = 'captain_notification_templates' self.table_name = 'captain_notification_templates'
belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', inverse_of: :notification_templates belongs_to :inbox, inverse_of: :captain_notification_templates
enum timing_direction: { before: 0, after: 1 } enum timing_direction: { before: 0, after: 1 }
@ -33,9 +32,9 @@ class Captain::NotificationTemplate < ApplicationRecord
validates :content, presence: true validates :content, presence: true
validates :timing_minutes, presence: true, numericality: { greater_than: 0 } validates :timing_minutes, presence: true, numericality: { greater_than: 0 }
validates :timing_direction, presence: true validates :timing_direction, presence: true
validates :captain_unit_id, presence: true validates :inbox_id, presence: true
scope :active, -> { where(active: true) } scope :active, -> { where(active: true) }
scope :ordered, -> { order(:position, :id) } scope :ordered, -> { order(:position, :id) }
scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) } scope :for_inbox, ->(inbox_id) { where(inbox_id: inbox_id) }
end end

View File

@ -54,8 +54,6 @@ class Captain::Unit < ApplicationRecord
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit, has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
dependent: :destroy dependent: :destroy
has_many :notification_templates, class_name: 'Captain::NotificationTemplate', foreign_key: :captain_unit_id,
inverse_of: :unit, dependent: :destroy
encrypts :inter_client_secret encrypts :inter_client_secret
encrypts :inter_account_number encrypts :inter_account_number