ZERADO
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
<!-- src/features/agenda/components/AgendaClinicMosaic.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
@@ -22,33 +25,63 @@ const props = defineProps({
|
||||
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
|
||||
minColWidth: { type: Number, default: 360 }
|
||||
// largura mínima de cada coluna (terapeutas)
|
||||
minColWidth: { type: Number, default: 360 },
|
||||
|
||||
// ✅ coluna da clínica
|
||||
showClinicColumn: { type: Boolean, default: true },
|
||||
clinicId: { type: String, default: '' },
|
||||
clinicTitle: { type: String, default: 'Clínica' },
|
||||
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
|
||||
|
||||
// subtitle terapeutas
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' }
|
||||
})
|
||||
|
||||
// ✅ rangeChange = mudança de range (carregar eventos)
|
||||
// ✅ slotSelect = seleção de intervalo em uma coluna específica (criar evento)
|
||||
// ✅ eventClick/Drop/Resize = ações em evento
|
||||
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize'])
|
||||
const emit = defineEmits([
|
||||
'rangeChange',
|
||||
'slotSelect',
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize',
|
||||
// ✅ debug
|
||||
'debugColumn'
|
||||
])
|
||||
|
||||
const calendarRefs = ref([])
|
||||
|
||||
function setCalendarRef (el, idx) {
|
||||
if (!el) return
|
||||
calendarRefs.value[idx] = el
|
||||
}
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
|
||||
const initialView = computed(() => {
|
||||
if (props.view === 'week') return 'timeGridWeek'
|
||||
if (props.view === 'month') return 'dayGridMonth'
|
||||
return 'timeGridDay'
|
||||
})
|
||||
|
||||
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
|
||||
// ✅ 23:59:59 para evitar edge-case de 24:00:00
|
||||
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
|
||||
|
||||
// ✅ coluna fixa (clínica)
|
||||
const clinicColumn = computed(() => {
|
||||
if (!props.showClinicColumn) return null
|
||||
const id = String(props.clinicId || '').trim()
|
||||
if (!id) return null
|
||||
return { id, title: props.clinicTitle || 'Clínica', __kind: 'clinic' }
|
||||
})
|
||||
|
||||
const staffColumns = computed(() => {
|
||||
const base = Array.isArray(props.staff) ? props.staff : []
|
||||
return base
|
||||
.filter(s => s?.id)
|
||||
.map(s => ({ ...s, __kind: 'staff' }))
|
||||
})
|
||||
|
||||
function apiAt (idx) {
|
||||
const fc = calendarRefs.value[idx]
|
||||
return fc?.getApi?.()
|
||||
}
|
||||
|
||||
function forEachApi (fn) {
|
||||
for (let i = 0; i < calendarRefs.value.length; i++) {
|
||||
const api = apiAt(i)
|
||||
@@ -59,18 +92,24 @@ function forEachApi (fn) {
|
||||
function goToday () { forEachApi(api => api.today()) }
|
||||
function prev () { forEachApi(api => api.prev()) }
|
||||
function next () { forEachApi(api => api.next()) }
|
||||
|
||||
function setView (v) {
|
||||
const target = v === 'week' ? 'timeGridWeek' : 'timeGridDay'
|
||||
forEachApi(api => api.changeView(target))
|
||||
function gotoDate (date) {
|
||||
if (!date) return
|
||||
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
|
||||
dt.setHours(12, 0, 0, 0) // anti “voltar dia”
|
||||
forEachApi(api => api.gotoDate(dt))
|
||||
}
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
function setView (v) {
|
||||
const target = v === 'week' ? 'timeGridWeek' : (v === 'month' ? 'dayGridMonth' : 'timeGridDay')
|
||||
forEachApi(api => api.changeView(target))
|
||||
}
|
||||
function setMode () {}
|
||||
|
||||
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
|
||||
|
||||
// Eventos por profissional (owner)
|
||||
function eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
|
||||
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''))
|
||||
}
|
||||
|
||||
// ---- range sync ----
|
||||
@@ -82,35 +121,93 @@ function onDatesSet (arg) {
|
||||
if (key === lastRangeKey) return
|
||||
lastRangeKey = key
|
||||
|
||||
// dispara carregamento no pai
|
||||
emit('rangeChange', {
|
||||
start: arg.start,
|
||||
end: arg.end,
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type
|
||||
viewType: arg.view.type,
|
||||
currentDate: arg.view?.currentStart || arg.start
|
||||
})
|
||||
|
||||
// mantém todos os calendários na mesma data
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.start
|
||||
const masterDate = arg.view?.currentStart || arg.start
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur) return
|
||||
if (!cur || !masterDate) return
|
||||
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
|
||||
})
|
||||
|
||||
// libera no próximo tick (evita loops)
|
||||
Promise.resolve().then(() => { suppressSync = false })
|
||||
}
|
||||
|
||||
// Se trocar view, garante que todos estão no mesmo
|
||||
watch(() => props.view, async () => {
|
||||
await nextTick()
|
||||
setView(props.view)
|
||||
})
|
||||
|
||||
// ---------- helpers UI ----------
|
||||
function colSubtitle (p) {
|
||||
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
|
||||
}
|
||||
|
||||
// ✅ debug emitter (cabeçalho clicável)
|
||||
function emitDebug (col) {
|
||||
emit('debugColumn', {
|
||||
staffCol: col,
|
||||
staffUserId: col?.id || null,
|
||||
staffTitle: col?.title || null,
|
||||
kind: col?.__kind || null,
|
||||
at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
function buildFcOptions (ownerId) {
|
||||
const base = {
|
||||
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
|
||||
locale: ptBrLocale,
|
||||
timeZone: props.timezone,
|
||||
|
||||
headerToolbar: false,
|
||||
initialView: initialView.value,
|
||||
|
||||
nowIndicator: true,
|
||||
editable: true,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
|
||||
slotDuration: props.slotDuration,
|
||||
slotMinTime: computedSlotMinTime.value,
|
||||
slotMaxTime: computedSlotMaxTime.value,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false,
|
||||
|
||||
events: eventsFor(ownerId),
|
||||
datesSet: onDatesSet,
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
}
|
||||
|
||||
base.select = (selection) => {
|
||||
emit('slotSelect', {
|
||||
ownerId,
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
startStr: selection.startStr,
|
||||
endStr: selection.endStr,
|
||||
jsEvent: selection.jsEvent || null,
|
||||
viewType: selection.view?.type || initialView.value
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,74 +216,105 @@ watch(() => props.view, async () => {
|
||||
Carregando agenda da clínica…
|
||||
</div>
|
||||
|
||||
<!-- Mosaic -->
|
||||
<div
|
||||
class="p-2 md:p-3 overflow-x-auto"
|
||||
:style="{ display: 'grid', gridAutoFlow: 'column', gridAutoColumns: `minmax(${minColWidth}px, 1fr)`, gap: '12px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(p, idx) in staff"
|
||||
:key="p.id"
|
||||
class="rounded-[1.25rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] overflow-hidden"
|
||||
>
|
||||
<!-- Header da coluna -->
|
||||
<div class="p-3 border-b border-[var(--surface-border)] flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ p.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">Visão diária operacional</div>
|
||||
<div class="mosaic-shell">
|
||||
<!-- Coluna fixa: Clínica -->
|
||||
<div v-if="clinicColumn" class="mosaic-fixed">
|
||||
<div class="mosaic-col">
|
||||
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ clinicColumn.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, 0)"
|
||||
:options="buildFcOptions(clinicColumn.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, idx)"
|
||||
:options="{
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: initialView,
|
||||
timeZone: timezone,
|
||||
<!-- Área rolável: Terapeutas -->
|
||||
<div class="mosaic-scroll">
|
||||
<div
|
||||
class="mosaic-grid"
|
||||
:style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(p, sIdx) in staffColumns"
|
||||
:key="p.id"
|
||||
class="mosaic-col"
|
||||
>
|
||||
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(p)" title="Debug desta coluna">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ p.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">{{ colSubtitle(p) }}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
headerToolbar: false,
|
||||
nowIndicator: true,
|
||||
|
||||
editable: true,
|
||||
|
||||
// ✅ seleção para criar evento (por coluna)
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
select: (selection) => {
|
||||
emit('slotSelect', {
|
||||
ownerId: p.id,
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
startStr: selection.startStr,
|
||||
endStr: selection.endStr,
|
||||
jsEvent: selection.jsEvent || null,
|
||||
viewType: selection.view?.type || initialView
|
||||
})
|
||||
},
|
||||
|
||||
slotDuration: slotDuration,
|
||||
slotMinTime: computedSlotMinTime,
|
||||
slotMaxTime: computedSlotMaxTime,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false,
|
||||
|
||||
events: eventsFor(p.id),
|
||||
|
||||
datesSet: onDatesSet,
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
}"
|
||||
/>
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
|
||||
:options="buildFcOptions(p.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mosaic-shell{
|
||||
display:flex;
|
||||
gap:12px;
|
||||
padding: 8px;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
.mosaic-shell{ padding: 12px; }
|
||||
}
|
||||
|
||||
.mosaic-fixed{
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
min-width: 320px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.mosaic-scroll{
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mosaic-grid{
|
||||
display:grid;
|
||||
grid-auto-flow: column;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.mosaic-col{
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.mosaic-col-head{
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
||||
<!-- src/features/agenda/components/AgendaRightPanel.vue -->
|
||||
<script setup>
|
||||
import Card from 'primevue/card'
|
||||
import Divider from 'primevue/divider'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Painel' },
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Agenda' },
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
<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' } }"
|
||||
>
|
||||
<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="dc-header-dot shrink-0"
|
||||
: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"
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner de preview -->
|
||||
<div
|
||||
class="dc-banner"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
>
|
||||
<span
|
||||
class="dc-banner__pill"
|
||||
:style="{ color: form.text_color || '#ffffff' }"
|
||||
>
|
||||
{{ form.name || 'Nome do compromisso' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-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>
|
||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||
<span class="text-sm font-medium">Ativo</span>
|
||||
<InputSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção Cor -->
|
||||
<div class="dc-section">
|
||||
<div class="dc-section__label">Cor</div>
|
||||
|
||||
<!-- Paleta predefinida -->
|
||||
<div class="dc-palette">
|
||||
<button
|
||||
v-for="p in presetColors"
|
||||
:key="p.bg"
|
||||
class="dc-swatch"
|
||||
:class="{ 'dc-swatch--active': form.bg_color === p.bg }"
|
||||
: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 dc-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="dc-swatch dc-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker
|
||||
v-model="form.bg_color"
|
||||
format="hex"
|
||||
:disabled="saving || isEditLocked"
|
||||
/>
|
||||
</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="dc-text-opt"
|
||||
:class="{ 'dc-text-opt--active': form.text_color === '#ffffff' }"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="form.text_color = '#ffffff'"
|
||||
>
|
||||
<span class="dc-text-opt__dot" style="background:#ffffff; border: 1px solid #ccc;" />
|
||||
Branco
|
||||
</button>
|
||||
<button
|
||||
class="dc-text-opt"
|
||||
:class="{ 'dc-text-opt--active': form.text_color === '#000000' }"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="form.text_color = '#000000'"
|
||||
>
|
||||
<span class="dc-text-opt__dot" style="background:#000000;" />
|
||||
Preto
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Campos adicionais -->
|
||||
<div class="dc-section">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="dc-section__label mb-0">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-2xl 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 class="md:col-span-12 text-xs opacity-40 font-mono">
|
||||
key: {{ f.key }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
|
||||
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' },
|
||||
]
|
||||
|
||||
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) // nativo+locked → sempre ativo, nunca pode desativar
|
||||
const isEditLocked = computed(() => false) // edição sempre permitida
|
||||
const isFieldsLocked = computed(() => false) // campos sempre editáveis
|
||||
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) {
|
||||
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ─────────────────────────────── */
|
||||
.dc-header-dot {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Banner de preview ───────────────────── */
|
||||
.dc-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.dc-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────── */
|
||||
.dc-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.dc-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Paleta ──────────────────────────────── */
|
||||
.dc-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem;
|
||||
}
|
||||
.dc-swatch {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
position: relative;
|
||||
}
|
||||
.dc-swatch:hover:not(:disabled) {
|
||||
transform: scale(1.18);
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
.dc-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.dc-swatch__check {
|
||||
font-size: 0.6rem; color: #fff; font-weight: 900;
|
||||
}
|
||||
.dc-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dc-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%;
|
||||
border: none; border-radius: 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Texto toggle ────────────────────────── */
|
||||
.dc-text-opt {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
font-size: 0.8rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.dc-text-opt:hover:not(:disabled) { background: var(--surface-hover); }
|
||||
.dc-text-opt--active {
|
||||
background: var(--surface-section, var(--surface-100));
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
.dc-text-opt__dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Agenda' },
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
stats: { type: Object, default: () => ({}) }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// src/features/agenda/composables/useAgendaClinicEvents.js
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
listClinicEvents,
|
||||
createClinicAgendaEvento,
|
||||
updateClinicAgendaEvento,
|
||||
deleteClinicAgendaEvento
|
||||
} from '@/features/agenda/services/agendaClinicRepository'
|
||||
|
||||
export function useAgendaClinicEvents () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
async function loadClinicRange ({ tenantId, ownerIds, startISO, endISO }) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
rows.value = await listClinicEvents({ tenantId, ownerIds, startISO, endISO })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createClinic (payload, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await createClinicAgendaEvento(payload, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClinic (id, patch, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await updateClinicAgendaEvento(id, patch, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClinic (id, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await deleteClinicAgendaEvento(id, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
rows,
|
||||
loadClinicRange,
|
||||
createClinic,
|
||||
updateClinic,
|
||||
removeClinic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export function useDeterminedCommitments (tenantIdRef) {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
const tenantId = computed(() => {
|
||||
const v = tenantIdRef?.value ?? tenantIdRef
|
||||
return v ? String(v) : ''
|
||||
})
|
||||
|
||||
async function load () {
|
||||
try {
|
||||
if (!tenantId.value) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
|
||||
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
|
||||
.eq('active', true)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (err) throw err
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar compromissos determinísticos.'
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, rows, load }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,753 @@
|
||||
<!-- src/features/agenda/pages/CompromissosDeterminados.vue -->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="cmpr-sentinel" />
|
||||
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="cmpr-hero mx-3 md:mx-5 mb-4" :class="{ 'cmpr-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="cmpr-hero__blobs" aria-hidden="true">
|
||||
<div class="cmpr-hero__blob cmpr-hero__blob--1" />
|
||||
<div class="cmpr-hero__blob cmpr-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="cmpr-hero__row1">
|
||||
<div class="cmpr-hero__brand">
|
||||
<div class="cmpr-hero__icon">
|
||||
<i class="pi pi-list text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="cmpr-hero__title">Compromissos</div>
|
||||
<div class="cmpr-hero__sub">Configure tipos de compromissos e campos adicionais</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll()" />
|
||||
</div>
|
||||
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="cmpr-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: filtros + busca (oculta no mobile) -->
|
||||
<div class="cmpr-hero__row2">
|
||||
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" />
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar compromisso" :disabled="loading" />
|
||||
<Button
|
||||
v-if="filters.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar busca"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar compromisso" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome ou descrição..." autofocus />
|
||||
<Button
|
||||
v-if="filters.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar"
|
||||
@click="filters.global.value = null"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="mb-4 px-3 md:px-5 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="c in cardsCommitments"
|
||||
:key="c.id"
|
||||
class="rounded-3xl border border-[var(--surface-border)] shadow-sm"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="c.bg_color"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:style="{ backgroundColor: `#${c.bg_color}`, color: c.text_color || '#ffffff' }"
|
||||
>{{ c.name }}</span>
|
||||
<span v-else class="truncate text-base font-semibold">{{ c.name }}</span>
|
||||
<Tag v-if="c.is_native" value="Nativo" severity="info" />
|
||||
</div>
|
||||
<div class="mt-1 line-clamp-2 text-sm opacity-70">
|
||||
{{ c.description || '—' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-sm">
|
||||
<span class="opacity-70">Tempo total:</span>
|
||||
<span class="ml-2 font-semibold">{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-70">Ativo</span>
|
||||
<InputSwitch
|
||||
v-model="c.active"
|
||||
:disabled="isActiveLocked(c) || saving"
|
||||
@change="onToggleActive(c)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
:disabled="isEditLocked(c) || saving"
|
||||
@click="openEdit(c)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="isDeleteLocked(c) || saving"
|
||||
@click="confirmDelete(c)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="mx-3 md:mx-5 mb-5 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<div class="text-base font-semibold">Lista de compromissos</div>
|
||||
<div class="text-sm opacity-60">
|
||||
{{ visibleCommitments.length }} itens
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="visibleCommitments"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
:globalFilterFields="['name','description']"
|
||||
>
|
||||
|
||||
<Column field="name" header="Nome" sortable filter filterPlaceholder="Filtrar nome" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ data.name }}</span>
|
||||
<Tag v-if="data.is_native" value="Nativo" severity="info" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descrição" sortable filter filterPlaceholder="Filtrar descrição" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<span class="opacity-80">{{ data.description || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tempo total" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatMinutes(getTotalMinutes(data.id)) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="active" header="Ativo" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<InputSwitch
|
||||
v-model="data.active"
|
||||
:disabled="isActiveLocked(data) || saving"
|
||||
@change="onToggleActive(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
:disabled="isEditLocked(data) || saving"
|
||||
@click="openEdit(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="isDeleteLocked(data) || saving"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum compromisso determinístico encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearSearch" />
|
||||
<Button icon="pi pi-plus" label="Cadastrar compromisso" @click="openCreate()" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<DeterminedCommitmentDialog
|
||||
v-model="dlgOpen"
|
||||
:mode="dlgMode"
|
||||
:saving="saving"
|
||||
:commitment="editing"
|
||||
@save="onSave"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
const searchDlgOpen = ref(false)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Novo compromisso',
|
||||
icon: 'pi pi-plus',
|
||||
command: () => openCreate()
|
||||
},
|
||||
{
|
||||
label: 'Buscar',
|
||||
icon: 'pi pi-search',
|
||||
command: () => { searchDlgOpen.value = true }
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Recarregar',
|
||||
icon: 'pi pi-refresh',
|
||||
command: () => fetchAll()
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
await fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
name: { value: null, matchMode: 'contains' },
|
||||
description: { value: null, matchMode: 'contains' }
|
||||
})
|
||||
|
||||
/**
|
||||
* Filtro por tipo (Todos / Nativos / Meus)
|
||||
* - aplica na tabela (via computed) e nos cards
|
||||
*/
|
||||
const typeFilter = ref('all')
|
||||
const typeOptions = [
|
||||
{ label: 'Todos', value: 'all' },
|
||||
{ label: 'Nativos', value: 'native' },
|
||||
{ label: 'Meus', value: 'custom' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Modelo de compromisso (tipo determinístico):
|
||||
* - is_native: template do sistema
|
||||
* - is_locked: trava comportamento (ex: Sessão)
|
||||
* - fields: campos adicionais (dinâmicos)
|
||||
*/
|
||||
const commitments = ref([])
|
||||
|
||||
// Totais reais (minutos) agregados de commitment_time_logs
|
||||
const totalsByCommitmentId = ref({})
|
||||
|
||||
/**
|
||||
* Lista base para tabela:
|
||||
* - aplica filtro por tipo (Todos / Nativos / Meus)
|
||||
* - (global search do DataTable continua via :filters)
|
||||
*/
|
||||
const visibleCommitments = computed(() => {
|
||||
let list = commitments.value
|
||||
|
||||
if (typeFilter.value === 'native') list = list.filter(c => !!c.is_native)
|
||||
if (typeFilter.value === 'custom') list = list.filter(c => !c.is_native)
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
/**
|
||||
* Lista para cards:
|
||||
* - aplica o mesmo filtro de tipo
|
||||
* - + aplica busca global (para cards acompanharem a barra de busca)
|
||||
*/
|
||||
const cardsCommitments = computed(() => {
|
||||
let list = visibleCommitments.value
|
||||
|
||||
const q = String(filters.global?.value ?? '').trim().toLowerCase()
|
||||
if (q) {
|
||||
list = list.filter(c =>
|
||||
String(c.name || '').toLowerCase().includes(q) ||
|
||||
String(c.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function clearSearch () {
|
||||
filters.global.value = null
|
||||
}
|
||||
|
||||
const dlgOpen = ref(false)
|
||||
const dlgMode = ref('create') // 'create' | 'edit'
|
||||
const editing = ref(null)
|
||||
|
||||
function getTenantId () {
|
||||
// ✅ sem fallback (evita vazamento clinic↔therapist)
|
||||
return tenantStore.activeTenantId || null
|
||||
}
|
||||
|
||||
|
||||
async function fetchAll () {
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Tenant inválido.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 1) commitments
|
||||
const { data: cData, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (cErr) throw cErr
|
||||
|
||||
const ids = (cData || []).map(x => x.id)
|
||||
|
||||
// 2) fields
|
||||
let fieldsByCommitmentId = {}
|
||||
if (ids.length > 0) {
|
||||
const { data: fData, error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('commitment_id', ids)
|
||||
.order('sort_order', { ascending: true })
|
||||
|
||||
if (fErr) throw fErr
|
||||
|
||||
fieldsByCommitmentId = (fData || []).reduce((acc, row) => {
|
||||
const k = row.commitment_id
|
||||
if (!acc[k]) acc[k] = []
|
||||
acc[k].push({
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
type: row.field_type,
|
||||
required: !!row.required,
|
||||
sort_order: row.sort_order
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
// 3) totals (logs)
|
||||
const { data: lData, error: lErr } = await supabase
|
||||
.from('commitment_time_logs')
|
||||
.select('commitment_id, minutes')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (lErr) throw lErr
|
||||
|
||||
const totals = {}
|
||||
for (const row of (lData || [])) {
|
||||
const cid = row.commitment_id
|
||||
const m = Number(row.minutes ?? 0) || 0
|
||||
totals[cid] = (totals[cid] || 0) + m
|
||||
}
|
||||
|
||||
totalsByCommitmentId.value = totals
|
||||
|
||||
// 4) merge
|
||||
commitments.value = (cData || []).map(c => ({
|
||||
...c,
|
||||
fields: fieldsByCommitmentId[c.id] || []
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar compromissos.', life: 4500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTotalMinutes (commitmentId) {
|
||||
return Number(totalsByCommitmentId.value?.[commitmentId] ?? 0)
|
||||
}
|
||||
|
||||
function formatMinutes (minutes) {
|
||||
const m = Math.max(0, Number(minutes) || 0)
|
||||
const h = Math.floor(m / 60)
|
||||
const mm = m % 60
|
||||
if (h <= 0) return `${mm}m`
|
||||
return `${h}h ${String(mm).padStart(2, '0')}m`
|
||||
}
|
||||
|
||||
function isActiveLocked (c) {
|
||||
return !!c.is_locked
|
||||
}
|
||||
|
||||
function isDeleteLocked (c) {
|
||||
return !!c.is_native
|
||||
}
|
||||
|
||||
function isEditLocked (_c) {
|
||||
return false // edição sempre permitida; só o "ativo" fica travado
|
||||
}
|
||||
|
||||
function openCreate () {
|
||||
dlgMode.value = 'create'
|
||||
editing.value = null
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit (c) {
|
||||
dlgMode.value = 'edit'
|
||||
editing.value = JSON.parse(JSON.stringify(c))
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
async function onToggleActive (c) {
|
||||
if (isActiveLocked(c)) return
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('determined_commitments')
|
||||
.update({ active: !!c.active })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', c.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Atualizado',
|
||||
detail: `“${c.name}” agora está ${c.active ? 'ativo' : 'inativo'}.`,
|
||||
life: 2500
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
c.active = !c.active
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar.', life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave (payload) {
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// pega usuário atual (se quiser auditoria futura)
|
||||
await supabase.auth.getUser()
|
||||
|
||||
if (dlgMode.value === 'create') {
|
||||
const insertRow = {
|
||||
tenant_id: tenantId,
|
||||
is_native: false,
|
||||
native_key: null,
|
||||
is_locked: false,
|
||||
active: !!payload.active,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
}
|
||||
|
||||
const { data: newC, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.insert(insertRow)
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.single()
|
||||
|
||||
if (cErr) throw cErr
|
||||
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : []
|
||||
if (fields.length > 0) {
|
||||
const rows = fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: newC.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.type,
|
||||
required: !!f.required,
|
||||
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
|
||||
}))
|
||||
|
||||
const { error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.insert(rows)
|
||||
|
||||
if (fErr) throw fErr
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
} else {
|
||||
const updateRow = {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
active: !!payload.active,
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
}
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.update(updateRow)
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', payload.id)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : []
|
||||
|
||||
const { error: delErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', payload.id)
|
||||
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (fields.length > 0) {
|
||||
const rows = fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: payload.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.type,
|
||||
required: !!f.required,
|
||||
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
|
||||
}))
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.insert(rows)
|
||||
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || ''
|
||||
const detail = (e?.code === '23505' || /duplicate key value/i.test(msg))
|
||||
? 'Já existe um compromisso com esse nome neste tenant. Escolha outro nome.'
|
||||
: (msg || 'Falha ao salvar compromisso.')
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete (c) {
|
||||
if (isDeleteLocked(c)) return
|
||||
const ok = window.confirm(`Excluir “${c.name}”? Essa ação não pode ser desfeita.`)
|
||||
if (!ok) return
|
||||
onDelete(c)
|
||||
}
|
||||
|
||||
async function onDelete (c) {
|
||||
if (isDeleteLocked(c)) return
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', c.id)
|
||||
if (fErr) throw fErr
|
||||
|
||||
const { error: lErr } = await supabase
|
||||
.from('commitment_time_logs')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', c.id)
|
||||
if (lErr) throw lErr
|
||||
|
||||
const { data: delRows, error: dErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', c.id)
|
||||
.eq('is_native', false)
|
||||
.select('id')
|
||||
if (dErr) throw dErr
|
||||
|
||||
if (!delRows || delRows.length === 0) {
|
||||
throw new Error('DELETE bloqueado por RLS (0 linhas). Confirme policy dc_delete_custom_for_active_member.')
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir compromisso.', life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.cmpr-sentinel { height: 1px; }
|
||||
|
||||
.cmpr-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.cmpr-hero--stuck {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs */
|
||||
.cmpr-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.cmpr-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.cmpr-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.cmpr-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
/* Linha 1 */
|
||||
.cmpr-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.cmpr-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.cmpr-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.cmpr-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.cmpr-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.cmpr-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.cmpr-hero__divider,
|
||||
.cmpr-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
// src/features/agenda/services/agendaClinicRepository.js
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
function assertValidTenantId (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.')
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidIsoRange (startISO, endISO) {
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
|
||||
}
|
||||
|
||||
function sanitizeOwnerIds (ownerIds) {
|
||||
return (ownerIds || [])
|
||||
.filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
|
||||
*/
|
||||
export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO } = {}) {
|
||||
assertValidTenantId(tenantId)
|
||||
if (!ownerIds?.length) return []
|
||||
assertValidIsoRange(startISO, endISO)
|
||||
|
||||
const safeOwnerIds = sanitizeOwnerIds(ownerIds)
|
||||
if (!safeOwnerIds.length) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista profissionais/membros para montar colunas no mosaico.
|
||||
* Usando view "v_tenant_staff" (como você já tem).
|
||||
*/
|
||||
export async function listTenantStaff (tenantId) {
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('v_tenant_staff')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Criação para a área da clínica (admin/secretária):
|
||||
* - exige tenantId explícito
|
||||
* - permite definir owner_id (terapeuta dono do compromisso)
|
||||
*
|
||||
* Segurança real deve ser garantida por RLS:
|
||||
* - clinic_admin/tenant_admin pode criar para qualquer owner dentro do tenant
|
||||
* - therapist não deve conseguir passar daqui (guard + RLS)
|
||||
*/
|
||||
export async function createClinicAgendaEvento (payload, { tenantId } = {}) {
|
||||
assertValidTenantId(tenantId)
|
||||
if (!payload) throw new Error('Payload vazio.')
|
||||
|
||||
const ownerId = payload.owner_id
|
||||
if (!ownerId || ownerId === 'null' || ownerId === 'undefined') {
|
||||
throw new Error('owner_id é obrigatório para criação pela clínica.')
|
||||
}
|
||||
|
||||
const insertPayload = {
|
||||
...payload,
|
||||
tenant_id: tenantId
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert(insertPayload)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualização segura para clínica:
|
||||
* - filtra por id + tenant_id (evita update cruzado)
|
||||
* - permite editar owner_id (caso você mova evento para outro profissional)
|
||||
*/
|
||||
export async function updateClinicAgendaEvento (id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
if (!patch) throw new Error('Patch vazio.')
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete seguro para clínica:
|
||||
* - filtra por id + tenant_id
|
||||
*/
|
||||
export async function deleteClinicAgendaEvento (id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return true
|
||||
}
|
||||
@@ -1,20 +1,52 @@
|
||||
// src/features/agenda/services/agendaMappers.js
|
||||
|
||||
export function mapAgendaEventosToCalendarEvents (rows) {
|
||||
return (rows || []).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
extendedProps: {
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
paciente_id: r.paciente_id,
|
||||
terapeuta_id: r.terapeuta_id,
|
||||
observacoes: r.observacoes,
|
||||
owner_id: r.owner_id
|
||||
return (rows || []).map((r) => {
|
||||
// 🔥 regra importante:
|
||||
// prioridade: owner_id
|
||||
// fallback: terapeuta_id
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
extendedProps: {
|
||||
// 🔥 ESSENCIAL PARA O MOSAICO
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
paciente_nome: r.patients?.nome_completo ?? null,
|
||||
paciente_avatar: r.patients?.avatar_url ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
// ✅ usados na clínica p/ mascarar/privacidade
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// ✅ compromisso determinístico
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// ✅ campos customizados
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
@@ -98,21 +130,52 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
|
||||
}
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
resourceId: r.owner_id, // 🔥 coluna = dono da agenda (profissional)
|
||||
extendedProps: {
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
paciente_id: r.paciente_id,
|
||||
terapeuta_id: r.terapeuta_id,
|
||||
observacoes: r.observacoes,
|
||||
owner_id: r.owner_id
|
||||
return (rows || []).map((r) => {
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
// 🔥 resourceId também precisa ser confiável
|
||||
resourceId: ownerId,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------- helpers --------------------
|
||||
|
||||
function normalizeId (v) {
|
||||
if (v === null || v === undefined) return null
|
||||
const s = String(v).trim()
|
||||
return s ? s : null
|
||||
}
|
||||
|
||||
function normalizeWeekday (value) {
|
||||
|
||||
@@ -28,7 +28,9 @@ export async function getMyAgendaSettings () {
|
||||
.from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.single()
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
@@ -49,7 +51,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
@@ -57,7 +59,27 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
const rows = data || []
|
||||
|
||||
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
|
||||
// Fazemos um segundo fetch para esses casos e mesclamos.
|
||||
const orphanIds = [...new Set(
|
||||
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
|
||||
)]
|
||||
if (orphanIds.length) {
|
||||
const { data: pts } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, avatar_url')
|
||||
.in('id', orphanIds)
|
||||
if (pts?.length) {
|
||||
const map = Object.fromEntries(pts.map(p => [p.id, p]))
|
||||
for (const r of rows) {
|
||||
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +99,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<i :class="icon" class="opacity-80" />
|
||||
<div class="text-base font-semibold">{{ title }}</div>
|
||||
</div>
|
||||
<div class="mt-1 text-sm opacity-80">{{ desc }}</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0">
|
||||
<ToggleButton
|
||||
:modelValue="enabled"
|
||||
onLabel="Ativo"
|
||||
offLabel="Inativo"
|
||||
:loading="loading"
|
||||
@update:modelValue="$emit('toggle')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
|
||||
defineProps({
|
||||
title: { type: String, default: '' },
|
||||
desc: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
enabled: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
@@ -1,139 +1,112 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- HEADER CONCEITUAL -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative p-5">
|
||||
<!-- blobs sutis -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
|
||||
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
|
||||
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
|
||||
</div>
|
||||
<Toast />
|
||||
|
||||
<div class="relative flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<!-- título -->
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-users text-lg" />
|
||||
</div>
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="pat-sentinel" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-semibold leading-none">Pacientes</div>
|
||||
<Tag :value="`${kpis.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Lista de pacientes cadastrados. Filtre por status, tags e grupos.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="pat-hero mx-3 md:mx-5 mb-4" :class="{ 'pat-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="pat-hero__blobs" aria-hidden="true">
|
||||
<div class="pat-hero__blob pat-hero__blob--1" />
|
||||
<div class="pat-hero__blob pat-hero__blob--2" />
|
||||
<div class="pat-hero__blob pat-hero__blob--3" />
|
||||
</div>
|
||||
|
||||
<!-- KPIs como filtros -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Todos'"
|
||||
severity="secondary"
|
||||
@click="setStatus('Todos')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-users" />
|
||||
Total: <b>{{ kpis.total }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="pat-hero__row1">
|
||||
<div class="pat-hero__brand">
|
||||
<div class="pat-hero__icon">
|
||||
<i class="pi pi-users text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="pat-hero__title">Pacientes</div>
|
||||
<Tag :value="`${kpis.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="pat-hero__sub">Lista de pacientes cadastrados. Filtre por status, tags e grupos.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Ativo'"
|
||||
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
|
||||
@click="setStatus('Ativo')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-user-plus" />
|
||||
Ativos: <b>{{ kpis.active }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchAll" />
|
||||
<SplitButton label="Cadastrar" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Inativo'"
|
||||
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
|
||||
@click="setStatus('Inativo')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-user-minus" />
|
||||
Inativos: <b>{{ kpis.inactive }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="patMobileMenuRef" :model="patMobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-2 text-xs text-color-secondary">
|
||||
<i class="pi pi-calendar" />
|
||||
Último atendimento: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Divisor -->
|
||||
<Divider class="pat-hero__divider my-2" />
|
||||
|
||||
<!-- ações -->
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
v-model="filters.search"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
@input="onFilterChangedDebounced"
|
||||
/>
|
||||
</IconField>
|
||||
</FloatLabel>
|
||||
</span>
|
||||
<!-- Linha 2: KPI filtros (oculta no mobile) -->
|
||||
<div class="pat-hero__row2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Todos'"
|
||||
severity="secondary"
|
||||
@click="setStatus('Todos')"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="pi pi-users text-xs" />
|
||||
Total: <b>{{ kpis.total }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
label="Atualizar"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchAll"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Ativo'"
|
||||
:severity="filters.status === 'Ativo' ? 'success' : 'secondary'"
|
||||
@click="setStatus('Ativo')"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="pi pi-user-plus text-xs" />
|
||||
Ativos: <b>{{ kpis.active }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<SplitButton
|
||||
label="Cadastrar"
|
||||
icon="pi pi-user-plus"
|
||||
:model="createMenu"
|
||||
@click="goCreateFull"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
:outlined="filters.status !== 'Inativo'"
|
||||
:severity="filters.status === 'Inativo' ? 'danger' : 'secondary'"
|
||||
@click="setStatus('Inativo')"
|
||||
>
|
||||
<span class="flex items-center gap-1.5">
|
||||
<i class="pi pi-user-minus text-xs" />
|
||||
Inativos: <b>{{ kpis.inactive }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<!-- chips de filtros ativos (micro-UX) -->
|
||||
<div v-if="hasActiveFilters" class="relative mt-4 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-color-secondary">Filtros:</span>
|
||||
<span class="inline-flex items-center gap-1.5 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] px-3 py-1.5 text-xs text-color-secondary">
|
||||
<i class="pi pi-calendar" />
|
||||
Último atend.: <b class="text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
|
||||
|
||||
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
|
||||
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
|
||||
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
|
||||
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
|
||||
|
||||
<Button
|
||||
label="Limpar"
|
||||
icon="pi pi-filter-slash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
class="!rounded-full"
|
||||
@click="clearAllFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Chips de filtros ativos (fora do hero) -->
|
||||
<div v-if="hasActiveFilters" class="mx-3 md:mx-5 mb-3 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-color-secondary">Filtros:</span>
|
||||
<Tag v-if="filters.status && filters.status !== 'Todos'" :value="`Status: ${filters.status}`" severity="secondary" />
|
||||
<Tag v-if="filters.groupId" value="Grupo selecionado" severity="secondary" />
|
||||
<Tag v-if="filters.tagId" value="Tag selecionada" severity="secondary" />
|
||||
<Tag v-if="filters.createdFrom" value="Data inicial" severity="secondary" />
|
||||
<Tag v-if="filters.createdTo" value="Data final" severity="secondary" />
|
||||
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearAllFilters" />
|
||||
</div>
|
||||
|
||||
|
||||
<!-- KPI Cards
|
||||
@@ -209,7 +182,7 @@
|
||||
</div> -->
|
||||
|
||||
<!-- TABS (placeholder para evoluir depois) -->
|
||||
<Tabs value="pacientes" class="mt-3">
|
||||
<Tabs value="pacientes" class="px-3 md:px-5 mb-5">
|
||||
<TabList>
|
||||
<Tab value="pacientes"><i class="pi pi-users mr-2" />Pacientes</Tab>
|
||||
<Tab value="espera"><i class="pi pi-hourglass mr-2" />Lista de espera</Tab>
|
||||
@@ -403,163 +376,170 @@
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<DataTable
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="15"
|
||||
:rowsPerPageOptions="[10, 15, 25, 50]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
sortMode="single"
|
||||
:sortField="sort.field"
|
||||
:sortOrder="sort.order"
|
||||
@sort="onSort"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<Column
|
||||
:key="'col-paciente'"
|
||||
field="nome_completo"
|
||||
header="Paciente"
|
||||
v-if="isColVisible('paciente')"
|
||||
sortable
|
||||
>
|
||||
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar
|
||||
v-if="data.avatar_url"
|
||||
:image="data.avatar_url"
|
||||
shape="square"
|
||||
size="large"
|
||||
/>
|
||||
<Avatar
|
||||
v-else
|
||||
:label="initials(data.nome_completo)"
|
||||
shape="square"
|
||||
size="large"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.nome_completo }}</div>
|
||||
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
|
||||
<!-- Table – desktop (md+) -->
|
||||
<div class="hidden md:block">
|
||||
<DataTable
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="15"
|
||||
:rowsPerPageOptions="[10, 15, 25, 50]"
|
||||
stripedRows
|
||||
scrollable
|
||||
scrollHeight="flex"
|
||||
sortMode="single"
|
||||
:sortField="sort.field"
|
||||
:sortOrder="sort.order"
|
||||
@sort="onSort"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar paciente" @click="goCreateFull" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data.status"
|
||||
:severity="data.status === 'Ativo' ? 'success' : 'danger'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
<Column :key="'col-paciente'" field="nome_completo" header="Paciente" v-if="isColVisible('paciente')" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
|
||||
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ data.nome_completo }}</div>
|
||||
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm leading-tight">
|
||||
<div class="font-medium">
|
||||
{{ fmtPhoneBR(data.telefone) }}
|
||||
</div>
|
||||
<div class="text-xs text-color-secondary">
|
||||
{{ data.email_principal || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Telefone" style="width: 16rem;" v-if="isColVisible('telefone')" :key="'col-telefone'">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm leading-tight">
|
||||
<div class="font-medium">{{ fmtPhoneBR(data.telefone) }}</div>
|
||||
<div class="text-xs text-color-secondary">{{ data.email_principal || '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
|
||||
</Column>
|
||||
<Column field="email_principal" header="Email" style="width: 18rem;" v-if="isColVisible('email')" :key="'col-email'">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ data.email_principal || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.created_at || '—' }}</template>
|
||||
</Column>
|
||||
<Column field="last_attended_at" header="Último Atendimento" v-if="isColVisible('last_attended_at')" :key="'col-last_attended_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.last_attended_at || '—' }}</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.groups || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag
|
||||
v-for="g in data.groups"
|
||||
:key="g.id"
|
||||
:value="g.name"
|
||||
:style="chipStyle(g.color)"
|
||||
/>
|
||||
<Column field="created_at" header="Cadastrado em" v-if="isColVisible('created_at')" :key="'col-created_at'" sortable style="width: 12rem;">
|
||||
<template #body="{ data }">{{ data.created_at || '—' }}</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Grupos" style="min-width: 14rem;" v-if="isColVisible('grupos')" :key="'col-grupos'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.groups || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag v-for="g in data.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.tags || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag v-for="t in data.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 16rem;" frozen alignFrozen="right">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Cards – mobile (<md) -->
|
||||
<div class="md:hidden">
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredRows.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Tente limpar filtros ou mudar o termo de busca.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearAllFilters" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar" @click="goCreateFull" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3 pb-4">
|
||||
<div
|
||||
v-for="pat in filteredRows"
|
||||
:key="pat.id"
|
||||
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<!-- Topo: avatar + nome + status -->
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
|
||||
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate">{{ pat.nome_completo }}</div>
|
||||
<div class="text-xs text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
|
||||
</div>
|
||||
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tags" style="min-width: 14rem;" v-if="isColVisible('tags')" :key="'col-tags'">
|
||||
<template #body="{ data }">
|
||||
<div v-if="!(data.tags || []).length" class="text-color-secondary">—</div>
|
||||
<div v-else class="flex flex-wrap gap-2">
|
||||
<Tag
|
||||
v-for="t in data.tags"
|
||||
:key="t.id"
|
||||
:value="t.name"
|
||||
:style="chipStyle(t.color)"
|
||||
/>
|
||||
<!-- Grupos + Tags -->
|
||||
<div v-if="(pat.groups || []).length || (pat.tags || []).length" class="mt-3 flex flex-wrap gap-1.5">
|
||||
<Tag v-for="g in pat.groups" :key="g.id" :value="g.name" :style="chipStyle(g.color)" />
|
||||
<Tag v-for="t in pat.tags" :key="t.id" :value="t.name" :style="chipStyle(t.color)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column
|
||||
:key="'col-acoes'"
|
||||
header="Ações"
|
||||
style="width: 16rem;"
|
||||
frozen
|
||||
alignFrozen="right"
|
||||
>
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
|
||||
</DataTable>
|
||||
<!-- Ações -->
|
||||
<div class="mt-3 flex gap-2 justify-end">
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between text-xs text-color-secondary">
|
||||
<div>
|
||||
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
|
||||
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
|
||||
<span v-if="hasActiveFilters"> (filtrado)</span>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
Dica: clique em “Ativos/Inativos” no topo para filtrar rápido.
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Exibindo <b class="text-[var(--text-color)]">{{ filteredRows.length }}</b> de
|
||||
<b class="text-[var(--text-color)]">{{ patients.length }}</b> pacientes.
|
||||
<span v-if="hasActiveFilters"> (filtrado)</span>
|
||||
</div>
|
||||
<div class="hidden md:block">
|
||||
Dica: clique em “Ativos/Inativos" no topo para filtrar rápido.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</TabPanel>
|
||||
|
||||
@@ -604,18 +584,19 @@
|
||||
@close="closeProntuario"
|
||||
/>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import Popover from 'primevue/popover'
|
||||
import Menu from 'primevue/menu'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
@@ -642,6 +623,22 @@ function getAreaBase() {
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const patMobileMenuRef = ref(null)
|
||||
|
||||
const patMobileMenuItems = [
|
||||
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
|
||||
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
|
||||
{ separator: true },
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
]
|
||||
|
||||
const uid = ref(null)
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -681,7 +678,7 @@ const lockedKeys = computed(() =>
|
||||
columnCatalogAll.filter(c => c.locked).map(c => c.key)
|
||||
)
|
||||
|
||||
// SEM mutar selectedColumns: apenas “projeta” as visíveis
|
||||
// SEM mutar selectedColumns: apenas “projeta" as visíveis
|
||||
const visibleKeys = computed(() => {
|
||||
const set = new Set(selectedColumns.value || [])
|
||||
lockedKeys.value.forEach(k => set.add(k))
|
||||
@@ -751,10 +748,18 @@ const createMenu = [
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
await loadUser()
|
||||
await fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
function fmtPhoneBR(v) {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
if (!d) return '—'
|
||||
@@ -815,37 +820,62 @@ function onQuickCreated(row) {
|
||||
// -----------------------------
|
||||
// Navigation (shared feature)
|
||||
// -----------------------------
|
||||
function goGroups() {
|
||||
router.push(`${getAreaBase()}/patients/grupos`)
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/')[1]
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
}
|
||||
|
||||
function goCreateFull() {
|
||||
router.push(`${getAreaBase()}/patients/cadastro`)
|
||||
function getPatientsRoutes () {
|
||||
const area = getAreaKey()
|
||||
|
||||
if (area === 'therapist') {
|
||||
return {
|
||||
groupsPath: '/therapist/patients/grupos',
|
||||
createPath: '/therapist/patients/cadastro',
|
||||
editPath: (id) => `/therapist/patients/cadastro/${id}`,
|
||||
|
||||
// se existir no seu router
|
||||
createName: 'therapist-patients-cadastro',
|
||||
editName: 'therapist-patients-cadastro-edit',
|
||||
groupsName: 'therapist-patients-grupos'
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ admin usa "pacientes" (PT-BR)
|
||||
return {
|
||||
groupsPath: '/admin/pacientes/grupos',
|
||||
createPath: '/admin/pacientes/cadastro',
|
||||
editPath: (id) => `/admin/pacientes/cadastro/${id}`,
|
||||
|
||||
// se existir no seu router (pelo que você mostrou antes, existe)
|
||||
createName: 'admin-pacientes-cadastro',
|
||||
editName: 'admin-pacientes-cadastro-edit',
|
||||
groupsName: 'admin-pacientes-grupos'
|
||||
}
|
||||
}
|
||||
|
||||
function goEdit(row) {
|
||||
function safePush (toObj, fallbackPath) {
|
||||
try {
|
||||
const r = router.resolve(toObj)
|
||||
if (r?.matched?.length) return router.push(toObj)
|
||||
} catch (_) {}
|
||||
return router.push(fallbackPath)
|
||||
}
|
||||
|
||||
function goGroups () {
|
||||
const r = getPatientsRoutes()
|
||||
return safePush({ name: r.groupsName }, r.groupsPath)
|
||||
}
|
||||
|
||||
function goCreateFull () {
|
||||
const r = getPatientsRoutes()
|
||||
return safePush({ name: r.createName }, r.createPath)
|
||||
}
|
||||
|
||||
function goEdit (row) {
|
||||
if (!row?.id) return
|
||||
router.push(`${getAreaBase()}/patients/cadastro/${row.id}`)
|
||||
}
|
||||
|
||||
function setStatus(v) {
|
||||
filters.status = v
|
||||
onFilterChanged()
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
filters.status = 'Todos'
|
||||
filters.search = ''
|
||||
filters.groupId = null
|
||||
filters.tagId = null
|
||||
filters.createdFrom = null
|
||||
filters.createdTo = null
|
||||
onFilterChanged()
|
||||
}
|
||||
|
||||
function onSort(e) {
|
||||
sort.field = e.sortField
|
||||
sort.order = e.sortOrder
|
||||
const r = getPatientsRoutes()
|
||||
return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(row.id))
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
@@ -1188,7 +1218,7 @@ function confirmDeleteOne(row) {
|
||||
const nome = row?.nome_completo || 'este paciente'
|
||||
confirm.require({
|
||||
header: 'Excluir paciente',
|
||||
message: `Tem certeza que deseja excluir “${nome}”?`,
|
||||
message: `Tem certeza que deseja excluir “${nome}"?`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
@@ -1237,17 +1267,65 @@ function updateKpis() {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card :deep(.p-card-body) {
|
||||
padding: 1rem;
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.pat-sentinel { height: 1px; }
|
||||
|
||||
.pat-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.pat-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.15s ease;
|
||||
/* Blobs */
|
||||
.pat-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.pat-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.pat-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.pat-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
.pat-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 30%; background: rgba(236,72,153,0.07); }
|
||||
|
||||
/* Linha 1 */
|
||||
.pat-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.pat-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.pat-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.pat-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.pat-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.pat-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.pat-hero__divider,
|
||||
.pat-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
/* KPI card */
|
||||
.kpi-card :deep(.p-card-body) { padding: 1rem; }
|
||||
|
||||
/* Fade */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
@@ -16,8 +18,17 @@ const confirm = useConfirm()
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
/**
|
||||
* ✅ NOTAS IMPORTANTES DO AJUSTE
|
||||
* - Corrige o 404 (admin usa /pacientes..., therapist usa /patients...).
|
||||
* - Depois de criar (insert), faz upload do avatar usando o ID recém-criado.
|
||||
* - Se bucket não for público, troca para signed URL automaticamente (fallback).
|
||||
*/
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Tenant helpers
|
||||
// ------------------------------------------------------
|
||||
async function getCurrentTenantId () {
|
||||
// ajuste para o nome real no seu store
|
||||
return tenantStore.tenantId || tenantStore.currentTenantId || tenantStore.tenant?.id
|
||||
}
|
||||
|
||||
@@ -105,6 +116,48 @@ onBeforeUnmount(() => {
|
||||
const patientId = computed(() => String(route.params?.id || '').trim() || null)
|
||||
const isEdit = computed(() => !!patientId.value)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// ✅ FIX 404: base por área + rotas reais (admin: /pacientes | therapist: /patients)
|
||||
// ------------------------------------------------------
|
||||
function getAreaKey () {
|
||||
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
|
||||
return seg === 'therapist' ? 'therapist' : 'admin'
|
||||
}
|
||||
|
||||
function getPatientsRoutes () {
|
||||
const area = getAreaKey()
|
||||
|
||||
if (area === 'therapist') {
|
||||
return {
|
||||
listName: 'therapist-patients',
|
||||
editName: 'therapist-patients-edit',
|
||||
listPath: '/therapist/patients',
|
||||
editPath: (id) => `/therapist/patients/cadastro/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
listName: 'admin-pacientes',
|
||||
editName: 'admin-pacientes-cadastro-edit',
|
||||
listPath: '/admin/pacientes',
|
||||
editPath: (id) => `/admin/pacientes/cadastro/${id}`
|
||||
}
|
||||
}
|
||||
|
||||
async function safePush (toNameObj, fallbackPath) {
|
||||
try {
|
||||
const r = router.resolve(toNameObj)
|
||||
if (r?.matched?.length) return router.push(toNameObj)
|
||||
} catch (_) {}
|
||||
return router.push(fallbackPath)
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
const { listName, listPath } = getPatientsRoutes()
|
||||
if (window.history.length > 1) router.back()
|
||||
else safePush({ name: listName }, listPath)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Avatar state (TEM que existir no setup)
|
||||
// ------------------------------------------------------
|
||||
@@ -112,7 +165,7 @@ const avatarFile = ref(null)
|
||||
const avatarPreviewUrl = ref('')
|
||||
const avatarUploading = ref(false)
|
||||
|
||||
const AVATAR_BUCKET = 'avatars' // confirme o nome do bucket no Supabase
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
function isImageFile (file) {
|
||||
return !!file && typeof file.type === 'string' && file.type.startsWith('image/')
|
||||
@@ -149,6 +202,24 @@ function onAvatarPicked (ev) {
|
||||
toast.add({ severity: 'info', summary: 'Avatar', detail: 'Preview carregado. Clique em “Salvar” para enviar.', life: 2500 })
|
||||
}
|
||||
|
||||
// ✅ Gera URL pública OU signed URL (se o bucket for privado)
|
||||
async function getReadableAvatarUrl (path) {
|
||||
// tenta público primeiro
|
||||
try {
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
const publicUrl = pub?.publicUrl || null
|
||||
if (publicUrl) return publicUrl
|
||||
} catch (_) {}
|
||||
|
||||
// fallback: signed (bucket privado)
|
||||
const { data, error } = await supabase.storage
|
||||
.from(AVATAR_BUCKET)
|
||||
.createSignedUrl(path, 60 * 60 * 24 * 7) // 7 dias
|
||||
if (error) throw error
|
||||
if (!data?.signedUrl) throw new Error('Não consegui gerar signed URL do avatar.')
|
||||
return data.signedUrl
|
||||
}
|
||||
|
||||
async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
|
||||
if (!ownerId) throw new Error('ownerId ausente.')
|
||||
if (!patientId) throw new Error('patientId ausente.')
|
||||
@@ -171,15 +242,12 @@ async function uploadAvatarToStorage ({ ownerId, patientId, file }) {
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
const { data: pub } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
const publicUrl = pub?.publicUrl || null
|
||||
if (!publicUrl) throw new Error('Não consegui gerar URL pública do avatar.')
|
||||
|
||||
return { publicUrl, path }
|
||||
const readableUrl = await getReadableAvatarUrl(path)
|
||||
return { publicUrl: readableUrl, path }
|
||||
}
|
||||
|
||||
async function maybeUploadAvatar (ownerId, id) {
|
||||
if (!avatarFile.value) return
|
||||
if (!avatarFile.value) return null
|
||||
|
||||
avatarUploading.value = true
|
||||
try {
|
||||
@@ -189,45 +257,40 @@ async function maybeUploadAvatar (ownerId, id) {
|
||||
file: avatarFile.value
|
||||
})
|
||||
|
||||
// 1) atualiza UI IMEDIATAMENTE (não deixa “sumir”)
|
||||
// UI
|
||||
form.value.avatar_url = publicUrl
|
||||
|
||||
// 2) grava no banco
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
|
||||
// 3) limpa o arquivo selecionado
|
||||
avatarFile.value = null
|
||||
|
||||
// 4) se o preview era blob, pode revogar
|
||||
// MAS NÃO zere o avatarPreviewUrl se o template depende dele
|
||||
// => aqui vamos só revogar e então setar para a própria URL pública.
|
||||
revokePreview()
|
||||
avatarPreviewUrl.value = publicUrl
|
||||
|
||||
// DB
|
||||
await updatePatient(id, { avatar_url: publicUrl })
|
||||
|
||||
return publicUrl
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Avatar',
|
||||
detail: e?.message || 'Falha ao enviar avatar.',
|
||||
life: 4000
|
||||
life: 4500
|
||||
})
|
||||
return null
|
||||
} finally {
|
||||
avatarUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Form state (PT-BR)
|
||||
// Form state
|
||||
// ------------------------------------------------------
|
||||
function resetForm () {
|
||||
return {
|
||||
// Sessão 1 — pessoais
|
||||
nome_completo: '',
|
||||
telefone: '',
|
||||
email_principal: '',
|
||||
email_alternativo: '',
|
||||
telefone_alternativo: '',
|
||||
data_nascimento: '', // ✅ SEMPRE DD-MM-AAAA (hífen)
|
||||
data_nascimento: '',
|
||||
genero: '',
|
||||
estado_civil: '',
|
||||
cpf: '',
|
||||
@@ -237,7 +300,6 @@ function resetForm () {
|
||||
onde_nos_conheceu: '',
|
||||
encaminhado_por: '',
|
||||
|
||||
// Sessão 2 — endereço
|
||||
cep: '',
|
||||
pais: 'Brasil',
|
||||
cidade: '',
|
||||
@@ -247,24 +309,19 @@ function resetForm () {
|
||||
bairro: '',
|
||||
complemento: '',
|
||||
|
||||
// Sessão 3 — adicionais
|
||||
escolaridade: '',
|
||||
profissao: '',
|
||||
nome_parente: '',
|
||||
grau_parentesco: '',
|
||||
telefone_parente: '',
|
||||
|
||||
// Sessão 4 — responsável
|
||||
nome_responsavel: '',
|
||||
cpf_responsavel: '',
|
||||
telefone_responsavel: '',
|
||||
observacao_responsavel: '',
|
||||
cobranca_no_responsavel: false,
|
||||
|
||||
// Sessão 5 — internos
|
||||
notas_internas: '',
|
||||
|
||||
// Avatar
|
||||
avatar_url: ''
|
||||
}
|
||||
}
|
||||
@@ -331,7 +388,6 @@ function toISODateFromDDMMYYYY (s) {
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// banco (YYYY-MM-DD ou ISO) -> form (DD-MM-YYYY)
|
||||
function isoToDDMMYYYY (value) {
|
||||
if (!value) return ''
|
||||
const s = String(value).trim()
|
||||
@@ -407,7 +463,6 @@ function mapDbToForm (p) {
|
||||
cobranca_no_responsavel: !!p.cobranca_no_responsavel,
|
||||
|
||||
notas_internas: p.notas_internas ?? '',
|
||||
|
||||
avatar_url: p.avatar_url ?? ''
|
||||
}
|
||||
}
|
||||
@@ -430,8 +485,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'owner_id',
|
||||
'tenant_id',
|
||||
'responsible_member_id',
|
||||
|
||||
// Sessão 1
|
||||
'nome_completo',
|
||||
'telefone',
|
||||
'email_principal',
|
||||
@@ -446,8 +499,6 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'observacoes',
|
||||
'onde_nos_conheceu',
|
||||
'encaminhado_por',
|
||||
|
||||
// Sessão 2
|
||||
'pais',
|
||||
'cep',
|
||||
'cidade',
|
||||
@@ -456,25 +507,17 @@ const PACIENTES_COLUNAS_PERMITIDAS = new Set([
|
||||
'numero',
|
||||
'bairro',
|
||||
'complemento',
|
||||
|
||||
// Sessão 3
|
||||
'escolaridade',
|
||||
'profissao',
|
||||
'nome_parente',
|
||||
'grau_parentesco',
|
||||
'telefone_parente',
|
||||
|
||||
// Sessão 4
|
||||
'nome_responsavel',
|
||||
'cpf_responsavel',
|
||||
'telefone_responsavel',
|
||||
'observacao_responsavel',
|
||||
'cobranca_no_responsavel',
|
||||
|
||||
// Sessão 5
|
||||
'notas_internas',
|
||||
|
||||
// Avatar
|
||||
'avatar_url'
|
||||
])
|
||||
|
||||
@@ -523,11 +566,10 @@ function sanitizePayload (raw, ownerId) {
|
||||
cobranca_no_responsavel: !!raw.cobranca_no_responsavel,
|
||||
|
||||
notas_internas: raw.notas_internas || null,
|
||||
|
||||
avatar_url: raw.avatar_url || null
|
||||
}
|
||||
|
||||
// strings vazias -> null
|
||||
// strings vazias -> null e trim
|
||||
Object.keys(payload).forEach(k => {
|
||||
if (payload[k] === '') payload[k] = null
|
||||
if (typeof payload[k] === 'string') {
|
||||
@@ -536,23 +578,19 @@ function sanitizePayload (raw, ownerId) {
|
||||
}
|
||||
})
|
||||
|
||||
// docs: só dígitos
|
||||
payload.cpf = payload.cpf ? digitsOnly(payload.cpf) : null
|
||||
payload.rg = payload.rg ? digitsOnly(payload.rg) : null
|
||||
payload.cpf_responsavel = payload.cpf_responsavel ? digitsOnly(payload.cpf_responsavel) : null
|
||||
|
||||
// fones: só dígitos
|
||||
payload.telefone = payload.telefone ? digitsOnly(payload.telefone) : null
|
||||
payload.telefone_alternativo = payload.telefone_alternativo ? digitsOnly(payload.telefone_alternativo) : null
|
||||
payload.telefone_parente = payload.telefone_parente ? digitsOnly(payload.telefone_parente) : null
|
||||
payload.telefone_responsavel = payload.telefone_responsavel ? digitsOnly(payload.telefone_responsavel) : null
|
||||
|
||||
// ✅ FIX CRÍTICO: DD-MM-YYYY -> YYYY-MM-DD
|
||||
payload.data_nascimento = payload.data_nascimento
|
||||
? (toISODateFromDDMMYYYY(payload.data_nascimento) || null)
|
||||
: null
|
||||
|
||||
// filtra
|
||||
const filtrado = {}
|
||||
Object.keys(payload).forEach(k => {
|
||||
if (PACIENTES_COLUNAS_PERMITIDAS.has(k)) filtrado[k] = payload[k]
|
||||
@@ -565,11 +603,7 @@ function sanitizePayload (raw, ownerId) {
|
||||
// Supabase: lists / get / relations
|
||||
// ------------------------------------------------------
|
||||
async function listGroups () {
|
||||
const probe = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
const probe = await supabase.from('patient_groups').select('*').limit(1)
|
||||
if (probe.error) throw probe.error
|
||||
|
||||
const row = probe.data?.[0] || {}
|
||||
@@ -582,13 +616,8 @@ async function listGroups () {
|
||||
.select('id,nome,descricao,cor,is_system,is_active')
|
||||
.eq('is_active', true)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []).map(g => ({
|
||||
...g,
|
||||
name: g.nome,
|
||||
color: g.cor
|
||||
}))
|
||||
return (data || []).map(g => ({ ...g, name: g.nome, color: g.cor }))
|
||||
}
|
||||
|
||||
if (hasEN) {
|
||||
@@ -597,93 +626,42 @@ async function listGroups () {
|
||||
.select('id,name,description,color,is_system,is_active')
|
||||
.eq('is_active', true)
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return (data || []).map(g => ({
|
||||
...g,
|
||||
nome: g.name,
|
||||
cor: g.color
|
||||
}))
|
||||
return (data || []).map(g => ({ ...g, nome: g.name, cor: g.color }))
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_groups').select('*').order('id', { ascending: true })
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
|
||||
async function listTags () {
|
||||
// 1) Pega 1 registro sem order, só pra descobrir o schema real (sem 400)
|
||||
const probe = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.limit(1)
|
||||
|
||||
const probe = await supabase.from('patient_tags').select('*').limit(1)
|
||||
if (probe.error) throw probe.error
|
||||
|
||||
const row = probe.data?.[0] || {}
|
||||
const hasEN = ('name' in row) || ('color' in row)
|
||||
const hasPT = ('nome' in row) || ('cor' in row)
|
||||
|
||||
// 2) Se não tem nada, a tabela pode estar vazia.
|
||||
// Ainda assim, precisamos decidir por qual coluna ordenar.
|
||||
// Vamos descobrir colunas existentes via select de 0 rows (head) NÃO é suportado bem no client,
|
||||
// então usamos uma estratégia safe:
|
||||
// - tenta EN com order se faz sentido
|
||||
// - senão PT
|
||||
// - e por último sem order.
|
||||
|
||||
if (hasEN) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id,name,color')
|
||||
.order('name', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_tags').select('id,name,color').order('name', { ascending: true })
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
if (hasPT) {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id,nome,cor')
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_tags').select('id,nome,cor').order('nome', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
return (data || []).map(t => ({
|
||||
...t,
|
||||
name: t.nome,
|
||||
color: t.cor
|
||||
}))
|
||||
return (data || []).map(t => ({ ...t, name: t.nome, color: t.cor }))
|
||||
}
|
||||
|
||||
// 3) fallback final: tabela vazia ou schema incomum
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.select('*')
|
||||
.order('id', { ascending: true })
|
||||
|
||||
const { data, error } = await supabase.from('patient_tags').select('*').order('id', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
return (data || []).map(t => ({
|
||||
...t,
|
||||
name: t.name ?? t.nome ?? '',
|
||||
color: t.color ?? t.cor ?? null
|
||||
}))
|
||||
return (data || []).map(t => ({ ...t, name: t.name ?? t.nome ?? '', color: t.color ?? t.cor ?? null }))
|
||||
}
|
||||
|
||||
|
||||
async function getPatientById (id) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
const { data, error } = await supabase.from('patients').select('*').eq('id', id).single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
@@ -708,11 +686,7 @@ async function getPatientRelations (id) {
|
||||
}
|
||||
|
||||
async function createPatient (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('patients')
|
||||
.insert(payload)
|
||||
.select('id')
|
||||
.single()
|
||||
const { data, error } = await supabase.from('patients').insert(payload).select('id').single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
@@ -720,17 +694,14 @@ async function createPatient (payload) {
|
||||
async function updatePatient (id, payload) {
|
||||
const { error } = await supabase
|
||||
.from('patients')
|
||||
.update({
|
||||
...payload,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.update({ ...payload, updated_at: new Date().toISOString() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Relations
|
||||
// Relations state
|
||||
// ------------------------------------------------------
|
||||
const groups = ref([])
|
||||
const tags = ref([])
|
||||
@@ -738,17 +709,11 @@ const grupoIdSelecionado = ref(null)
|
||||
const tagIdsSelecionadas = ref([])
|
||||
|
||||
async function replacePatientGroups (patient_id, groupId) {
|
||||
const { error: delErr } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.delete()
|
||||
.eq('patient_id', patient_id)
|
||||
const { error: delErr } = await supabase.from('patient_group_patient').delete().eq('patient_id', patient_id)
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (!groupId) return
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.insert({ patient_id, patient_group_id: groupId })
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const { error: insErr } = await supabase.from('patient_group_patient').insert({ patient_id, patient_group_id: groupId, tenant_id: tenantId })
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
@@ -765,15 +730,9 @@ async function replacePatientTags (patient_id, tagIds) {
|
||||
const clean = Array.from(new Set([...(tagIds || [])].filter(Boolean)))
|
||||
if (!clean.length) return
|
||||
|
||||
const rows = clean.map(tag_id => ({
|
||||
owner_id: ownerId,
|
||||
patient_id,
|
||||
tag_id
|
||||
}))
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.insert(rows)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
const rows = clean.map(tag_id => ({ owner_id: ownerId, patient_id, tag_id, tenant_id: tenantId }))
|
||||
const { error: insErr } = await supabase.from('patient_patient_tag').insert(rows)
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
@@ -808,19 +767,6 @@ const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const deleting = ref(false)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Route base (admin x therapist)
|
||||
// ------------------------------------------------------
|
||||
function getAreaBase () {
|
||||
const seg = String(route.path || '').split('/')[1]
|
||||
return seg === 'therapist' ? '/therapist' : '/admin'
|
||||
}
|
||||
|
||||
function goBack () {
|
||||
if (window.history.length > 1) router.back()
|
||||
else router.push(`${getAreaBase()}/patients`)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Fetch (load everything)
|
||||
// ------------------------------------------------------
|
||||
@@ -829,34 +775,32 @@ async function fetchAll () {
|
||||
try {
|
||||
const [gRes, tRes] = await Promise.allSettled([listGroups(), listTags()])
|
||||
|
||||
if (gRes.status === 'fulfilled') {
|
||||
groups.value = gRes.value || []
|
||||
} else {
|
||||
if (gRes.status === 'fulfilled') groups.value = gRes.value || []
|
||||
else {
|
||||
groups.value = []
|
||||
console.warn('[listGroups error]', gRes.reason)
|
||||
toast.add({ severity: 'warn', summary: 'Grupos', detail: gRes.reason?.message || 'Falha ao carregar grupos', life: 3500 })
|
||||
}
|
||||
|
||||
if (tRes.status === 'fulfilled') {
|
||||
tags.value = tRes.value || []
|
||||
} else {
|
||||
if (tRes.status === 'fulfilled') tags.value = tRes.value || []
|
||||
else {
|
||||
tags.value = []
|
||||
console.warn('[listTags error]', tRes.reason)
|
||||
toast.add({ severity: 'warn', summary: 'Tags', detail: tRes.reason?.message || 'Falha ao carregar tags', life: 3500 })
|
||||
}
|
||||
|
||||
console.log('[groups]', groups.value.length, groups.value[0])
|
||||
console.log('[tags]', tags.value.length, tags.value[0])
|
||||
|
||||
if (isEdit.value) {
|
||||
const p = await getPatientById(patientId.value)
|
||||
form.value = mapDbToForm(p)
|
||||
|
||||
// se já tinha avatar no banco, garante preview
|
||||
avatarPreviewUrl.value = form.value.avatar_url || ''
|
||||
|
||||
const rel = await getPatientRelations(patientId.value)
|
||||
grupoIdSelecionado.value = rel.groupIds?.[0] || null
|
||||
tagIdsSelecionadas.value = rel.tagIds || []
|
||||
} else {
|
||||
form.value = resetForm()
|
||||
//form.value = resetForm()
|
||||
grupoIdSelecionado.value = null
|
||||
tagIdsSelecionadas.value = []
|
||||
avatarFile.value = null
|
||||
@@ -872,19 +816,33 @@ async function fetchAll () {
|
||||
watch(() => route.params?.id, fetchAll, { immediate: true })
|
||||
onMounted(fetchAll)
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Tenant resolve (robusto)
|
||||
// ------------------------------------------------------
|
||||
async function resolveTenantContextOrFail () {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser()
|
||||
if (authError) throw authError
|
||||
const uid = authData?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
|
||||
// 1) tenta pelo store
|
||||
const storeTid = await getCurrentTenantId()
|
||||
if (storeTid) {
|
||||
try {
|
||||
const mid = await getCurrentMemberId(storeTid)
|
||||
return { tenantId: storeTid, memberId: mid }
|
||||
} catch (_) {
|
||||
// cai pro fallback (último membership active)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) fallback
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id, tenant_id')
|
||||
.eq('user_id', uid)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false }) // se existir
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
@@ -904,20 +862,69 @@ async function onSubmit () {
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
|
||||
// depois do sanitize
|
||||
const payload = sanitizePayload(form.value, ownerId)
|
||||
|
||||
// multi-tenant obrigatório
|
||||
payload.tenant_id = tenantId
|
||||
payload.responsible_member_id = memberId
|
||||
|
||||
// ✅ validações mínimas (NÃO DEIXA CHEGAR NO BANCO)
|
||||
const nome = String(form.value?.nome_completo || '').trim()
|
||||
if (!nome) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Nome obrigatório',
|
||||
detail: 'Preencha “Nome completo” para salvar o paciente.',
|
||||
life: 3500
|
||||
})
|
||||
|
||||
// abre o painel certo (você já tem navItems: "Informações pessoais" é o 0)
|
||||
await openPanel(0)
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// EDIT
|
||||
// ---------------------------
|
||||
if (isEdit.value) {
|
||||
await updatePatient(patientId.value, payload)
|
||||
|
||||
// ✅ Se houver avatar selecionado, sobe e grava avatar_url
|
||||
await maybeUploadAvatar(ownerId, patientId.value)
|
||||
|
||||
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
|
||||
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
|
||||
} else {
|
||||
const created = await createPatient(payload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
// opcional: router.push(`${getAreaBase()}/patients/${created.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
// ---------------------------
|
||||
// CREATE
|
||||
// ---------------------------
|
||||
const created = await createPatient(payload)
|
||||
|
||||
// ✅ upload do avatar usando ID recém-criado
|
||||
await maybeUploadAvatar(ownerId, created.id)
|
||||
|
||||
await replacePatientGroups(created.id, grupoIdSelecionado.value)
|
||||
await replacePatientTags(created.id, tagIdsSelecionadas.value)
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
|
||||
|
||||
// ✅ NÃO navega para /cadastro/:id (fica em /admin/pacientes/cadastro)
|
||||
// Em vez disso, reseta o formulário para novo cadastro:
|
||||
form.value = resetForm()
|
||||
grupoIdSelecionado.value = null
|
||||
tagIdsSelecionadas.value = []
|
||||
|
||||
avatarFile.value = null
|
||||
revokePreview()
|
||||
avatarPreviewUrl.value = ''
|
||||
|
||||
// volta pro primeiro painel (UX boa)
|
||||
await openPanel(0)
|
||||
|
||||
return
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar paciente.', life: 4000 })
|
||||
@@ -925,8 +932,6 @@ async function onSubmit () {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Delete
|
||||
// ------------------------------------------------------
|
||||
@@ -968,7 +973,7 @@ async function doDelete () {
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Fake fill (opcional)
|
||||
// Fake fill (opcional) — mantive como você tinha
|
||||
// ------------------------------------------------------
|
||||
function randInt (min, max) { return Math.floor(Math.random() * (max - min + 1)) + min }
|
||||
function pick (arr) { return arr[randInt(0, arr.length - 1)] }
|
||||
@@ -1036,7 +1041,6 @@ function fillRandomPatient () {
|
||||
|
||||
form.value = {
|
||||
...resetForm(),
|
||||
|
||||
nome_completo: nomeCompleto,
|
||||
telefone: randomPhoneBR(),
|
||||
email_principal: randomEmailFromName(nomeCompleto),
|
||||
@@ -1076,16 +1080,10 @@ function fillRandomPatient () {
|
||||
cobranca_no_responsavel: true,
|
||||
|
||||
notas_internas: 'Paciente apresenta discurso organizado. Acompanhar evolução clínica.',
|
||||
|
||||
avatar_url: ''
|
||||
}
|
||||
|
||||
// Grupo
|
||||
if (Array.isArray(groups.value) && groups.value.length) {
|
||||
grupoIdSelecionado.value = pick(groups.value).id
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (Array.isArray(groups.value) && groups.value.length) grupoIdSelecionado.value = pick(groups.value).id
|
||||
if (Array.isArray(tags.value) && tags.value.length) {
|
||||
const shuffled = [...tags.value].sort(() => Math.random() - 0.5)
|
||||
tagIdsSelecionadas.value = shuffled.slice(0, randInt(1, Math.min(3, tags.value.length))).map(t => t.id)
|
||||
@@ -1118,137 +1116,88 @@ const maritalStatusOptions = [
|
||||
const createGroupDialog = ref(false)
|
||||
const createGroupSaving = ref(false)
|
||||
const createGroupError = ref('')
|
||||
const newGroup = ref({ name: '', color: '#6366F1' }) // indigo default
|
||||
const newGroup = ref({ name: '', color: '#6366F1' })
|
||||
|
||||
const createTagDialog = ref(false)
|
||||
const createTagSaving = ref(false)
|
||||
const createTagError = ref('')
|
||||
const newTag = ref({ name: '', color: '#22C55E' }) // green default
|
||||
const newTag = ref({ name: '', color: '#22C55E' })
|
||||
|
||||
function openGroupDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
function openGroupDlg () {
|
||||
createGroupError.value = ''
|
||||
newGroup.value = { name: '', color: '#6366F1' }
|
||||
createGroupDialog.value = true
|
||||
}
|
||||
|
||||
function openTagDlg(mode = 'create') {
|
||||
// por enquanto só create
|
||||
function openTagDlg () {
|
||||
createTagError.value = ''
|
||||
newTag.value = { name: '', color: '#22C55E' }
|
||||
createTagDialog.value = true
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Grupo
|
||||
// ------------------------------------------------------
|
||||
async function createGroupPersist() {
|
||||
async function createGroupPersist () {
|
||||
if (createGroupSaving.value) return
|
||||
createGroupError.value = ''
|
||||
|
||||
const name = String(newGroup.value?.name || '').trim()
|
||||
const color = String(newGroup.value?.color || '').trim() || '#6366F1'
|
||||
|
||||
if (!name) {
|
||||
createGroupError.value = 'Informe um nome para o grupo.'
|
||||
return
|
||||
}
|
||||
if (!name) { createGroupError.value = 'Informe um nome para o grupo.'; return }
|
||||
|
||||
createGroupSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// Tenta schema PT-BR primeiro (pelo teu listGroups)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
let createdId = null
|
||||
{
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
nome: name,
|
||||
descricao: null,
|
||||
cor: color,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!error) createdId = data?.id || null
|
||||
else {
|
||||
// fallback (caso seu schema seja EN)
|
||||
const { data: d2, error: e2 } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({
|
||||
owner_id: ownerId,
|
||||
name,
|
||||
description: null,
|
||||
color,
|
||||
is_system: false,
|
||||
is_active: true
|
||||
})
|
||||
.select('id')
|
||||
.single()
|
||||
if (e2) throw e2
|
||||
createdId = d2?.id || null
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('patient_groups')
|
||||
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, descricao: null, cor: color, is_system: false, is_active: true })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
createdId = data?.id || null
|
||||
|
||||
// Recarrega lista e seleciona o novo
|
||||
groups.value = await listGroups()
|
||||
if (createdId) grupoIdSelecionado.value = createdId
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Grupo', detail: 'Grupo criado.', life: 2500 })
|
||||
createGroupDialog.value = false
|
||||
} catch (e) {
|
||||
createGroupError.value = e?.message || 'Falha ao criar grupo.'
|
||||
const msg = e?.message || ''
|
||||
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
|
||||
createGroupError.value = 'Já existe um grupo com esse nome.'
|
||||
} else {
|
||||
createGroupError.value = msg || 'Falha ao criar grupo.'
|
||||
}
|
||||
} finally {
|
||||
createGroupSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Persist: Tag
|
||||
// ------------------------------------------------------
|
||||
async function createTagPersist() {
|
||||
async function createTagPersist () {
|
||||
if (createTagSaving.value) return
|
||||
createTagError.value = ''
|
||||
|
||||
const name = String(newTag.value?.name || '').trim()
|
||||
const color = String(newTag.value?.color || '').trim() || '#22C55E'
|
||||
|
||||
if (!name) {
|
||||
createTagError.value = 'Informe um nome para a tag.'
|
||||
return
|
||||
}
|
||||
if (!name) { createTagError.value = 'Informe um nome para a tag.'; return }
|
||||
|
||||
createTagSaving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// Tenta schema EN primeiro (pelo teu listTags)
|
||||
const { tenantId } = await resolveTenantContextOrFail()
|
||||
let createdId = null
|
||||
{
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, name, color })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (!error) createdId = data?.id || null
|
||||
else {
|
||||
// fallback PT-BR
|
||||
const { data: d2, error: e2 } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, nome: name, cor: color })
|
||||
.select('id')
|
||||
.single()
|
||||
if (e2) throw e2
|
||||
createdId = d2?.id || null
|
||||
}
|
||||
}
|
||||
const { data, error } = await supabase
|
||||
.from('patient_tags')
|
||||
.insert({ owner_id: ownerId, tenant_id: tenantId, nome: name, cor: color })
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
createdId = data?.id || null
|
||||
|
||||
// Recarrega lista e já marca a nova na seleção
|
||||
tags.value = await listTags()
|
||||
if (createdId) {
|
||||
const set = new Set([...(tagIdsSelecionadas.value || []), createdId])
|
||||
@@ -1258,7 +1207,12 @@ async function createTagPersist() {
|
||||
toast.add({ severity: 'success', summary: 'Tag', detail: 'Tag criada.', life: 2500 })
|
||||
createTagDialog.value = false
|
||||
} catch (e) {
|
||||
createTagError.value = e?.message || 'Falha ao criar tag.'
|
||||
const msg = e?.message || ''
|
||||
if (e?.code === '23505' || /duplicate key value/i.test(msg)) {
|
||||
createTagError.value = 'Já existe uma tag com esse nome.'
|
||||
} else {
|
||||
createTagError.value = msg || 'Falha ao criar tag.'
|
||||
}
|
||||
} finally {
|
||||
createTagSaving.value = false
|
||||
}
|
||||
@@ -1285,11 +1239,12 @@ async function createTagPersist() {
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
icon="pi pi-bolt"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="fillRandomPatient"
|
||||
@click="fillRandomPatient"
|
||||
/>
|
||||
<Button
|
||||
label="Voltar"
|
||||
@@ -1931,6 +1886,7 @@ async function createTagPersist() {
|
||||
<Dialog
|
||||
v-model:visible="createGroupDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar grupo"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createGroupSaving"
|
||||
@@ -1965,6 +1921,7 @@ async function createTagPersist() {
|
||||
<Dialog
|
||||
v-model:visible="createTagDialog"
|
||||
modal
|
||||
:draggable="false"
|
||||
header="Criar tag"
|
||||
:style="{ width: '26rem' }"
|
||||
:closable="!createTagSaving"
|
||||
|
||||
@@ -1,249 +1,240 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- Top header -->
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="h-10 w-10 rounded-2xl bg-slate-900 text-slate-50 grid place-items-center shadow-sm">
|
||||
<i class="pi pi-link text-lg"></i>
|
||||
</div>
|
||||
<Toast />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-2xl font-semibold text-slate-900 leading-tight">
|
||||
Cadastro Externo
|
||||
</div>
|
||||
<div class="text-slate-600 mt-1">
|
||||
Gere um link para o paciente preencher o pré-cadastro com calma e segurança.
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="extlink-sentinel" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
|
||||
<div class="extlink-hero__blobs" aria-hidden="true">
|
||||
<div class="extlink-hero__blob extlink-hero__blob--1" />
|
||||
<div class="extlink-hero__blob extlink-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Row 1 -->
|
||||
<div class="extlink-hero__row1">
|
||||
<div class="extlink-hero__brand">
|
||||
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="extlink-hero__title">Link de Cadastro</div>
|
||||
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border transition-colors"
|
||||
:class="inviteToken
|
||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
|
||||
/>
|
||||
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
|
||||
</span>
|
||||
<Button
|
||||
label="Gerar novo link"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:loading="rotating"
|
||||
@click="rotateLink"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main grid -->
|
||||
<div class="mt-5 grid grid-cols-1 lg:grid-cols-12 gap-4">
|
||||
<!-- Left: Link card -->
|
||||
<div class="lg:col-span-7">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<!-- Card head -->
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<!-- Divider -->
|
||||
<Divider class="extlink-hero__divider my-2" />
|
||||
|
||||
<!-- Row 2: link rápido (oculto no mobile) -->
|
||||
<div class="extlink-hero__row2">
|
||||
<div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-spin pi-spinner text-xs" /> Gerando link…
|
||||
</div>
|
||||
<InputGroup v-else class="max-w-2xl">
|
||||
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
||||
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
|
||||
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
|
||||
|
||||
<!-- Esquerda: ações do link -->
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-4">
|
||||
|
||||
<!-- Card principal: link -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border"
|
||||
:class="inviteToken
|
||||
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
|
||||
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<span class="h-2 w-2 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
||||
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<!-- Skeleton -->
|
||||
<div v-if="!inviteToken" class="space-y-3">
|
||||
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
|
||||
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Link com ações -->
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
|
||||
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
|
||||
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
|
||||
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
|
||||
</InputGroup>
|
||||
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
|
||||
</div>
|
||||
|
||||
<!-- CTAs rápidas -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<button class="extlink-cta-btn" @click="copyLink">
|
||||
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
|
||||
<i class="pi pi-copy" />
|
||||
</div>
|
||||
<div class="text-left min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button class="extlink-cta-btn" @click="copyInviteMessage">
|
||||
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-comment" />
|
||||
</div>
|
||||
<div class="text-left min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Aviso -->
|
||||
<Message severity="warn" :closable="false">
|
||||
<b>Dica:</b> ao gerar um novo link, o anterior é revogado. Use isso quando quiser invalidar um link já compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagem pronta -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
|
||||
<i class="pi pi-comment text-sm text-[var(--text-color-secondary)]" />
|
||||
Mensagem pronta para envio
|
||||
</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
|
||||
<div class="rounded-xl bg-[var(--surface-ground)] border border-[var(--surface-border)] p-4 text-sm text-[var(--text-color)] leading-relaxed">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono text-xs break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Direita: instruções -->
|
||||
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
|
||||
|
||||
<!-- Como funciona -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div class="p-5 border-b border-[var(--surface-border)]">
|
||||
<div class="font-semibold text-[var(--text-color)]">Como funciona</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
|
||||
</div>
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="extlink-step shrink-0">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg font-semibold text-slate-900">Seu link</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Envie este link ao paciente. Ele abre a página de cadastro externo.
|
||||
</div>
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden md:flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 text-xs px-2.5 py-1 rounded-full border"
|
||||
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-slate-200 text-slate-600 bg-slate-50'"
|
||||
>
|
||||
<span
|
||||
class="h-2 w-2 rounded-full"
|
||||
:class="inviteToken ? 'bg-emerald-500' : 'bg-slate-400'"
|
||||
></span>
|
||||
{{ inviteToken ? 'Ativo' : 'Gerando...' }}
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<div class="extlink-step shrink-0">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">O paciente preenche</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Campos opcionais podem ficar em branco. Menos fricção, mais adesão.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card content -->
|
||||
<div class="p-5">
|
||||
<!-- Skeleton while loading -->
|
||||
<div v-if="!inviteToken" class="space-y-3">
|
||||
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
|
||||
<div class="h-10 rounded-xl bg-slate-100 animate-pulse"></div>
|
||||
<Message severity="info" :closable="false">
|
||||
Gerando seu link...
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Link display + quick actions -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-sm font-medium text-slate-700">Link público</label>
|
||||
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-stretch">
|
||||
<div class="flex-1 min-w-0">
|
||||
<InputText
|
||||
readonly
|
||||
:value="publicUrl"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="mt-1 text-xs text-slate-500 break-words">
|
||||
Token: <span class="font-mono">{{ inviteToken }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 sm:flex-col sm:w-[140px]">
|
||||
<Button
|
||||
class="w-full"
|
||||
icon="pi pi-copy"
|
||||
label="Copiar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="copyLink"
|
||||
/>
|
||||
<Button
|
||||
class="w-full"
|
||||
icon="pi pi-external-link"
|
||||
label="Abrir"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<div class="extlink-step shrink-0">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.</div>
|
||||
</div>
|
||||
|
||||
<!-- Big CTA -->
|
||||
<div class="rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Envio rápido</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Copie e mande por WhatsApp / e-mail. O paciente preenche e você recebe o cadastro no sistema.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar link agora"
|
||||
class="md:shrink-0"
|
||||
@click="copyLink"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Safety note -->
|
||||
<Message severity="warn" :closable="false">
|
||||
<b>Dica:</b> ao gerar um novo link, o anterior deve deixar de funcionar. Use isso quando você quiser “revogar” um link que já foi compartilhado.
|
||||
</Message>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Concept / Instructions -->
|
||||
<div class="lg:col-span-5">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div class="p-5 border-b border-slate-200">
|
||||
<div class="text-lg font-semibold text-slate-900">Como funciona</div>
|
||||
<div class="text-slate-600 text-sm mt-1">
|
||||
Um fluxo simples, mas com cuidado clínico: menos fricção, mais adesão.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
<ol class="space-y-4">
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">1</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você envia o link</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Pode ser WhatsApp, e-mail ou mensagem direta. O link abre a página de cadastro externo.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">2</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">O paciente preenche</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Campos opcionais podem ser deixados em branco. A ideia é reduzir ansiedade e acelerar o início.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li class="flex gap-3">
|
||||
<div class="h-8 w-8 rounded-xl bg-slate-900 text-slate-50 grid place-items-center text-sm font-semibold">3</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-900">Você recebe no admin</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Os dados entram como “cadastro recebido”. Você revisa, completa e transforma em paciente quando quiser.
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-slate-50 p-4">
|
||||
<div class="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<i class="pi pi-shield text-slate-700"></i>
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="mt-2 space-y-2 text-sm text-slate-700">
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Gere um novo link se você suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Envie junto uma mensagem curta: “preencha com calma; campos opcionais podem ficar em branco”.</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<i class="pi pi-check text-emerald-600 mt-0.5"></i>
|
||||
<span>Evite divulgar em público; é um link pensado para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-slate-500">
|
||||
Se você quiser, eu deixo este card ainda mais “noir” (contraste, microtextos, ícones, sombras) sem perder legibilidade.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Small helper card -->
|
||||
<div class="mt-4 rounded-2xl border border-slate-200 bg-white shadow-sm p-5">
|
||||
<div class="font-semibold text-slate-900">Mensagem pronta (copiar/colar)</div>
|
||||
<div class="text-sm text-slate-600 mt-1">
|
||||
Se quiser, use este texto ao enviar o link:
|
||||
</div>
|
||||
|
||||
<div class="mt-3 rounded-xl bg-slate-50 border border-slate-200 p-3 text-sm text-slate-800">
|
||||
Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
<span class="block mt-2 font-mono break-words">{{ publicUrl || '…' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-copy"
|
||||
label="Copiar mensagem"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="!publicUrl"
|
||||
@click="copyInviteMessage"
|
||||
/>
|
||||
</div>
|
||||
<!-- Boas práticas -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
|
||||
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
|
||||
Boas práticas
|
||||
</div>
|
||||
<ul class="space-y-2.5">
|
||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
|
||||
</li>
|
||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>Informe o paciente que campos opcionais podem ficar em branco.</span>
|
||||
</li>
|
||||
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
|
||||
<span>Evite divulgar em público; é um link para compartilhamento individual.</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast is global in layout usually; if not, add <Toast /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
|
||||
import Message from 'primevue/message'
|
||||
import Menu from 'primevue/menu'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
@@ -252,12 +243,25 @@ const toast = useToast()
|
||||
const inviteToken = ref('')
|
||||
const rotating = ref(false)
|
||||
|
||||
/**
|
||||
* Se o cadastro externo estiver em outro domínio, fixe aqui:
|
||||
* ex.: const PUBLIC_BASE_URL = 'https://seusite.com'
|
||||
* se vazio, usa window.location.origin
|
||||
*/
|
||||
const PUBLIC_BASE_URL = '' // opcional
|
||||
// ── Hero sticky ────────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ────────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
|
||||
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
|
||||
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
|
||||
{ separator: true },
|
||||
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
|
||||
])
|
||||
|
||||
// ── URL base ────────────────────────────────────────────────
|
||||
const PUBLIC_BASE_URL = ''
|
||||
|
||||
const origin = computed(() => {
|
||||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
|
||||
@@ -269,12 +273,13 @@ const publicUrl = computed(() => {
|
||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
|
||||
function newToken () {
|
||||
// ── Token helpers ───────────────────────────────────────────
|
||||
function newToken() {
|
||||
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
|
||||
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||
}
|
||||
|
||||
async function requireUserId () {
|
||||
async function requireUserId() {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
@@ -282,7 +287,7 @@ async function requireUserId () {
|
||||
return uid
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite () {
|
||||
async function loadOrCreateInvite() {
|
||||
const uid = await requireUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
@@ -310,16 +315,14 @@ async function loadOrCreateInvite () {
|
||||
inviteToken.value = t
|
||||
}
|
||||
|
||||
async function rotateLink () {
|
||||
async function rotateLink() {
|
||||
rotating.value = true
|
||||
try {
|
||||
const uid = await requireUserId()
|
||||
const t = newToken()
|
||||
|
||||
// tenta RPC primeiro
|
||||
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
|
||||
if (rpc.error) {
|
||||
// fallback: desativa todos os ativos e cria um novo
|
||||
const { error: e1 } = await supabase
|
||||
.from('patient_invites')
|
||||
.update({ active: false, updated_at: new Date().toISOString() })
|
||||
@@ -334,7 +337,7 @@ async function rotateLink () {
|
||||
}
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado. O anterior foi revogado.', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
|
||||
} finally {
|
||||
@@ -342,40 +345,138 @@ async function rotateLink () {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink () {
|
||||
async function copyLink() {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
await navigator.clipboard.writeText(publicUrl.value)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado.', life: 1500 })
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 })
|
||||
} catch {
|
||||
// fallback clássico
|
||||
window.prompt('Copie o link:', publicUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openLink () {
|
||||
function openLink() {
|
||||
if (!publicUrl.value) return
|
||||
window.open(publicUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
async function copyInviteMessage () {
|
||||
async function copyInviteMessage() {
|
||||
try {
|
||||
if (!publicUrl.value) return
|
||||
const msg =
|
||||
`Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:
|
||||
${publicUrl.value}`
|
||||
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
|
||||
await navigator.clipboard.writeText(msg)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
|
||||
try {
|
||||
await loadOrCreateInvite()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Sentinel ─────────────────────────────────────── */
|
||||
.extlink-sentinel { height: 1px; }
|
||||
|
||||
/* ── Hero ─────────────────────────────────────────── */
|
||||
.extlink-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.extlink-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs decorativos */
|
||||
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
|
||||
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
|
||||
|
||||
/* Linha 1 */
|
||||
.extlink-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.extlink-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.extlink-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
/* Linha 2 */
|
||||
.extlink-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.extlink-hero__divider,
|
||||
.extlink-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
/* ── CTA button ───────────────────────────────────── */
|
||||
.extlink-cta-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.875rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
|
||||
text-align: left;
|
||||
}
|
||||
.extlink-cta-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.extlink-cta-btn:active { transform: translateY(0); }
|
||||
.extlink-cta-btn__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.25rem; height: 2.25rem;
|
||||
border-radius: 0.75rem; flex-shrink: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* ── Step numbers ─────────────────────────────────── */
|
||||
.extlink-step {
|
||||
display: grid; place-items: center;
|
||||
width: 2rem; height: 2rem;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.8rem; font-weight: 700;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Tag from 'primevue/tag'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import ConfirmDialog from 'primevue/confirmdialog'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Avatar from 'primevue/avatar'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { brToISO, isoToBR } from '@/utils/dateBR'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const converting = ref(false)
|
||||
const loading = ref(false)
|
||||
@@ -227,7 +224,7 @@ function fmtDate (iso) {
|
||||
return d.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
|
||||
// converte nascimento para ISO date (YYYY-MM-DD)
|
||||
function normalizeBirthToISO (v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
@@ -248,6 +245,34 @@ function normalizeBirthToISO (v) {
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Tenant + Responsible Member (para satisfazer trigger)
|
||||
// -----------------------------
|
||||
async function getTenantIdForConversion (item) {
|
||||
// intake NÃO tem tenant_id hoje, então usamos o contexto
|
||||
const fromStore =
|
||||
tenantStore?.activeTenantId ||
|
||||
tenantStore?.currentTenantId ||
|
||||
tenantStore?.tenantId ||
|
||||
tenantStore?.tenant?.id
|
||||
|
||||
return fromStore || null
|
||||
}
|
||||
|
||||
async function getResponsibleMemberId (tenantId, userId) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('user_id', userId)
|
||||
.eq('status', 'active')
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.id) throw new Error('Responsible member not found')
|
||||
return data.id
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Seções do modal
|
||||
// -----------------------------
|
||||
@@ -420,19 +445,19 @@ async function markRejected () {
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Converter
|
||||
// Converter (com tenant_id + responsible_member_id)
|
||||
// -----------------------------
|
||||
async function convertToPatient () {
|
||||
const item = dlg.value?.item
|
||||
if (!item?.id) return
|
||||
if (converting.value) return
|
||||
|
||||
// regra de negócio: só converte "new"
|
||||
if (item.status !== 'new') {
|
||||
// só bloqueia cadastros já convertidos
|
||||
if (item.status === 'converted') {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Só é possível converter cadastros com status "Novo".',
|
||||
detail: 'Este cadastro já foi convertido em paciente.',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
@@ -447,19 +472,27 @@ async function convertToPatient () {
|
||||
const ownerId = userData?.user?.id
|
||||
if (!ownerId) throw new Error('Sessão inválida.')
|
||||
|
||||
const tenantId = await getTenantIdForConversion(item)
|
||||
if (!tenantId) throw new Error('tenant_id is required')
|
||||
|
||||
const responsibleMemberId = await getResponsibleMemberId(tenantId, ownerId)
|
||||
|
||||
const cleanStr = (v) => {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : null
|
||||
}
|
||||
|
||||
const digitsOnly = (v) => {
|
||||
const d = String(v ?? '').replace(/\D/g, '')
|
||||
return d ? d : null
|
||||
}
|
||||
|
||||
// tenta reaproveitar avatar do intake (se vier url/path)
|
||||
// tenta reaproveitar avatar do intake
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
|
||||
|
||||
const patientPayload = {
|
||||
tenant_id: tenantId,
|
||||
responsible_member_id: responsibleMemberId,
|
||||
owner_id: ownerId,
|
||||
|
||||
// identificação/contato
|
||||
@@ -471,7 +504,7 @@ async function convertToPatient () {
|
||||
telefone_alternativo: digitsOnly(fTelAlt(item)),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)),
|
||||
naturalidade: cleanStr(fNaturalidade(item)),
|
||||
genero: cleanStr(fGenero(item)),
|
||||
estado_civil: cleanStr(fEstadoCivil(item)),
|
||||
@@ -520,6 +553,7 @@ async function convertToPatient () {
|
||||
const patientId = created?.id
|
||||
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
|
||||
|
||||
// ✅ intake é externo: não prenda por owner_id aqui
|
||||
const { error: upErr } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.update({
|
||||
@@ -528,7 +562,6 @@ async function convertToPatient () {
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
@@ -537,6 +570,7 @@ async function convertToPatient () {
|
||||
dlg.value.open = false
|
||||
await fetchIntakes()
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Falha ao converter',
|
||||
@@ -557,135 +591,125 @@ const totals = computed(() => {
|
||||
return { total, nNew, nConv, nRej }
|
||||
})
|
||||
|
||||
onMounted(fetchIntakes)
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
const recMobileMenuRef = ref(null)
|
||||
const recSearchDlgOpen = ref(false)
|
||||
|
||||
const recMobileMenuItems = computed(() => [
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { recSearchDlgOpen.value = true } },
|
||||
{ separator: true },
|
||||
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchIntakes() }
|
||||
])
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
fetchIntakes()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- HEADER -->
|
||||
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="relative px-5 py-5">
|
||||
<!-- faixa de cor -->
|
||||
<div class="pointer-events-none absolute inset-0 opacity-80">
|
||||
<div class="absolute -top-10 -right-12 h-40 w-40 rounded-full bg-emerald-400/20 blur-3xl" />
|
||||
<div class="absolute top-10 -left-16 h-44 w-44 rounded-full bg-indigo-400/20 blur-3xl" />
|
||||
</div>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="rec-sentinel" />
|
||||
|
||||
<div class="relative flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="grid h-11 w-11 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-lg"></i>
|
||||
</div>
|
||||
<!-- Hero sticky -->
|
||||
<div ref="headerEl" class="rec-hero mx-3 md:mx-5 mb-4" :class="{ 'rec-hero--stuck': headerStuck }">
|
||||
<div class="rec-hero__blobs" aria-hidden="true">
|
||||
<div class="rec-hero__blob rec-hero__blob--1" />
|
||||
<div class="rec-hero__blob rec-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-semibold leading-none">Cadastros recebidos</div>
|
||||
<Tag :value="`${totals.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="text-color-secondary mt-1">
|
||||
Solicitações de pré-cadastro (cadastro externo) para avaliar e converter.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- filtros -->
|
||||
<div class="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'new'"
|
||||
:severity="statusFilter === 'new' ? 'info' : 'secondary'"
|
||||
@click="toggleStatusFilter('new')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-sparkles" />
|
||||
Novos: <b>{{ totals.nNew }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'converted'"
|
||||
:severity="statusFilter === 'converted' ? 'success' : 'secondary'"
|
||||
@click="toggleStatusFilter('converted')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-check" />
|
||||
Convertidos: <b>{{ totals.nConv }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
:outlined="statusFilter !== 'rejected'"
|
||||
:severity="statusFilter === 'rejected' ? 'danger' : 'secondary'"
|
||||
@click="toggleStatusFilter('rejected')"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="pi pi-times" />
|
||||
Rejeitados: <b>{{ totals.nRej }}</b>
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="statusFilter"
|
||||
type="button"
|
||||
class="!rounded-full"
|
||||
severity="secondary"
|
||||
outlined
|
||||
icon="pi pi-filter-slash"
|
||||
label="Limpar filtro"
|
||||
@click="statusFilter = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-2 sm:items-center">
|
||||
<span class="p-input-icon-left w-full sm:w-[360px]">
|
||||
<InputText
|
||||
v-model="q"
|
||||
class="w-full"
|
||||
placeholder="Buscar por nome, e-mail ou telefone…"
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
label="Atualizar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="loading"
|
||||
@click="fetchIntakes"
|
||||
/>
|
||||
</div>
|
||||
<!-- Linha 1 -->
|
||||
<div class="rec-hero__row1">
|
||||
<div class="rec-hero__brand">
|
||||
<div class="rec-hero__icon"><i class="pi pi-inbox text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="rec-hero__title">Cadastros recebidos</div>
|
||||
<Tag :value="`${totals.total}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="rec-hero__sub">Pré-cadastros externos para avaliar e converter em pacientes</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchIntakes" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => recMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="recMobileMenuRef" :model="recMobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TABLE -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="flex items-center justify-center py-10">
|
||||
<ProgressSpinner style="width: 38px; height: 38px" />
|
||||
<!-- Divisor -->
|
||||
<Divider class="rec-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: filtros de status + busca -->
|
||||
<div class="rec-hero__row2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'new'" :severity="statusFilter === 'new' ? 'info' : 'secondary'" @click="toggleStatusFilter('new')">
|
||||
<span class="flex items-center gap-1.5"><i class="pi pi-sparkles text-xs" /> Novos: <b>{{ totals.nNew }}</b></span>
|
||||
</Button>
|
||||
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'converted'" :severity="statusFilter === 'converted' ? 'success' : 'secondary'" @click="toggleStatusFilter('converted')">
|
||||
<span class="flex items-center gap-1.5"><i class="pi pi-check text-xs" /> Convertidos: <b>{{ totals.nConv }}</b></span>
|
||||
</Button>
|
||||
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'rejected'" :severity="statusFilter === 'rejected' ? 'danger' : 'secondary'" @click="toggleStatusFilter('rejected')">
|
||||
<span class="flex items-center gap-1.5"><i class="pi pi-times text-xs" /> Rejeitados: <b>{{ totals.nRej }}</b></span>
|
||||
</Button>
|
||||
<Button v-if="statusFilter" type="button" size="small" class="!rounded-full" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" @click="statusFilter = ''" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
responsiveLayout="scroll"
|
||||
stripedRows
|
||||
class="!border-0"
|
||||
>
|
||||
<InputGroup class="w-72 shrink-0">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" :disabled="loading" />
|
||||
<Button v-if="q" icon="pi pi-trash" severity="danger" title="Limpar" @click="q = ''" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog busca mobile -->
|
||||
<Dialog v-model:visible="recSearchDlgOpen" modal :draggable="false" header="Buscar cadastro" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" autofocus />
|
||||
<Button v-if="q" icon="pi pi-trash" severity="danger" @click="q = ''" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="recSearchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- TABLE – desktop (md+) -->
|
||||
<div class="hidden md:block mx-3 md:mx-5 mb-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<DataTable
|
||||
:value="filteredRows"
|
||||
:loading="loading"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
stripedRows
|
||||
class="!border-0"
|
||||
>
|
||||
<Column header="Status" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
@@ -734,13 +758,67 @@ onMounted(fetchIntakes)
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-6 text-center">
|
||||
Nenhum cadastro encontrado.
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum cadastro encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
|
||||
</div>
|
||||
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- TABLE – mobile cards (<md) -->
|
||||
<div class="md:hidden mx-3 mb-5">
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredRows.length === 0" class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-inbox text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum cadastro encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
|
||||
</div>
|
||||
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="q = ''; statusFilter = ''" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="row in filteredRows"
|
||||
:key="row.id"
|
||||
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="avatarUrl(row)" :image="avatarUrl(row)" shape="circle" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold truncate">{{ fNome(row) || '—' }}</div>
|
||||
<div class="text-sm text-color-secondary truncate">{{ fEmail(row) || '—' }}</div>
|
||||
</div>
|
||||
<Tag :value="statusLabel(row.status)" :severity="statusSeverity(row.status)" />
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between gap-2">
|
||||
<div class="text-sm text-color-secondary flex flex-col gap-0.5">
|
||||
<span>{{ fmtPhoneBR(fTel(row)) }}</span>
|
||||
<span>{{ fmtDate(row.created_at) }}</span>
|
||||
</div>
|
||||
<Button icon="pi pi-eye" label="Ver" severity="secondary" outlined size="small" @click="openDetails(row)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
@@ -748,6 +826,7 @@ onMounted(fetchIntakes)
|
||||
:header="null"
|
||||
:style="{ width: 'min(940px, 96vw)' }"
|
||||
:contentStyle="{ padding: 0 }"
|
||||
:draggable="false"
|
||||
@hide="closeDlg"
|
||||
>
|
||||
<div v-if="dlg.item" class="relative">
|
||||
@@ -878,5 +957,49 @@ onMounted(fetchIntakes)
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rec-sentinel { height: 1px; }
|
||||
|
||||
.rec-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.rec-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.rec-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.rec-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.rec-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.rec-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
.rec-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
|
||||
.rec-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
||||
.rec-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.rec-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.rec-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.rec-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; flex-wrap: wrap; align-items: center;
|
||||
justify-content: space-between; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.rec-hero__divider,
|
||||
.rec-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,79 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- TOOLBAR (padrão Sakai CRUD) -->
|
||||
<Toolbar class="mb-4">
|
||||
<template #start>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-xl font-semibold leading-none">Grupos de Pacientes</div>
|
||||
<small class="text-color-secondary mt-1">
|
||||
Organize seus pacientes por grupos. Alguns grupos são padrões do sistema e não podem ser alterados.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
<Toast />
|
||||
|
||||
<template #end>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled="!selectedGroups || !selectedGroups.length"
|
||||
@click="confirmDeleteSelected"
|
||||
/>
|
||||
<Button label="Adicionar" icon="pi pi-plus" @click="openCreate" />
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="grp-sentinel" />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="grp-hero mx-3 md:mx-5 mb-4" :class="{ 'grp-hero--stuck': headerStuck }">
|
||||
<div class="grp-hero__blobs" aria-hidden="true">
|
||||
<div class="grp-hero__blob grp-hero__blob--1" />
|
||||
<div class="grp-hero__blob grp-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1 -->
|
||||
<div class="grp-hero__row1">
|
||||
<div class="grp-hero__brand">
|
||||
<div class="grp-hero__icon"><i class="pi pi-sitemap text-lg" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="grp-hero__title">Grupos</div>
|
||||
<Tag :value="`${groups.length}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="grp-hero__sub">Organize seus pacientes por grupos temáticos ou clínicos</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
v-if="selectedGroups?.length"
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="confirmDeleteSelected"
|
||||
/>
|
||||
<Button label="Novo" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => grpMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="grpMobileMenuRef" :model="grpMobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="grp-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: busca (oculta no mobile) -->
|
||||
<div class="grp-hero__row2">
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar grupo..." :disabled="loading" />
|
||||
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="grpSearchDlgOpen" modal :draggable="false" header="Buscar grupo" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome do grupo..." autofocus />
|
||||
<Button v-if="filters.global.value" icon="pi pi-trash" severity="danger" @click="filters.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="grpSearchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
|
||||
<!-- LEFT: TABLE -->
|
||||
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
|
||||
<Card class="h-full">
|
||||
@@ -48,16 +95,9 @@
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} grupos"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-wrap gap-2 items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Lista de Grupos</span>
|
||||
<Tag :value="`${groups.length} grupos`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<IconField>
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar grupos..." class="w-64" />
|
||||
</IconField>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Lista de Grupos</span>
|
||||
<Tag :value="`${groups.length} grupos`" severity="secondary" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -73,7 +113,18 @@
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="data.cor"
|
||||
class="inline-block w-3 h-3 rounded-full flex-shrink-0"
|
||||
:style="colorStyle(data.cor)"
|
||||
/>
|
||||
<span>{{ data.nome }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
@@ -116,14 +167,24 @@
|
||||
outlined
|
||||
rounded
|
||||
disabled
|
||||
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
|
||||
title="Grupo padrão do sistema (inalterável)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
Nenhum grupo encontrado.
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum grupo encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Tente limpar o filtro ou crie um novo grupo.</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtro" @click="filters.global.value = null" />
|
||||
<Button icon="pi pi-plus" label="Criar grupo" @click="openCreate" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
@@ -192,31 +253,118 @@
|
||||
|
||||
<!-- DIALOG CREATE / EDIT -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Criar Grupo' : 'Editar Grupo'"
|
||||
modal
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label class="block mb-2">Nome do Grupo</label>
|
||||
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
|
||||
<small class="text-color-secondary">
|
||||
Grupos “Padrão” são do sistema e não podem ser editados.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
class="grp-dialog w-[96vw] max-w-lg"
|
||||
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
|
||||
>
|
||||
<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">
|
||||
<span class="grp-dlg-dot shrink-0" :style="{ backgroundColor: dlgPreviewColor }" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text :disabled="dlg.saving" @click="dlg.open = false" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
|
||||
:loading="dlg.saving"
|
||||
@click="saveDialog"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.open = false"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
@click="saveDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner de preview -->
|
||||
<div class="grp-dlg-banner" :style="{ backgroundColor: dlgPreviewColor }">
|
||||
<span class="grp-dlg-banner__pill">{{ dlg.nome || 'Nome do grupo' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- Nome -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<i class="pi pi-sitemap" />
|
||||
</InputIcon>
|
||||
|
||||
<InputText
|
||||
id="grp-nome"
|
||||
v-model="dlg.nome"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="dlg.saving"
|
||||
@keydown.enter.prevent="saveDialog"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<label for="grp-nome">Nome do grupo *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Cor -->
|
||||
<div class="grp-dlg-section">
|
||||
<div class="grp-dlg-section__label">Cor</div>
|
||||
|
||||
<div class="grp-dlg-palette">
|
||||
|
||||
<button
|
||||
v-for="p in dlgPresetColors"
|
||||
:key="p.bg"
|
||||
class="grp-dlg-swatch"
|
||||
:class="{ 'grp-dlg-swatch--active': dlg.cor === p.bg }"
|
||||
:style="{ backgroundColor: `#${p.bg}` }"
|
||||
:title="p.name"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = p.bg"
|
||||
>
|
||||
<i v-if="dlg.cor === p.bg" class="pi pi-check grp-dlg-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="grp-dlg-swatch grp-dlg-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
|
||||
</div>
|
||||
|
||||
<!-- Limpar cor -->
|
||||
<button
|
||||
v-if="dlg.cor"
|
||||
class="grp-dlg-swatch grp-dlg-swatch--clear"
|
||||
title="Sem cor"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = ''"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- ✅ DIALOG PACIENTES (com botão Abrir) -->
|
||||
<Dialog
|
||||
@@ -253,8 +401,12 @@
|
||||
</Message>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
|
||||
Nenhum paciente associado a este grupo.
|
||||
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-users text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente neste grupo</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Associe pacientes a este grupo na página de pacientes.</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
@@ -299,7 +451,16 @@
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum resultado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Nenhum paciente corresponde a "{{ patientsDialog.search }}".</div>
|
||||
<div class="mt-4">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="patientsDialog.search = ''" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -311,17 +472,17 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Menu from 'primevue/menu'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import {
|
||||
@@ -335,6 +496,24 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const grpMobileMenuRef = ref(null)
|
||||
const grpSearchDlgOpen = ref(false)
|
||||
|
||||
const grpMobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar grupo', icon: 'pi pi-plus', command: () => openCreate() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { grpSearchDlgOpen.value = true } },
|
||||
{ separator: true },
|
||||
...(selectedGroups.value?.length ? [{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmDeleteSelected() }, { separator: true }] : []),
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||
])
|
||||
|
||||
const dt = ref(null)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
@@ -350,9 +529,30 @@ const dlg = reactive({
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
cor: '',
|
||||
saving: false
|
||||
})
|
||||
|
||||
const dlgPresetColors = [
|
||||
{ bg: '6366f1', name: 'Índigo' },
|
||||
{ bg: '8b5cf6', name: 'Violeta' },
|
||||
{ bg: 'ec4899', name: 'Rosa' },
|
||||
{ bg: 'ef4444', name: 'Vermelho' },
|
||||
{ bg: 'f97316', name: 'Laranja' },
|
||||
{ bg: 'eab308', name: 'Amarelo' },
|
||||
{ bg: '22c55e', name: 'Verde' },
|
||||
{ bg: '14b8a6', name: 'Teal' },
|
||||
{ bg: '3b82f6', name: 'Azul' },
|
||||
{ bg: '06b6d4', name: 'Ciano' },
|
||||
{ bg: '64748b', name: 'Ardósia' },
|
||||
{ bg: '292524', name: 'Escuro' },
|
||||
]
|
||||
|
||||
const dlgPreviewColor = computed(() => {
|
||||
if (!dlg.cor) return '#64748b'
|
||||
return dlg.cor.startsWith('#') ? dlg.cor : `#${dlg.cor}`
|
||||
})
|
||||
|
||||
const patientsDialog = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
@@ -428,6 +628,12 @@ function patientsLabel (n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`
|
||||
}
|
||||
|
||||
function colorStyle (cor) {
|
||||
if (!cor) return {}
|
||||
const hex = String(cor).startsWith('#') ? cor : '#' + cor
|
||||
return { background: hex }
|
||||
}
|
||||
|
||||
function humanizeError (err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
|
||||
const code = err?.code
|
||||
@@ -482,6 +688,7 @@ function openCreate () {
|
||||
dlg.mode = 'create'
|
||||
dlg.id = ''
|
||||
dlg.nome = ''
|
||||
dlg.cor = ''
|
||||
}
|
||||
|
||||
function openEdit (row) {
|
||||
@@ -489,6 +696,7 @@ function openEdit (row) {
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
dlg.nome = row.nome
|
||||
dlg.cor = row.cor || ''
|
||||
}
|
||||
|
||||
async function saveDialog () {
|
||||
@@ -502,13 +710,16 @@ async function saveDialog () {
|
||||
return
|
||||
}
|
||||
|
||||
const corRaw = String(dlg.cor || '').trim()
|
||||
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
|
||||
|
||||
dlg.saving = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createGroup(nome)
|
||||
await createGroup(nome, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
|
||||
} else {
|
||||
await updateGroup(dlg.id, nome)
|
||||
await updateGroup(dlg.id, nome, cor)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
|
||||
}
|
||||
dlg.open = false
|
||||
@@ -653,12 +864,125 @@ function abrirPaciente (patient) {
|
||||
router.push(`/features/patients/cadastro/${patient.id}`)
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
/* ── Hero ────────────────────────────────────────── */
|
||||
.grp-sentinel { height: 1px; }
|
||||
|
||||
.grp-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.grp-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
.grp-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.grp-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.grp-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(16,185,129,0.10); }
|
||||
.grp-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
.grp-hero__row1 { position: relative; z-index: 1; display: flex; align-items: center; gap: 1rem; }
|
||||
.grp-hero__brand { display: flex; align-items: center; gap: 0.75rem; flex: 1; min-width: 0; }
|
||||
.grp-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.grp-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.grp-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
.grp-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.grp-hero__divider,
|
||||
.grp-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
/* ── Dialog ──────────────────────────────────────── */
|
||||
.grp-dlg-dot {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.grp-dlg-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.grp-dlg-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.grp-dlg-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.grp-dlg-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.grp-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
|
||||
.grp-dlg-swatch {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.grp-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
|
||||
.grp-dlg-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.grp-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
|
||||
.grp-dlg-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.grp-dlg-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.grp-dlg-swatch--clear {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Fade */
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Chip from 'primevue/chip'
|
||||
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
|
||||
@@ -1,32 +1,88 @@
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<!-- TOOLBAR -->
|
||||
<Toolbar class="mb-4">
|
||||
<template #start>
|
||||
<div class="flex flex-col">
|
||||
<div class="text-xl font-semibold leading-none">Tags de Pacientes</div>
|
||||
<small class="text-color-secondary mt-1">
|
||||
Classifique pacientes por temas (ex.: Burnout, Ansiedade, Triagem). Clique em “Pacientes” para ver a lista.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
<Toast />
|
||||
|
||||
<template #end>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled="!etiquetasSelecionadas?.length"
|
||||
@click="confirmarExclusaoSelecionadas"
|
||||
/>
|
||||
<Button label="Adicionar" icon="pi pi-plus" @click="abrirCriar" />
|
||||
</div>
|
||||
</template>
|
||||
</Toolbar>
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="tags-sentinel" />
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="tags-hero mx-3 md:mx-5 mb-4" :class="{ 'tags-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="tags-hero__blobs" aria-hidden="true">
|
||||
<div class="tags-hero__blob tags-hero__blob--1" />
|
||||
<div class="tags-hero__blob tags-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="tags-hero__row1">
|
||||
<div class="tags-hero__brand">
|
||||
<div class="tags-hero__icon">
|
||||
<i class="pi pi-tags text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="tags-hero__title">Tags</div>
|
||||
<Tag :value="`${etiquetas.length}`" severity="secondary" />
|
||||
</div>
|
||||
<div class="tags-hero__sub">Classifique pacientes por temas — ex.: Burnout, Ansiedade, Triagem</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
v-if="etiquetasSelecionadas?.length"
|
||||
label="Excluir selecionados"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="confirmarExclusaoSelecionadas"
|
||||
/>
|
||||
<Button label="Nova" icon="pi pi-plus" class="rounded-full" @click="abrirCriar" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="carregando" title="Recarregar" @click="buscarEtiquetas" />
|
||||
</div>
|
||||
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="tags-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: busca (oculta no mobile) -->
|
||||
<div class="tags-hero__row2">
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filtros.global.value" placeholder="Buscar tag..." :disabled="carregando" />
|
||||
<Button
|
||||
v-if="filtros.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar busca"
|
||||
@click="filtros.global.value = null"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar tag" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filtros.global.value" placeholder="Nome da tag..." autofocus />
|
||||
<Button v-if="filtros.global.value" icon="pi pi-trash" severity="danger" title="Limpar" @click="filtros.global.value = null" />
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
|
||||
<!-- LEFT: tabela -->
|
||||
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
|
||||
<Card class="h-full">
|
||||
@@ -46,34 +102,6 @@
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
currentPageReportTemplate="Mostrando {first} a {last} de {totalRecords} tags"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Lista de Tags</span>
|
||||
<Tag :value="`${etiquetas.length} tags`" severity="secondary" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 w-full md:w-auto">
|
||||
<IconField class="w-full md:w-80">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText
|
||||
v-model="filtros.global.value"
|
||||
placeholder="Buscar tag..."
|
||||
class="w-full"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
v-tooltip.top="'Atualizar'"
|
||||
@click="buscarEtiquetas"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Seleção (bloqueia tags padrão) -->
|
||||
<Column :exportable="false" headerStyle="width: 3rem">
|
||||
<template #body="{ data }">
|
||||
@@ -124,7 +152,7 @@
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="data.is_padrao"
|
||||
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
|
||||
:title="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
|
||||
@click="abrirEditar(data)"
|
||||
/>
|
||||
<Button
|
||||
@@ -133,7 +161,7 @@
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="data.is_padrao"
|
||||
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
|
||||
:title="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
|
||||
@click="confirmarExclusaoUma(data)"
|
||||
/>
|
||||
</div>
|
||||
@@ -141,8 +169,20 @@
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
|
||||
</template>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhuma tag encontrada</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="filtros.global.value = null" />
|
||||
<Button icon="pi pi-user-plus" label="Cadastrar tag" @click="abrirCriar" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
@@ -155,12 +195,12 @@
|
||||
<template #subtitle>As tags aparecem aqui quando houver pacientes associados.</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="cards.length === 0" class="min-h-[150px] flex flex-col items-center justify-center text-center gap-2">
|
||||
<i class="pi pi-tags text-3xl"></i>
|
||||
<div class="font-medium">Sem dados ainda</div>
|
||||
<small class="text-color-secondary">
|
||||
Quando você associar pacientes às tags, elas aparecem aqui.
|
||||
</small>
|
||||
<div v-if="cards.length === 0" class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-tags text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhuma tag em uso</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">As tags mais usadas aparecem aqui quando houver pacientes associados.</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
@@ -212,64 +252,102 @@
|
||||
<!-- DIALOG CREATE / EDIT -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
|
||||
modal
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
:draggable="false"
|
||||
:closable="!dlg.saving"
|
||||
:dismissableMask="!dlg.saving"
|
||||
class="tag-dialog w-[96vw] max-w-lg"
|
||||
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block mb-2">Nome da Tag</label>
|
||||
<InputText v-model="dlg.nome" class="w-full" :disabled="dlg.saving" />
|
||||
<small class="text-color-secondary">Ex.: Burnout, Ansiedade, Triagem.</small>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block mb-2">Cor (opcional)</label>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
|
||||
|
||||
<InputText
|
||||
v-model="dlg.cor"
|
||||
class="w-44"
|
||||
placeholder="#22c55e"
|
||||
:disabled="dlg.saving"
|
||||
/>
|
||||
|
||||
<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">
|
||||
<span
|
||||
class="inline-block rounded-lg"
|
||||
:style="{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
border: '1px solid var(--surface-border)',
|
||||
background: corPreview(dlg.cor)
|
||||
}"
|
||||
class="tag-dlg-dot shrink-0"
|
||||
:style="{ backgroundColor: tagDlgPreviewColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ dlg.nome || (dlg.mode === 'create' ? 'Nova tag' : 'Editar tag') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ dlg.mode === 'create' ? 'Criar nova tag' : 'Editando tag' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="dlg.saving" @click="fecharDlg" />
|
||||
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" :disabled="!String(dlg.nome || '').trim()" @click="salvarDlg" />
|
||||
</div>
|
||||
|
||||
<small class="text-color-secondary">
|
||||
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
|
||||
</small>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner -->
|
||||
<div class="tag-dlg-banner" :style="{ backgroundColor: tagDlgPreviewColor }">
|
||||
<span class="tag-dlg-banner__pill">{{ dlg.nome || 'Nome da tag' }}</span>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" icon="pi pi-times" text @click="fecharDlg" :disabled="dlg.saving" />
|
||||
<Button
|
||||
:label="dlg.mode === 'create' ? 'Criar' : 'Salvar'"
|
||||
icon="pi pi-check"
|
||||
@click="salvarDlg"
|
||||
:loading="dlg.saving"
|
||||
:disabled="!String(dlg.nome || '').trim()"
|
||||
/>
|
||||
</template>
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Nome -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
id="tag-nome"
|
||||
v-model="dlg.nome"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="dlg.saving"
|
||||
@keydown.enter.prevent="salvarDlg"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="tag-nome">Nome da tag *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Cor -->
|
||||
<div class="tag-dlg-section">
|
||||
<div class="tag-dlg-section__label">Cor</div>
|
||||
<div class="tag-dlg-palette">
|
||||
<button
|
||||
v-for="p in tagPresetColors"
|
||||
:key="p.bg"
|
||||
class="tag-dlg-swatch"
|
||||
:class="{ 'tag-dlg-swatch--active': dlg.cor === p.bg }"
|
||||
:style="{ backgroundColor: `#${p.bg}` }"
|
||||
:title="p.name"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = p.bg"
|
||||
>
|
||||
<i v-if="dlg.cor === p.bg" class="pi pi-check tag-dlg-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="tag-dlg-swatch tag-dlg-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker v-model="dlg.cor" format="hex" :disabled="dlg.saving" />
|
||||
</div>
|
||||
|
||||
<!-- Limpar -->
|
||||
<button
|
||||
v-if="dlg.cor"
|
||||
class="tag-dlg-swatch tag-dlg-swatch--clear"
|
||||
title="Sem cor"
|
||||
:disabled="dlg.saving"
|
||||
@click="dlg.cor = ''"
|
||||
>
|
||||
<i class="pi pi-times text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
|
||||
<!-- MODAL: pacientes da tag -->
|
||||
<Dialog
|
||||
v-model:visible="modalPacientes.open"
|
||||
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
|
||||
:header="modalPacientesHeader"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '900px', maxWidth: '96vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
@@ -330,7 +408,16 @@
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum paciente encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">Nenhum resultado para "{{ modalPacientes.search }}".</div>
|
||||
<div class="mt-4">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar busca" @click="modalPacientes.search = ''" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
@@ -340,17 +427,17 @@
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
@@ -358,6 +445,27 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
const searchDlgOpen = ref(false)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{ label: 'Adicionar', icon: 'pi pi-plus', command: () => abrirCriar() },
|
||||
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchDlgOpen.value = true } },
|
||||
...(etiquetasSelecionadas.value?.length ? [
|
||||
{ separator: true },
|
||||
{ label: 'Excluir selecionados', icon: 'pi pi-trash', command: () => confirmarExclusaoSelecionadas() }
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => buscarEtiquetas() }
|
||||
])
|
||||
|
||||
const dt = ref(null)
|
||||
const carregando = ref(false)
|
||||
|
||||
@@ -393,6 +501,30 @@ const cards = computed(() =>
|
||||
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
|
||||
)
|
||||
|
||||
const tagPresetColors = [
|
||||
{ bg: '6366f1', name: 'Índigo' },
|
||||
{ bg: '8b5cf6', name: 'Violeta' },
|
||||
{ bg: 'ec4899', name: 'Rosa' },
|
||||
{ bg: 'ef4444', name: 'Vermelho' },
|
||||
{ bg: 'f97316', name: 'Laranja' },
|
||||
{ bg: 'eab308', name: 'Amarelo' },
|
||||
{ bg: '22c55e', name: 'Verde' },
|
||||
{ bg: '14b8a6', name: 'Teal' },
|
||||
{ bg: '3b82f6', name: 'Azul' },
|
||||
{ bg: '06b6d4', name: 'Ciano' },
|
||||
{ bg: '64748b', name: 'Ardósia' },
|
||||
{ bg: '292524', name: 'Escuro' },
|
||||
]
|
||||
|
||||
const tagDlgPreviewColor = computed(() => {
|
||||
if (!dlg.cor) return '#64748b'
|
||||
const s = String(dlg.cor).trim()
|
||||
return s.startsWith('#') ? s : `#${s}`
|
||||
})
|
||||
const modalPacientesHeader = computed(() =>
|
||||
modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'
|
||||
)
|
||||
|
||||
const modalPacientesFiltrado = computed(() => {
|
||||
const s = String(modalPacientes.search || '').trim().toLowerCase()
|
||||
if (!s) return modalPacientes.items || []
|
||||
@@ -405,9 +537,17 @@ const modalPacientesFiltrado = computed(() => {
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
buscarEtiquetas()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
@@ -416,6 +556,20 @@ async function getOwnerId() {
|
||||
return user.id
|
||||
}
|
||||
|
||||
async function getActiveTenantId(uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('tenant_id')
|
||||
.eq('user_id', uid)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
if (error) throw error
|
||||
if (!data?.tenant_id) throw new Error('Tenant não encontrado.')
|
||||
return data.tenant_id
|
||||
}
|
||||
|
||||
function normalizarEtiquetaRow(r) {
|
||||
// Compatível com banco antigo (name/color/is_native/patient_count)
|
||||
// e com banco pt-BR (nome/cor/is_padrao/patients_count)
|
||||
@@ -441,7 +595,7 @@ function isUniqueViolation(e) {
|
||||
}
|
||||
|
||||
function friendlyDupMessage(nome) {
|
||||
return `Já existe uma tag chamada “${nome}”. Tente outro nome.`
|
||||
return `Já existe uma tag chamada “${nome}". Tente outro nome.`
|
||||
}
|
||||
|
||||
function corPreview(raw) {
|
||||
@@ -560,22 +714,14 @@ async function salvarDlg() {
|
||||
const cor = hex ? `#${hex}` : null
|
||||
|
||||
if (dlg.mode === 'create') {
|
||||
// tenta pt-BR
|
||||
let res = await supabase.from('patient_tags').insert({
|
||||
const tenantId = await getActiveTenantId(ownerId)
|
||||
const res = await supabase.from('patient_tags').insert({
|
||||
owner_id: ownerId,
|
||||
tenant_id: tenantId,
|
||||
nome,
|
||||
cor
|
||||
})
|
||||
|
||||
// se colunas pt-BR não existem ainda, cai pra legado
|
||||
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
|
||||
res = await supabase.from('patient_tags').insert({
|
||||
owner_id: ownerId,
|
||||
name: nome,
|
||||
color: cor
|
||||
})
|
||||
}
|
||||
|
||||
if (res.error) throw res.error
|
||||
toast.add({ severity: 'success', summary: 'Tag criada', detail: nome, life: 2500 })
|
||||
} else {
|
||||
@@ -640,7 +786,7 @@ async function salvarDlg() {
|
||||
-------------------------------- */
|
||||
function confirmarExclusaoUma(row) {
|
||||
confirm.require({
|
||||
message: `Excluir a tag “${row.nome}”? (Isso remove também os vínculos com pacientes)`,
|
||||
message: `Excluir a tag “${row.nome}"? (Isso remove também os vínculos com pacientes)`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
@@ -807,7 +953,114 @@ function abrirPaciente (patient) {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Mantido apenas porque Transition name="fade" precisa das classes */
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.tags-sentinel { height: 1px; }
|
||||
|
||||
.tags-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.tags-hero--stuck {
|
||||
margin-left: 0; margin-right: 0;
|
||||
border-top-left-radius: 0; border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs */
|
||||
.tags-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.tags-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.tags-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(236,72,153,0.09); }
|
||||
.tags-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.08); }
|
||||
|
||||
/* Linha 1 */
|
||||
.tags-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.tags-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.tags-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.tags-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.tags-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.tags-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.tags-hero__divider,
|
||||
.tags-hero__row2 { display: none; }
|
||||
}
|
||||
|
||||
/* ── Dialog de tag ───────────────────────────────── */
|
||||
.tag-dlg-dot {
|
||||
width: 14px; height: 14px; border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.tag-dlg-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.tag-dlg-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
color: #fff;
|
||||
}
|
||||
.tag-dlg-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.tag-dlg-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
.tag-dlg-palette { display: flex; flex-wrap: wrap; gap: 0.45rem; }
|
||||
.tag-dlg-swatch {
|
||||
width: 28px; height: 28px; border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.tag-dlg-swatch:hover:not(:disabled) { transform: scale(1.18); box-shadow: 0 3px 10px rgba(0,0,0,0.2); }
|
||||
.tag-dlg-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.tag-dlg-swatch__check { font-size: 0.6rem; color: #fff; font-weight: 900; }
|
||||
.tag-dlg-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.tag-dlg-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%; border: none; border-radius: 50%; opacity: 0;
|
||||
}
|
||||
.tag-dlg-swatch--clear { background: var(--surface-border); color: var(--text-color-secondary); }
|
||||
|
||||
/* Fade (Transition nos cards) */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from,
|
||||
|
||||
Reference in New Issue
Block a user