feat(lifecycle): settings tab with guards form and concierge per unit

Replaces stub Settings.vue with full implementation: anti-spam guard
form (quiet hours, interval, pause-on-reply, opt-out label) and a
collapsible ConciergeUnitCard per unit (inbox selector, persona name,
knowledge base, key-value variables). Adds CONCIERGE_CONFIGURED /
CONCIERGE_NOT_CONFIGURED i18n keys to en + pt_BR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-04-15 11:05:40 -03:00
parent ae4647d1c2
commit 94fdb5c318
4 changed files with 282 additions and 5 deletions

View File

@ -658,6 +658,8 @@
"CONCIERGE_VARIABLE_KEY": "Key",
"CONCIERGE_VARIABLE_VALUE": "Value",
"CONCIERGE_ADD_VARIABLE": "Add variable",
"CONCIERGE_CONFIGURED": "Configured",
"CONCIERGE_NOT_CONFIGURED": "Not configured",
"SAVE": "Save changes",
"TOAST": {
"SAVED": "Settings saved.",

View File

@ -660,6 +660,8 @@
"CONCIERGE_VARIABLE_KEY": "Chave",
"CONCIERGE_VARIABLE_VALUE": "Valor",
"CONCIERGE_ADD_VARIABLE": "Adicionar variável",
"CONCIERGE_CONFIGURED": "Configurado",
"CONCIERGE_NOT_CONFIGURED": "Não configurado",
"SAVE": "Salvar alterações",
"TOAST": {
"SAVED": "Configurações salvas.",

View File

@ -1,14 +1,143 @@
<script setup>
import { onMounted, ref, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ConciergeUnitCard from './components/ConciergeUnitCard.vue';
const store = useStore();
const { t } = useI18n();
const config = useMapGetter('captainLifecycleConfig/getConfig');
const uiFlags = useMapGetter('captainLifecycleConfig/getUIFlags');
const units = useMapGetter('captainUnits/getUnits');
const labels = useMapGetter('labels/getLabels');
const form = ref({});
const syncForm = () => {
form.value = { ...config.value };
};
watch(config, syncForm, { immediate: true });
onMounted(() => {
store.dispatch('captainLifecycleConfig/fetch');
store.dispatch('captainUnits/get');
store.dispatch('labels/get');
});
const save = async () => {
try {
await store.dispatch('captainLifecycleConfig/update', {
quiet_hours_enabled: form.value.quiet_hours_enabled,
quiet_hours_from: form.value.quiet_hours_from,
quiet_hours_to: form.value.quiet_hours_to,
min_interval_minutes: Number(form.value.min_interval_minutes),
pause_on_customer_reply: form.value.pause_on_customer_reply,
pause_on_customer_reply_within_minutes: Number(
form.value.pause_on_customer_reply_within_minutes
),
opt_out_label_id: form.value.opt_out_label_id || null,
});
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.SAVED'));
} catch (e) {
useAlert(e.message || 'Error');
}
};
</script>
<template>
<div class="p-6">
<h2 class="text-lg font-semibold">
{{ t('CAPTAIN_LIFECYCLE.TABS.SETTINGS') }}
</h2>
<!-- Implementation in Task 14 -->
<div class="p-6 space-y-8">
<section>
<h3 class="text-base font-semibold mb-3">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.GUARDS_TITLE') }}
</h3>
<div v-if="uiFlags.fetching">
<Spinner />
</div>
<div v-else class="space-y-3 max-w-xl">
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="form.quiet_hours_enabled" />
<span class="text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_ENABLED') }}
</span>
</label>
<div v-if="form.quiet_hours_enabled" class="flex gap-3">
<label class="flex-1 text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_FROM') }}
<input
v-model="form.quiet_hours_from"
type="time"
class="w-full border rounded px-2 py-1"
/>
</label>
<label class="flex-1 text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.QUIET_HOURS_TO') }}
<input
v-model="form.quiet_hours_to"
type="time"
class="w-full border rounded px-2 py-1"
/>
</label>
</div>
<Input
v-model="form.min_interval_minutes"
type="number"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL')"
:message="t('CAPTAIN_LIFECYCLE.SETTINGS.MIN_INTERVAL_HELP')"
/>
<label class="flex items-center gap-2 cursor-pointer">
<Checkbox v-model="form.pause_on_customer_reply" />
<span class="text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY') }}
</span>
</label>
<Input
v-if="form.pause_on_customer_reply"
v-model="form.pause_on_customer_reply_within_minutes"
type="number"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.PAUSE_ON_REPLY_WINDOW')"
/>
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.OPT_OUT_LABEL') }}
<select
v-model="form.opt_out_label_id"
class="w-full border rounded px-2 py-1"
>
<option :value="null"></option>
<option v-for="label in labels" :key="label.id" :value="label.id">
{{ label.title }}
</option>
</select>
</label>
<p class="text-xs text-n-slate-11">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.MAX_PER_RESERVATION_INFO') }}
</p>
<Button :disabled="uiFlags.updating" @click="save">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
</Button>
</div>
</section>
<section>
<h3 class="text-base font-semibold mb-3">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_TITLE') }}
</h3>
<div class="space-y-3">
<ConciergeUnitCard v-for="u in units" :key="u.id" :unit="u" />
</div>
</section>
</div>
</template>

View File

@ -0,0 +1,144 @@
<script setup>
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import CaptainUnitsAPI from 'dashboard/api/captain/units';
const props = defineProps({
unit: { type: Object, required: true },
});
const { t } = useI18n();
const inboxes = useMapGetter('inboxes/getWhatsAppInboxes');
const expanded = ref(false);
const conciergeInboxId = ref(props.unit.concierge_inbox_id || null);
const personaName = ref(props.unit.concierge_config?.persona_name || 'Sofia');
const knowledge = ref(props.unit.concierge_config?.knowledge || '');
const variables = ref(
Object.entries(props.unit.concierge_config?.variables || {}).map(
([k, v]) => ({
k,
v,
})
)
);
const addVariable = () => variables.value.push({ k: '', v: '' });
const removeVariable = i => variables.value.splice(i, 1);
const save = async () => {
try {
const varsObj = Object.fromEntries(
variables.value.filter(x => x.k).map(x => [x.k, x.v])
);
await CaptainUnitsAPI.updateConcierge(props.unit.id, {
concierge_inbox_id: conciergeInboxId.value,
concierge_config: {
persona_name: personaName.value,
knowledge: knowledge.value,
variables: varsObj,
},
});
useAlert(t('CAPTAIN_LIFECYCLE.SETTINGS.TOAST.CONCIERGE_SAVED'));
} catch (e) {
useAlert(e.message || 'Error saving concierge');
}
};
</script>
<template>
<div class="border border-n-slate-4 rounded-lg p-4">
<div
class="flex justify-between items-center cursor-pointer"
@click="expanded = !expanded"
>
<div>
<div class="font-medium">{{ unit.name }}</div>
<div class="text-xs text-n-slate-11">
{{
unit.concierge_inbox_id
? t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_CONFIGURED')
: t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_NOT_CONFIGURED')
}}
</div>
</div>
<span>{{ expanded ? '▾' : '▸' }}</span>
</div>
<div v-if="expanded" class="mt-4 space-y-3">
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_INBOX') }}
<select
v-model="conciergeInboxId"
class="w-full border rounded px-2 py-1"
>
<option :value="null"></option>
<option v-for="ib in inboxes" :key="ib.id" :value="ib.id">
{{ ib.name }}
</option>
</select>
</label>
<Input
v-model="personaName"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_PERSONA')"
/>
<label class="block text-sm">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_KNOWLEDGE') }}
<textarea
v-model="knowledge"
rows="8"
class="w-full border rounded p-2 font-mono text-xs"
/>
</label>
<div>
<div class="text-sm font-medium mb-2">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLES') }}
</div>
<div
v-for="(variable, i) in variables"
:key="i"
class="flex gap-2 mb-2"
>
<input
v-model="variable.k"
:placeholder="
t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_KEY')
"
class="border rounded px-2 py-1 w-1/3"
/>
<input
v-model="variable.v"
:placeholder="
t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_VARIABLE_VALUE')
"
class="border rounded px-2 py-1 flex-1"
/>
<Button
variant="ghost"
size="sm"
icon="i-lucide-x"
@click="removeVariable(i)"
/>
</div>
<Button
variant="outline"
size="sm"
icon="i-lucide-plus"
:label="t('CAPTAIN_LIFECYCLE.SETTINGS.CONCIERGE_ADD_VARIABLE')"
@click="addVariable"
/>
</div>
<Button @click="save">
{{ t('CAPTAIN_LIFECYCLE.SETTINGS.SAVE') }}
</Button>
</div>
</div>
</template>