386 lines
16 KiB
Vue
386 lines
16 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/features/agenda/components/DeterminedCommitmentDialog.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, reactive, watch } from 'vue';
|
|
|
|
import Textarea from 'primevue/textarea';
|
|
import Dropdown from 'primevue/dropdown';
|
|
import ColorPicker from 'primevue/colorpicker';
|
|
import ToggleSwitch from 'primevue/toggleswitch';
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Boolean, default: false },
|
|
mode: { type: String, default: 'create' }, // 'create' | 'edit'
|
|
saving: { type: Boolean, default: false },
|
|
commitment: { type: Object, default: null } // quando edit
|
|
});
|
|
|
|
const emit = defineEmits(['update:modelValue', 'save', 'delete']);
|
|
|
|
const fieldTypeOptions = [
|
|
{ label: 'Texto', value: 'text' },
|
|
{ label: 'Texto longo', value: 'textarea' }
|
|
];
|
|
|
|
const presetColors = [
|
|
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
|
|
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
|
|
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
|
|
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
|
|
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
|
|
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
|
|
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
|
|
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
|
|
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
|
|
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
|
|
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
|
|
{ bg: '292524', text: '#ffffff', name: 'Escuro' }
|
|
];
|
|
|
|
// bg_colors dos presets (sem #) para comparação
|
|
const presetBgValues = presetColors.map((p) => p.bg);
|
|
|
|
// Verdadeiro quando a cor atual não bate com nenhum preset
|
|
const isCustomColor = computed(() => {
|
|
if (!form.bg_color) return false;
|
|
const clean = String(form.bg_color).replace('#', '').toLowerCase();
|
|
return !presetBgValues.includes(clean);
|
|
});
|
|
|
|
function applyPreset(p) {
|
|
if (props.saving) return;
|
|
form.bg_color = p.bg;
|
|
form.text_color = p.text;
|
|
}
|
|
|
|
const visible = computed({
|
|
get: () => props.modelValue,
|
|
set: (v) => emit('update:modelValue', v)
|
|
});
|
|
|
|
const form = reactive({
|
|
id: null,
|
|
name: '',
|
|
description: '',
|
|
native: false,
|
|
locked: false,
|
|
active: true,
|
|
bg_color: '6366f1',
|
|
text_color: '#ffffff',
|
|
fields: []
|
|
});
|
|
|
|
const previewBgColor = computed(() => {
|
|
if (!form.bg_color) return '#6366f1';
|
|
return form.bg_color.startsWith('#') ? form.bg_color : `#${form.bg_color}`;
|
|
});
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(open) => {
|
|
if (!open) return;
|
|
hydrate();
|
|
}
|
|
);
|
|
|
|
watch(
|
|
() => props.commitment,
|
|
() => {
|
|
if (!props.modelValue) return;
|
|
hydrate();
|
|
}
|
|
);
|
|
|
|
function hydrate() {
|
|
const c = props.commitment;
|
|
if (props.mode === 'edit' && c) {
|
|
form.id = c.id;
|
|
form.name = c.name || '';
|
|
form.description = c.description || '';
|
|
form.native = !!c.native;
|
|
form.locked = !!c.locked;
|
|
form.active = !!c.active;
|
|
form.bg_color = c.bg_color || '6366f1';
|
|
form.text_color = c.text_color || '#ffffff';
|
|
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : [];
|
|
} else {
|
|
form.id = null;
|
|
form.name = '';
|
|
form.description = '';
|
|
form.native = false;
|
|
form.locked = false;
|
|
form.active = true;
|
|
form.bg_color = '6366f1';
|
|
form.text_color = '#ffffff';
|
|
form.fields = [];
|
|
}
|
|
}
|
|
|
|
const isActiveLocked = computed(() => !!form.locked);
|
|
const isEditLocked = computed(() => false);
|
|
const isFieldsLocked = computed(() => false);
|
|
const canDelete = computed(() => !form.native);
|
|
|
|
const canSubmit = computed(() => {
|
|
if (props.saving) return false;
|
|
if (!String(form.name || '').trim()) return false;
|
|
return true;
|
|
});
|
|
|
|
function close() {
|
|
if (props.saving) return;
|
|
visible.value = false;
|
|
}
|
|
|
|
function submit() {
|
|
if (!canSubmit.value) return;
|
|
|
|
const payload = {
|
|
id: form.id,
|
|
name: String(form.name || '').trim(),
|
|
description: String(form.description || '').trim(),
|
|
active: form.locked ? true : !!form.active,
|
|
bg_color: form.bg_color || null,
|
|
text_color: form.text_color || null,
|
|
fields: (form.fields || []).map((f) => ({
|
|
key: f.key,
|
|
label: String(f.label || '').trim() || 'Campo',
|
|
type: f.type === 'textarea' ? 'textarea' : 'text',
|
|
required: !!f.required
|
|
}))
|
|
};
|
|
|
|
emit('save', payload);
|
|
}
|
|
|
|
function emitDelete() {
|
|
if (props.saving) return;
|
|
emit('delete', { id: form.id });
|
|
visible.value = false;
|
|
}
|
|
|
|
function addField() {
|
|
const base = `campo-${form.fields.length + 1}`;
|
|
form.fields.push({
|
|
key: makeKey(base),
|
|
label: 'Observação',
|
|
type: 'textarea',
|
|
required: false
|
|
});
|
|
}
|
|
|
|
function removeField(idx) {
|
|
form.fields.splice(idx, 1);
|
|
}
|
|
|
|
function syncKey(field) {
|
|
const next = makeKey(field.label);
|
|
field.key = next;
|
|
}
|
|
|
|
function makeKey(label) {
|
|
const k =
|
|
String(label || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.replace(/[^a-z0-9]+/g, '_')
|
|
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`;
|
|
return k;
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<Dialog
|
|
v-model:visible="visible"
|
|
modal
|
|
:draggable="false"
|
|
:closable="!saving"
|
|
:dismissableMask="!saving"
|
|
class="dc-dialog w-[96vw] max-w-2xl"
|
|
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' }, footer: { class: 'pt-0' } }"
|
|
pt:mask:class="backdrop-blur-xs"
|
|
>
|
|
<template #header>
|
|
<div class="flex w-full items-center justify-between gap-3 px-1">
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<!-- Dot de cor -->
|
|
<span class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200" :style="{ backgroundColor: previewBgColor }" />
|
|
<div class="min-w-0">
|
|
<div class="text-base font-semibold truncate">
|
|
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
|
|
</div>
|
|
<div class="text-xs opacity-50">
|
|
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2 shrink-0">
|
|
<Button v-if="mode === 'edit' && canDelete" icon="pi pi-trash" severity="danger" text rounded :disabled="saving" v-tooltip.top="'Excluir'" @click="emitDelete" />
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Banner de preview -->
|
|
<div class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]" :style="{ backgroundColor: previewBgColor }">
|
|
<span class="text-base font-bold tracking-[-0.02em] px-[1.1rem] py-[0.35rem] bg-black/15 rounded-full backdrop-blur-sm transition-colors duration-200" :style="{ color: form.text_color || '#ffffff' }">
|
|
{{ form.name || 'Nome do compromisso' }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Corpo -->
|
|
<div class="flex flex-col gap-4 mt-4">
|
|
<!-- Nome + Ativo -->
|
|
<div class="flex items-center gap-3">
|
|
<div class="flex-1">
|
|
<FloatLabel variant="on">
|
|
<IconField>
|
|
<InputIcon class="pi pi-tag" />
|
|
<InputText id="cr-nome" v-model="form.name" class="w-full" variant="filled" :disabled="saving || isEditLocked" @keydown.enter.prevent="submit" />
|
|
</IconField>
|
|
<label for="cr-nome">Nome *</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<!-- Toggle Ativo -->
|
|
<div class="shrink-0 flex items-center gap-2">
|
|
<span class="text-sm font-medium">Ativo</span>
|
|
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Descrição -->
|
|
<FloatLabel variant="on">
|
|
<Textarea id="cr-descricao" v-model="form.description" autoResize rows="2" class="w-full" variant="filled" :disabled="saving || isEditLocked" />
|
|
<label for="cr-descricao">Descrição</label>
|
|
</FloatLabel>
|
|
|
|
<!-- Seção Cor -->
|
|
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
|
|
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45 mb-3">Cor</div>
|
|
|
|
<!-- Paleta predefinida -->
|
|
<div class="flex flex-wrap gap-[0.45rem]">
|
|
<button
|
|
v-for="p in presetColors"
|
|
:key="p.bg"
|
|
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)] disabled:cursor-not-allowed"
|
|
:class="form.bg_color === p.bg ? 'shadow-[0_0_0_2px_var(--text-color)] border-2 border-[var(--surface-0,#fff)]' : 'border-0'"
|
|
:style="{ backgroundColor: `#${p.bg}` }"
|
|
:title="p.name"
|
|
:disabled="saving || isEditLocked"
|
|
@click="applyPreset(p)"
|
|
>
|
|
<i v-if="form.bg_color === p.bg" class="pi pi-check !text-[13px] text-white font-black p-1" />
|
|
</button>
|
|
|
|
<!-- Custom ColorPicker -->
|
|
<div
|
|
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer overflow-hidden relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)]"
|
|
:class="isCustomColor ? 'shadow-[0_0_0_2px_var(--text-color)]' : ''"
|
|
style="background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red)"
|
|
title="Cor personalizada"
|
|
>
|
|
<i v-if="isCustomColor" class="pi pi-check !text-[13px] text-white font-black absolute z-10 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]" />
|
|
<ColorPicker
|
|
v-model="form.bg_color"
|
|
format="hex"
|
|
:disabled="saving || isEditLocked"
|
|
class="absolute inset-0 [&_.p-colorpicker-preview]:w-full [&_.p-colorpicker-preview]:h-full [&_.p-colorpicker-preview]:border-0 [&_.p-colorpicker-preview]:rounded-full [&_.p-colorpicker-preview]:opacity-0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Texto -->
|
|
<div class="flex items-center gap-3 mt-2">
|
|
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
|
|
<div class="flex gap-1">
|
|
<button
|
|
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
|
|
:class="
|
|
form.text_color === '#ffffff'
|
|
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
|
|
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
|
|
"
|
|
:disabled="saving || isEditLocked"
|
|
@click="form.text_color = '#ffffff'"
|
|
>
|
|
<span class="w-2.5 h-2.5 rounded-full inline-block border border-[#ccc]" style="background: #ffffff" />
|
|
Branco
|
|
</button>
|
|
<button
|
|
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
|
|
:class="
|
|
form.text_color === '#000000'
|
|
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
|
|
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
|
|
"
|
|
:disabled="saving || isEditLocked"
|
|
@click="form.text_color = '#000000'"
|
|
>
|
|
<span class="w-2.5 h-2.5 rounded-full inline-block" style="background: #000000" />
|
|
Preto
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Campos adicionais -->
|
|
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
|
|
<div class="flex items-center justify-between gap-2 mb-3">
|
|
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
|
|
<Button label="Adicionar campo" icon="pi pi-plus" severity="secondary" outlined size="small" class="rounded-full" :disabled="saving || isFieldsLocked" @click="addField" />
|
|
</div>
|
|
|
|
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">Nenhum campo adicional configurado.</div>
|
|
|
|
<div v-else class="flex flex-col gap-2">
|
|
<div v-for="(f, idx) in form.fields" :key="f.key" class="grid grid-cols-1 gap-2 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12">
|
|
<div class="md:col-span-6">
|
|
<FloatLabel variant="on">
|
|
<InputText :id="`cr-field-label-${idx}`" v-model="f.label" class="w-full" variant="filled" :disabled="saving || isFieldsLocked" @keydown.enter.prevent="submit" @blur="syncKey(f)" />
|
|
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<div class="md:col-span-4">
|
|
<FloatLabel variant="on">
|
|
<Dropdown :id="`cr-field-type-${idx}`" v-model="f.type" :options="fieldTypeOptions" optionLabel="label" optionValue="value" class="w-full" variant="filled" :disabled="saving || isFieldsLocked" />
|
|
<label :for="`cr-field-type-${idx}`">Tipo</label>
|
|
</FloatLabel>
|
|
</div>
|
|
|
|
<div class="md:col-span-2 flex items-center justify-end">
|
|
<Button icon="pi pi-trash" severity="danger" text rounded :disabled="saving || isFieldsLocked" @click="removeField(idx)" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer com botões Cancelar / Salvar -->
|
|
<template #footer>
|
|
<div class="flex items-center justify-end gap-2 pt-2">
|
|
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="close" />
|
|
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" :disabled="!canSubmit" @click="submit" />
|
|
</div>
|
|
</template>
|
|
</Dialog>
|
|
</template>
|