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:
parent
ce2904e57f
commit
84fff38d94
@ -1,30 +1,29 @@
|
||||
/* global axios */
|
||||
import ApiClient from '../ApiClient';
|
||||
|
||||
class CaptainNotificationTemplatesAPI extends ApiClient {
|
||||
class NotificationTemplatesAPI extends ApiClient {
|
||||
constructor() {
|
||||
super('captain/units', { accountScoped: true });
|
||||
super('inboxes', { accountScoped: true });
|
||||
}
|
||||
|
||||
getTemplates(unitId) {
|
||||
return axios.get(`${this.url}/${unitId}/notification_templates`);
|
||||
getAll(inboxId) {
|
||||
return this.get(`${inboxId}/notification_templates`);
|
||||
}
|
||||
|
||||
createTemplate(unitId, data) {
|
||||
return axios.post(`${this.url}/${unitId}/notification_templates`, {
|
||||
create(inboxId, data) {
|
||||
return this.post(`${inboxId}/notification_templates`, {
|
||||
notification_template: data,
|
||||
});
|
||||
}
|
||||
|
||||
updateTemplate(unitId, id, data) {
|
||||
return axios.patch(`${this.url}/${unitId}/notification_templates/${id}`, {
|
||||
update(inboxId, id, data) {
|
||||
return this.patch(`${inboxId}/notification_templates/${id}`, {
|
||||
notification_template: data,
|
||||
});
|
||||
}
|
||||
|
||||
deleteTemplate(unitId, id) {
|
||||
return axios.delete(`${this.url}/${unitId}/notification_templates/${id}`);
|
||||
delete(inboxId, id) {
|
||||
return this.delete(`${inboxId}/notification_templates/${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainNotificationTemplatesAPI();
|
||||
export default new NotificationTemplatesAPI();
|
||||
|
||||
@ -476,6 +476,13 @@
|
||||
"DELETE": {
|
||||
"SUCCESS": "Notification removed.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -477,6 +477,13 @@
|
||||
"DELETE": {
|
||||
"SUCCESS": "Notificação removida.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -79,7 +79,7 @@ export default {
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'units/:unitId/notifications',
|
||||
path: 'notifications',
|
||||
name: 'captain_settings_notifications',
|
||||
component: NotificationsIndex,
|
||||
meta: {
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ref, computed, watch, onMounted } from 'vue';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@ -9,17 +8,24 @@ import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const store = useStore();
|
||||
|
||||
const unitId = computed(() => route.params.unitId);
|
||||
|
||||
const templates = useMapGetter('captainNotificationTemplates/getTemplates');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const templates = useMapGetter('captainNotificationTemplates/getRecords');
|
||||
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
|
||||
|
||||
const selectedInboxId = ref(null);
|
||||
const editingId = ref(null);
|
||||
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 = () => ({
|
||||
label: '',
|
||||
content: '',
|
||||
@ -39,12 +45,20 @@ const VARIABLES = [
|
||||
'{{unit_name}}',
|
||||
];
|
||||
|
||||
// ─── Carregamento ─────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
if (unitId.value) {
|
||||
await store.dispatch('captainNotificationTemplates/fetch', unitId.value);
|
||||
await store.dispatch('inboxes/get');
|
||||
});
|
||||
|
||||
watch(selectedInboxId, async id => {
|
||||
if (id) {
|
||||
await store.dispatch('captainNotificationTemplates/fetch', id);
|
||||
showNewForm.value = false;
|
||||
editingId.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Novo template ────────────────────────────────────────────────────────────
|
||||
const openNewForm = () => {
|
||||
newForm.value = emptyForm();
|
||||
showNewForm.value = true;
|
||||
@ -59,8 +73,8 @@ const saveNew = async () => {
|
||||
if (!newForm.value.label || !newForm.value.content) return;
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/create', {
|
||||
unitId: unitId.value,
|
||||
...newForm.value,
|
||||
inboxId: selectedInboxId.value,
|
||||
payload: newForm.value,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
|
||||
showNewForm.value = false;
|
||||
@ -70,6 +84,7 @@ const saveNew = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Edição ───────────────────────────────────────────────────────────────────
|
||||
const startEdit = template => {
|
||||
editingId.value = template.id;
|
||||
editForm.value = { ...template };
|
||||
@ -83,9 +98,9 @@ const cancelEdit = () => {
|
||||
const saveEdit = async () => {
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/update', {
|
||||
unitId: unitId.value,
|
||||
inboxId: selectedInboxId.value,
|
||||
id: editingId.value,
|
||||
...editForm.value,
|
||||
payload: editForm.value,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
|
||||
editingId.value = null;
|
||||
@ -94,22 +109,24 @@ const saveEdit = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Toggle ativo ─────────────────────────────────────────────────────────────
|
||||
const toggleActive = async template => {
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/update', {
|
||||
unitId: unitId.value,
|
||||
inboxId: selectedInboxId.value,
|
||||
id: template.id,
|
||||
active: !template.active,
|
||||
payload: { active: !template.active },
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Exclusão ─────────────────────────────────────────────────────────────────
|
||||
const deleteTemplate = async template => {
|
||||
try {
|
||||
await store.dispatch('captainNotificationTemplates/delete', {
|
||||
unitId: unitId.value,
|
||||
inboxId: selectedInboxId.value,
|
||||
id: template.id,
|
||||
});
|
||||
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
|
||||
@ -118,6 +135,7 @@ const deleteTemplate = async template => {
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Variáveis ────────────────────────────────────────────────────────────────
|
||||
const insertVariable = (variable, target) => {
|
||||
if (target === 'new') {
|
||||
newForm.value.content += variable;
|
||||
@ -144,107 +162,221 @@ const timingDisplay = template =>
|
||||
<BaseSettingsHeader
|
||||
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
|
||||
: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 #body>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Template list -->
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="rounded-lg border border-n-75 bg-white p-4"
|
||||
>
|
||||
<!-- View mode -->
|
||||
<div class="flex flex-col gap-6 px-6 pb-8">
|
||||
<!-- Seletor de inbox -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.INBOX_LABEL') }}
|
||||
</label>
|
||||
<div v-if="!hasInboxes" class="text-sm text-n-slate-10">
|
||||
{{ 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: só aparece após selecionar inbox -->
|
||||
<div v-if="selectedInboxId" class="flex flex-col gap-3">
|
||||
<!-- Template list -->
|
||||
<div
|
||||
v-if="editingId !== template.id"
|
||||
class="flex items-start justify-between gap-3"
|
||||
v-for="template in templates"
|
||||
: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">
|
||||
<span class="text-sm font-semibold text-n-900">{{
|
||||
template.label
|
||||
}}</span>
|
||||
<span class="text-sm text-n-600 whitespace-pre-line">{{
|
||||
template.content
|
||||
}}</span>
|
||||
<span class="text-xs text-n-500 mt-1">
|
||||
{{ timingDisplay(template) }}
|
||||
</span>
|
||||
<!-- View mode -->
|
||||
<div
|
||||
v-if="editingId !== template.id"
|
||||
class="flex items-start justify-between gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span class="text-sm font-semibold text-n-slate-12">{{
|
||||
template.label
|
||||
}}</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 class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
class="text-xs px-2 py-1 rounded"
|
||||
:class="
|
||||
template.active ? 'bg-g-100 text-g-700' : 'bg-n-75 text-n-500'
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<input
|
||||
v-model="editForm.label"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
|
||||
"
|
||||
@click="toggleActive(template)"
|
||||
>
|
||||
{{
|
||||
template.active
|
||||
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
|
||||
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
class="text-n-500 hover:text-n-700"
|
||||
@click="startEdit(template)"
|
||||
>
|
||||
<span class="i-lucide-pencil w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="text-r-500 hover:text-r-700"
|
||||
@click="deleteTemplate(template)"
|
||||
>
|
||||
<span class="i-lucide-trash-2 w-4 h-4" />
|
||||
</button>
|
||||
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
|
||||
/>
|
||||
<textarea
|
||||
v-model="editForm.content"
|
||||
:placeholder="
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
|
||||
"
|
||||
rows="3"
|
||||
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 -->
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
v-for="v in VARIABLES"
|
||||
:key="v"
|
||||
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')"
|
||||
>
|
||||
{{ v }}
|
||||
</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>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<!-- New form -->
|
||||
<div
|
||||
v-if="showNewForm"
|
||||
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
|
||||
>
|
||||
<input
|
||||
v-model="editForm.label"
|
||||
v-model="newForm.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
|
||||
v-model="editForm.content"
|
||||
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"
|
||||
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 -->
|
||||
<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, 'edit')"
|
||||
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, 'new')"
|
||||
>
|
||||
{{ v }}
|
||||
</button>
|
||||
</div>
|
||||
<!-- Timing row -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-n-600">{{
|
||||
<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"
|
||||
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"
|
||||
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')
|
||||
}}</span>
|
||||
<select
|
||||
v-model="editForm.timing_direction"
|
||||
class="rounded border border-n-200 px-2 py-1 text-sm focus:outline-none focus:border-w-500"
|
||||
v-model="newForm.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') }}
|
||||
@ -253,7 +385,7 @@ const timingDisplay = template =>
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="text-n-600">{{
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
|
||||
}}</span>
|
||||
</div>
|
||||
@ -261,100 +393,52 @@ const timingDisplay = template =>
|
||||
<Button
|
||||
variant="clear"
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
|
||||
@click="cancelEdit"
|
||||
@click="cancelNew"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
|
||||
:is-loading="uiFlags.isUpdating"
|
||||
@click="saveEdit"
|
||||
:is-loading="uiFlags.isSaving"
|
||||
@click="saveNew"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<!-- New form -->
|
||||
<!-- Estado inicial: nenhuma inbox selecionada -->
|
||||
<div
|
||||
v-if="showNewForm"
|
||||
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
|
||||
v-else-if="hasInboxes"
|
||||
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
|
||||
>
|
||||
<input
|
||||
v-model="newForm.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"
|
||||
/>
|
||||
<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>
|
||||
<span class="i-lucide-mouse-pointer-click w-8 h-8 text-n-slate-9" />
|
||||
<p class="mb-0 text-sm text-n-slate-10">
|
||||
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.SELECT_INBOX_HINT') }}
|
||||
</p>
|
||||
</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>
|
||||
</template>
|
||||
</SettingsLayout>
|
||||
|
||||
@ -1,97 +1,77 @@
|
||||
import CaptainNotificationTemplatesAPI from 'dashboard/api/captain/notificationTemplates';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import notificationTemplatesAPI from '../../api/captain/notificationTemplates';
|
||||
|
||||
const state = {
|
||||
templates: [],
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isFetching: false,
|
||||
isCreating: false,
|
||||
isUpdating: false,
|
||||
isDeleting: false,
|
||||
isSaving: false,
|
||||
},
|
||||
};
|
||||
|
||||
const getters = {
|
||||
getTemplates: $state => $state.templates,
|
||||
getRecords: $state => $state.records,
|
||||
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 = {
|
||||
fetch: async ({ commit }, unitId) => {
|
||||
async fetch({ commit }, inboxId) {
|
||||
commit('SET_UI_FLAG', { isFetching: true });
|
||||
try {
|
||||
const { data } =
|
||||
await CaptainNotificationTemplatesAPI.getTemplates(unitId);
|
||||
commit('SET_TEMPLATES', data);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
const { data } = await notificationTemplatesAPI.getAll(inboxId);
|
||||
commit('SET_RECORDS', data);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isFetching: false });
|
||||
}
|
||||
},
|
||||
|
||||
create: async ({ commit }, { unitId, ...templateData }) => {
|
||||
commit('SET_UI_FLAG', { isCreating: true });
|
||||
async create({ commit }, { inboxId, payload }) {
|
||||
commit('SET_UI_FLAG', { isSaving: true });
|
||||
try {
|
||||
const { data } = await CaptainNotificationTemplatesAPI.createTemplate(
|
||||
unitId,
|
||||
templateData
|
||||
);
|
||||
commit('ADD_TEMPLATE', data);
|
||||
const { data } = await notificationTemplatesAPI.create(inboxId, payload);
|
||||
commit('ADD_RECORD', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isCreating: false });
|
||||
commit('SET_UI_FLAG', { isSaving: false });
|
||||
}
|
||||
},
|
||||
|
||||
update: async ({ commit }, { unitId, id, ...templateData }) => {
|
||||
commit('SET_UI_FLAG', { isUpdating: true });
|
||||
async update({ commit }, { inboxId, id, payload }) {
|
||||
commit('SET_UI_FLAG', { isSaving: true });
|
||||
try {
|
||||
const { data } = await CaptainNotificationTemplatesAPI.updateTemplate(
|
||||
unitId,
|
||||
const { data } = await notificationTemplatesAPI.update(
|
||||
inboxId,
|
||||
id,
|
||||
templateData
|
||||
payload
|
||||
);
|
||||
commit('UPDATE_TEMPLATE', data);
|
||||
commit('UPDATE_RECORD', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isUpdating: false });
|
||||
commit('SET_UI_FLAG', { isSaving: false });
|
||||
}
|
||||
},
|
||||
|
||||
delete: async ({ commit }, { unitId, id }) => {
|
||||
commit('SET_UI_FLAG', { isDeleting: true });
|
||||
try {
|
||||
await CaptainNotificationTemplatesAPI.deleteTemplate(unitId, id);
|
||||
commit('DELETE_TEMPLATE', id);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
commit('SET_UI_FLAG', { isDeleting: false });
|
||||
}
|
||||
async delete({ commit }, { inboxId, id }) {
|
||||
await notificationTemplatesAPI.delete(inboxId, id);
|
||||
commit('DELETE_RECORD', id);
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
SET_RECORDS($state, records) {
|
||||
$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,
|
||||
state,
|
||||
getters,
|
||||
mutations,
|
||||
actions,
|
||||
mutations,
|
||||
};
|
||||
|
||||
@ -90,9 +90,7 @@ Rails.application.routes.draw do
|
||||
post :label_suggestion
|
||||
post :follow_up
|
||||
end
|
||||
resources :units do
|
||||
resources :notification_templates, only: [:index, :create, :update, :destroy]
|
||||
end
|
||||
resources :units
|
||||
namespace :reports do
|
||||
resource :operational, only: [:show], controller: 'reports/operational'
|
||||
resources :insights, only: [:index, :show] do
|
||||
@ -242,6 +240,8 @@ Rails.application.routes.draw do
|
||||
post :sync_templates, on: :member
|
||||
get :health, on: :member
|
||||
post :on_whatsapp, on: :member
|
||||
resources :notification_templates, only: [:index, :create, :update, :destroy],
|
||||
module: 'captain'
|
||||
if ChatwootApp.enterprise?
|
||||
resource :conference, only: %i[create destroy], controller: 'conference' do
|
||||
get :token, on: :member
|
||||
|
||||
@ -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
|
||||
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_01_200000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@ -535,7 +535,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
|
||||
end
|
||||
|
||||
create_table "captain_notification_templates", force: :cascade do |t|
|
||||
t.bigint "captain_unit_id", null: false
|
||||
t.string "label", null: false
|
||||
t.text "content", 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.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["captain_unit_id", "active"], name: "idx_notif_templates_unit_active"
|
||||
t.index ["captain_unit_id"], name: "index_captain_notification_templates_on_captain_unit_id"
|
||||
t.bigint "inbox_id", null: false
|
||||
t.index ["inbox_id", "active"], name: "idx_notif_templates_inbox_active"
|
||||
end
|
||||
|
||||
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", "inboxes"
|
||||
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_units", column: "unit_id"
|
||||
add_foreign_key "captain_pricings", "accounts"
|
||||
|
||||
@ -1,23 +1,27 @@
|
||||
class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Accounts::BaseController
|
||||
before_action :current_account
|
||||
before_action -> { check_authorization(Captain::Assistant) }
|
||||
before_action :set_unit
|
||||
before_action :set_inbox
|
||||
before_action :set_template, only: [:update, :destroy]
|
||||
|
||||
def index
|
||||
@templates = @unit.notification_templates.ordered
|
||||
render json: @templates
|
||||
templates = @inbox.captain_notification_templates.ordered
|
||||
render json: templates
|
||||
end
|
||||
|
||||
def create
|
||||
@template = @unit.notification_templates.new(template_params)
|
||||
@template.save!
|
||||
render json: @template, status: :created
|
||||
template = @inbox.captain_notification_templates.new(template_params)
|
||||
if template.save
|
||||
render json: template, status: :created
|
||||
else
|
||||
render json: { error: template.errors.full_messages.join(', ') }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
@template.update!(template_params)
|
||||
render json: @template
|
||||
if @template.update(template_params)
|
||||
render json: @template
|
||||
else
|
||||
render json: { error: @template.errors.full_messages.join(', ') }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@ -27,26 +31,15 @@ class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Acc
|
||||
|
||||
private
|
||||
|
||||
def set_unit
|
||||
@unit = Current.account.captain_units.find(params[:unit_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Unidade não encontrada' }, status: :not_found
|
||||
def set_inbox
|
||||
@inbox = current_account.inboxes.find(params[:inbox_id])
|
||||
end
|
||||
|
||||
def set_template
|
||||
@template = @unit.notification_templates.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: 'Template não encontrado' }, status: :not_found
|
||||
@template = @inbox.captain_notification_templates.find(params[:id])
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:notification_template).permit(
|
||||
:label,
|
||||
:content,
|
||||
:timing_minutes,
|
||||
:timing_direction,
|
||||
:active,
|
||||
:position
|
||||
)
|
||||
params.require(:notification_template).permit(:label, :content, :timing_minutes, :timing_direction, :active, :position)
|
||||
end
|
||||
end
|
||||
|
||||
@ -20,12 +20,13 @@ class Captain::Notifications::NotificationScannerJob < ApplicationJob
|
||||
window_end = target_time + WINDOW_MINUTES.minutes
|
||||
|
||||
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(check_in_at: window_start..window_end)
|
||||
.where.not(conversation_id: nil)
|
||||
.where(
|
||||
"NOT (metadata->'notified_templates' @> ?::jsonb)",
|
||||
"NOT (captain_reservations.metadata->'notified_templates' @> ?::jsonb)",
|
||||
"[#{template.id}]"
|
||||
)
|
||||
end
|
||||
|
||||
@ -11,21 +11,20 @@
|
||||
# timing_minutes :integer default(10), not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# captain_unit_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_notif_templates_unit_active (captain_unit_id,active)
|
||||
# index_captain_notification_templates_on_captain_unit_id (captain_unit_id)
|
||||
# idx_notif_templates_inbox_active (inbox_id,active)
|
||||
#
|
||||
# Foreign Keys
|
||||
#
|
||||
# fk_rails_... (captain_unit_id => captain_units.id)
|
||||
# fk_rails_... (inbox_id => inboxes.id)
|
||||
#
|
||||
class Captain::NotificationTemplate < ApplicationRecord
|
||||
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 }
|
||||
|
||||
@ -33,9 +32,9 @@ class Captain::NotificationTemplate < ApplicationRecord
|
||||
validates :content, presence: true
|
||||
validates :timing_minutes, presence: true, numericality: { greater_than: 0 }
|
||||
validates :timing_direction, presence: true
|
||||
validates :captain_unit_id, presence: true
|
||||
validates :inbox_id, presence: true
|
||||
|
||||
scope :active, -> { where(active: true) }
|
||||
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
|
||||
|
||||
@ -54,8 +54,6 @@ class Captain::Unit < ApplicationRecord
|
||||
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,
|
||||
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_account_number
|
||||
|
||||
Loading…
Reference in New Issue
Block a user