Ajuste em Massa - Paciente, Terapeuta, Clinica e Admin - Inicio agenda
This commit is contained in:
1004
src/features/agenda/components/AdicionarCompromissoPage.vue
Normal file
1004
src/features/agenda/components/AdicionarCompromissoPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
10
src/features/agenda/components/AgendaCalendar.vue
Normal file
10
src/features/agenda/components/AgendaCalendar.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script setup>
|
||||
defineProps({})
|
||||
defineEmits([])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<b>AgendaCalendar (placeholder)</b>
|
||||
</div>
|
||||
</template>
|
||||
100
src/features/agenda/components/AgendaClinicCalendar.vue
Normal file
100
src/features/agenda/components/AgendaClinicCalendar.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<!-- src/features/agenda/components/AgendaClinicCalendar.vue -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
slotDuration: { type: String, default: '00:30:00' },
|
||||
slotMinTime: { type: String, default: '06:00:00' },
|
||||
slotMaxTime: { type: String, default: '22:00:00' },
|
||||
|
||||
resources: { type: Array, default: () => [] }, // [{ id, title }]
|
||||
events: { type: Array, default: () => [] }, // event.resourceId = resource.id
|
||||
|
||||
loading: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'rangeChange',
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize'
|
||||
])
|
||||
|
||||
const calendarRef = ref(null)
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay'))
|
||||
|
||||
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
|
||||
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '24:00:00' : props.slotMaxTime))
|
||||
|
||||
const options = computed(() => ({
|
||||
plugins: [resourceTimeGridPlugin, interactionPlugin],
|
||||
initialView: initialView.value,
|
||||
timeZone: props.timezone,
|
||||
|
||||
headerToolbar: false,
|
||||
|
||||
nowIndicator: true,
|
||||
editable: true,
|
||||
|
||||
slotDuration: props.slotDuration,
|
||||
slotMinTime: computedSlotMinTime.value,
|
||||
slotMaxTime: computedSlotMaxTime.value,
|
||||
|
||||
resourceAreaWidth: '280px',
|
||||
resourceAreaHeaderContent: 'Profissionais',
|
||||
resources: props.resources,
|
||||
|
||||
events: props.events,
|
||||
|
||||
datesSet(arg) {
|
||||
emit('rangeChange', {
|
||||
start: arg.start,
|
||||
end: arg.end,
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type
|
||||
})
|
||||
},
|
||||
|
||||
eventClick(info) { emit('eventClick', info) },
|
||||
eventDrop(info) { emit('eventDrop', info) },
|
||||
eventResize(info) { emit('eventResize', info) },
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false
|
||||
}))
|
||||
|
||||
function api () {
|
||||
const fc = calendarRef.value
|
||||
return fc?.getApi?.()
|
||||
}
|
||||
|
||||
function goToday () { api()?.today() }
|
||||
function prev () { api()?.prev() }
|
||||
function next () { api()?.next() }
|
||||
function setView (v) { api()?.changeView(v === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay') }
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="p-4 text-sm opacity-70 border-b border-[var(--surface-border)]">
|
||||
Carregando agenda da clínica…
|
||||
</div>
|
||||
|
||||
<div class="p-2 md:p-3">
|
||||
<FullCalendar ref="calendarRef" :options="options" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
192
src/features/agenda/components/AgendaClinicMosaic.vue
Normal file
192
src/features/agenda/components/AgendaClinicMosaic.vue
Normal file
@@ -0,0 +1,192 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
slotDuration: { type: String, default: '00:30:00' },
|
||||
slotMinTime: { type: String, default: '06:00:00' },
|
||||
slotMaxTime: { type: String, default: '22:00:00' },
|
||||
|
||||
// [{ id, title }]
|
||||
staff: { type: Array, default: () => [] },
|
||||
|
||||
// todos os eventos (com extendedProps.owner_id)
|
||||
events: { type: Array, default: () => [] },
|
||||
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
|
||||
minColWidth: { type: Number, default: 360 }
|
||||
})
|
||||
|
||||
// ✅ 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 calendarRefs = ref([])
|
||||
|
||||
function setCalendarRef (el, idx) {
|
||||
if (!el) return
|
||||
calendarRefs.value[idx] = el
|
||||
}
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : '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))
|
||||
|
||||
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)
|
||||
if (api) fn(api, i)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
|
||||
// Eventos por profissional (owner)
|
||||
function eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
|
||||
}
|
||||
|
||||
// ---- range sync ----
|
||||
let lastRangeKey = ''
|
||||
let suppressSync = false
|
||||
|
||||
function onDatesSet (arg) {
|
||||
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
|
||||
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
|
||||
})
|
||||
|
||||
// mantém todos os calendários na mesma data
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.start
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur) 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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<div v-if="loading" class="p-4 text-sm opacity-70 border-b border-[var(--surface-border)]">
|
||||
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>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, idx)"
|
||||
:options="{
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: initialView,
|
||||
timeZone: timezone,
|
||||
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
243
src/features/agenda/components/AgendaEventDialog.vue
Normal file
243
src/features/agenda/components/AgendaEventDialog.vue
Normal file
@@ -0,0 +1,243 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Button from 'primevue/button'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
|
||||
// Para editar
|
||||
eventRow: { type: Object, default: null },
|
||||
|
||||
// Para criar via seleção no calendário
|
||||
initialStartISO: { type: String, default: '' },
|
||||
initialEndISO: { type: String, default: '' },
|
||||
|
||||
// Quem é o dono da agenda (owner_id)
|
||||
ownerId: { type: String, default: '' },
|
||||
|
||||
// Se estiver criando na visão clínica e quiser atribuir a um owner específico
|
||||
allowOwnerEdit: { type: Boolean, default: false },
|
||||
ownerOptions: { type: Array, default: () => [] } // [{ label, value }]
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const tipoOptions = [
|
||||
{ label: 'Sessão', value: 'sessao' },
|
||||
{ label: 'Bloqueio', value: 'bloqueio' },
|
||||
{ label: 'Pessoal', value: 'pessoal' },
|
||||
{ label: 'Clínica', value: 'clinica' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Agendado', value: 'agendado' },
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' }
|
||||
]
|
||||
|
||||
const form = ref(resetForm())
|
||||
|
||||
watch(
|
||||
() => [props.eventRow, props.initialStartISO, props.initialEndISO, props.ownerId],
|
||||
() => {
|
||||
form.value = resetForm()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function resetForm () {
|
||||
const r = props.eventRow
|
||||
|
||||
// ISO strings (timestamptz)
|
||||
const startISO = r?.inicio_em || props.initialStartISO || ''
|
||||
const endISO = r?.fim_em || props.initialEndISO || ''
|
||||
|
||||
return {
|
||||
id: r?.id || null,
|
||||
owner_id: r?.owner_id || props.ownerId || '',
|
||||
terapeuta_id: r?.terapeuta_id ?? null,
|
||||
paciente_id: r?.paciente_id ?? null,
|
||||
|
||||
tipo: r?.tipo || 'sessao',
|
||||
status: r?.status || 'agendado',
|
||||
titulo: r?.titulo || '',
|
||||
observacoes: r?.observacoes || '',
|
||||
|
||||
inicio_em: startISO,
|
||||
fim_em: endISO,
|
||||
|
||||
// ajuda de UX (minutos) caso você queira editar duração fácil
|
||||
duracaoMin: calcMinutes(startISO, endISO) || 50
|
||||
}
|
||||
}
|
||||
|
||||
function calcMinutes (a, b) {
|
||||
try {
|
||||
if (!a || !b) return null
|
||||
const ms = new Date(b).getTime() - new Date(a).getTime()
|
||||
return Math.max(0, Math.round(ms / 60000))
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
function addMinutesISO (iso, min) {
|
||||
const d = new Date(iso)
|
||||
d.setMinutes(d.getMinutes() + Number(min || 0))
|
||||
return d.toISOString()
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!form.value.id)
|
||||
|
||||
const canSave = computed(() => {
|
||||
if (!form.value.owner_id) return false
|
||||
if (!form.value.inicio_em) return false
|
||||
if (!form.value.fim_em) return false
|
||||
const a = new Date(form.value.inicio_em).getTime()
|
||||
const b = new Date(form.value.fim_em).getTime()
|
||||
return b > a
|
||||
})
|
||||
|
||||
function applyDuration () {
|
||||
if (!form.value.inicio_em) return
|
||||
form.value.fim_em = addMinutesISO(form.value.inicio_em, form.value.duracaoMin || 50)
|
||||
}
|
||||
|
||||
function onSave () {
|
||||
if (!canSave.value) return
|
||||
|
||||
const payload = {
|
||||
owner_id: form.value.owner_id,
|
||||
terapeuta_id: form.value.terapeuta_id,
|
||||
paciente_id: form.value.paciente_id,
|
||||
|
||||
tipo: form.value.tipo,
|
||||
status: form.value.status,
|
||||
titulo: form.value.titulo || null,
|
||||
observacoes: form.value.observacoes || null,
|
||||
|
||||
inicio_em: form.value.inicio_em,
|
||||
fim_em: form.value.fim_em
|
||||
}
|
||||
|
||||
emit('save', { id: form.value.id, payload })
|
||||
}
|
||||
|
||||
function onDelete () {
|
||||
if (!form.value.id) return
|
||||
emit('delete', form.value.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="visible" modal :style="{ width: '720px', maxWidth: '95vw' }" :header="isEdit ? 'Editar evento' : 'Novo evento'">
|
||||
<div class="grid gap-4">
|
||||
<div class="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<Dropdown
|
||||
id="tipo"
|
||||
class="w-full"
|
||||
:options="tipoOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
v-model="form.tipo"
|
||||
/>
|
||||
<label for="tipo">Tipo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<Dropdown
|
||||
id="status"
|
||||
class="w-full"
|
||||
:options="statusOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
v-model="form.status"
|
||||
/>
|
||||
<label for="status">Status</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div v-if="allowOwnerEdit">
|
||||
<FloatLabel>
|
||||
<Dropdown
|
||||
id="owner"
|
||||
class="w-full"
|
||||
:options="ownerOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
v-model="form.owner_id"
|
||||
/>
|
||||
<label for="owner">Profissional</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputText id="titulo" class="w-full" v-model="form.titulo" placeholder=" " />
|
||||
<label for="titulo">Título</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputText id="inicio" class="w-full" v-model="form.inicio_em" placeholder=" " />
|
||||
<label for="inicio">Início (ISO)</label>
|
||||
</FloatLabel>
|
||||
<div class="text-xs opacity-70 mt-1">Por enquanto em ISO. Depois trocamos para DatePicker bonito.</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputText id="fim" class="w-full" v-model="form.fim_em" placeholder=" " />
|
||||
<label for="fim">Fim (ISO)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-3 items-end">
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<InputNumber id="dur" class="w-full" v-model="form.duracaoMin" :min="5" :max="480" />
|
||||
<label for="dur">Duração (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Aplicar duração" severity="secondary" outlined icon="pi pi-clock" @click="applyDuration" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel>
|
||||
<Textarea id="obs" class="w-full" autoResize rows="3" v-model="form.observacoes" placeholder=" " />
|
||||
<label for="obs">Observações</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center pt-2">
|
||||
<Button v-if="isEdit" label="Excluir" icon="pi pi-trash" severity="danger" outlined @click="onDelete" />
|
||||
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="visible = false" />
|
||||
<Button label="Salvar" icon="pi pi-check" :disabled="!canSave" @click="onSave" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
13
src/features/agenda/components/AgendaRightPanel.vue
Normal file
13
src/features/agenda/components/AgendaRightPanel.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup>
|
||||
defineProps({})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<b>AgendaRightPanel (placeholder)</b>
|
||||
<div class="mt-3">
|
||||
<slot name="top" />
|
||||
<slot name="bottom" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
115
src/features/agenda/components/AgendaToolbar.vue
Normal file
115
src/features/agenda/components/AgendaToolbar.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<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' },
|
||||
|
||||
// 'day' | 'week'
|
||||
view: { type: String, default: 'day' },
|
||||
|
||||
// 'full_24h' | 'work_hours'
|
||||
mode: { type: String, default: 'work_hours' },
|
||||
|
||||
showSearch: { type: Boolean, default: true },
|
||||
searchPlaceholder: { type: String, default: ' ' },
|
||||
|
||||
// controla se exibe botões de ação
|
||||
showActions: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'today',
|
||||
'prev',
|
||||
'next',
|
||||
'changeView',
|
||||
'toggleMode',
|
||||
'createSession',
|
||||
'createBlock',
|
||||
'search'
|
||||
])
|
||||
|
||||
const viewOptions = [
|
||||
{ label: 'Dia', value: 'day' },
|
||||
{ label: 'Semana', value: 'week' }
|
||||
]
|
||||
|
||||
const search = ref('')
|
||||
|
||||
watch(search, (v) => emit('search', v))
|
||||
|
||||
const modeLabel = computed(() => (props.mode === 'full_24h' ? '24h' : 'Horário'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 overflow-hidden rounded-[1.5rem] border border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="p-4 md:p-5 flex flex-col gap-3">
|
||||
<!-- topo -->
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-lg md:text-xl font-semibold truncate">{{ title }}</div>
|
||||
<div class="text-sm opacity-70">Operação do dia com visão e ação.</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button label="Hoje" icon="pi pi-calendar" severity="secondary" outlined @click="$emit('today')" />
|
||||
<Button icon="pi pi-chevron-left" severity="secondary" outlined @click="$emit('prev')" />
|
||||
<Button icon="pi pi-chevron-right" severity="secondary" outlined @click="$emit('next')" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- controles -->
|
||||
<div class="flex flex-col md:flex-row md:items-center gap-3 md:justify-between">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<SelectButton
|
||||
:modelValue="view"
|
||||
:options="viewOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
@update:modelValue="$emit('changeView', $event)"
|
||||
/>
|
||||
|
||||
<ToggleButton
|
||||
:modelValue="mode === 'full_24h'"
|
||||
onLabel="24h"
|
||||
offLabel="Horário"
|
||||
@update:modelValue="$emit('toggleMode', $event ? 'full_24h' : 'work_hours')"
|
||||
/>
|
||||
|
||||
<div class="text-sm opacity-70">
|
||||
Modo: <b>{{ modeLabel }}</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row items-stretch md:items-center gap-2">
|
||||
<template v-if="showActions">
|
||||
<Button label="Nova sessão" icon="pi pi-plus" @click="$emit('createSession')" />
|
||||
<Button label="Bloquear" icon="pi pi-lock" severity="secondary" outlined @click="$emit('createBlock')" />
|
||||
</template>
|
||||
|
||||
<div v-if="showSearch" class="md:w-72 w-full">
|
||||
<FloatLabel>
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText
|
||||
id="agendaSearch"
|
||||
class="w-full"
|
||||
v-model="search"
|
||||
:placeholder="searchPlaceholder"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="agendaSearch">Buscar por paciente/título</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
0
src/features/agenda/components/ConflictBanner.vue
Normal file
0
src/features/agenda/components/ConflictBanner.vue
Normal file
0
src/features/agenda/components/PreviewTimeline.vue
Normal file
0
src/features/agenda/components/PreviewTimeline.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
items: { type: Array, default: () => [] }
|
||||
})
|
||||
defineEmits(['open', 'confirm', 'reschedule'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 border rounded-lg">
|
||||
<b>AgendaNextSessionsCardList (placeholder)</b>
|
||||
</div>
|
||||
</template>
|
||||
12
src/features/agenda/components/cards/AgendaPulseCardGrid.vue
Normal file
12
src/features/agenda/components/cards/AgendaPulseCardGrid.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
stats: { type: Object, default: () => ({}) }
|
||||
})
|
||||
defineEmits(['quickBlock', 'quickCreate'])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-3 border rounded-lg">
|
||||
<b>AgendaPulseCardGrid (placeholder)</b>
|
||||
</div>
|
||||
</template>
|
||||
24
src/features/agenda/composables/useAgendaClinicStaff.js
Normal file
24
src/features/agenda/composables/useAgendaClinicStaff.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/features/agenda/composables/useAgendaClinicStaff.js
|
||||
import { ref } from 'vue'
|
||||
import { listTenantStaff } from '../services/agendaRepository'
|
||||
|
||||
export function useAgendaClinicStaff () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const staff = ref([])
|
||||
|
||||
async function load (tenantId) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
staff.value = await listTenantStaff(tenantId)
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar profissionais.'
|
||||
staff.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, staff, load }
|
||||
}
|
||||
106
src/features/agenda/composables/useAgendaEvents.js
Normal file
106
src/features/agenda/composables/useAgendaEvents.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/features/agenda/composables/useAgendaEvents.js
|
||||
import { ref } from 'vue'
|
||||
|
||||
import {
|
||||
listMyAgendaEvents,
|
||||
listClinicEvents,
|
||||
createAgendaEvento,
|
||||
updateAgendaEvento,
|
||||
deleteAgendaEvento
|
||||
} from '../services/agendaRepository.js'
|
||||
|
||||
export function useAgendaEvents () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
async function loadMyRange (startISO, endISO) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
rows.value = await listMyAgendaEvents({ startISO, endISO })
|
||||
return rows.value
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos.'
|
||||
rows.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadClinicRange (ownerIds, startISO, endISO) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
// ✅ evita erro "invalid input syntax for type uuid: null"
|
||||
const safeIds = (ownerIds || []).filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||
if (!safeIds.length) {
|
||||
rows.value = []
|
||||
return []
|
||||
}
|
||||
|
||||
rows.value = await listClinicEvents({ ownerIds: safeIds, startISO, endISO })
|
||||
return rows.value
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos da clínica.'
|
||||
rows.value = []
|
||||
return []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create (payload) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const created = await createAgendaEvento(payload)
|
||||
return created
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function update (id, patch) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const updated = await updateAgendaEvento(id, patch)
|
||||
return updated
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove (id) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await deleteAgendaEvento(id)
|
||||
return true
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao excluir evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
rows,
|
||||
loadMyRange,
|
||||
loadClinicRange,
|
||||
create,
|
||||
update,
|
||||
remove
|
||||
}
|
||||
}
|
||||
0
src/features/agenda/composables/useAgendaLimits.js
Normal file
0
src/features/agenda/composables/useAgendaLimits.js
Normal file
0
src/features/agenda/composables/useAgendaQuery.js
Normal file
0
src/features/agenda/composables/useAgendaQuery.js
Normal file
24
src/features/agenda/composables/useAgendaSettings.js
Normal file
24
src/features/agenda/composables/useAgendaSettings.js
Normal file
@@ -0,0 +1,24 @@
|
||||
// src/features/agenda/composables/useAgendaSettings.js
|
||||
import { ref } from 'vue'
|
||||
import { getMyAgendaSettings } from '../services/agendaRepository'
|
||||
|
||||
export function useAgendaSettings () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const settings = ref(null)
|
||||
|
||||
async function load () {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
settings.value = await getMyAgendaSettings()
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar configurações da agenda.'
|
||||
settings.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, settings, load }
|
||||
}
|
||||
0
src/features/agenda/domain/agenda.mappers.js
Normal file
0
src/features/agenda/domain/agenda.mappers.js
Normal file
0
src/features/agenda/domain/agenda.types.js
Normal file
0
src/features/agenda/domain/agenda.types.js
Normal file
288
src/features/agenda/pages/AgendaClinicaPage.vue
Normal file
288
src/features/agenda/pages/AgendaClinicaPage.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import AgendaToolbar from '../components/AgendaToolbar.vue'
|
||||
import AgendaClinicMosaic from '../components/AgendaClinicMosaic.vue'
|
||||
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
|
||||
|
||||
import { useAgendaEvents } from '../composables/useAgendaEvents.js'
|
||||
import { useAgendaClinicStaff } from '../composables/useAgendaClinicStaff.js'
|
||||
import { mapAgendaEventosToCalendarEvents } from '../services/agendaMappers.js'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// -------------------- UI state --------------------
|
||||
const view = ref('day')
|
||||
const mode = ref('work_hours')
|
||||
const calendarRef = ref(null)
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogEventRow = ref(null)
|
||||
const dialogStartISO = ref('')
|
||||
const dialogEndISO = ref('')
|
||||
|
||||
// guardamos o range atual (para recarregar depois)
|
||||
const currentRange = ref({ start: null, end: null })
|
||||
|
||||
// -------------------- data --------------------
|
||||
const { loading: loadingStaff, error: staffError, staff, load: loadStaff } = useAgendaClinicStaff()
|
||||
|
||||
// ✅ agora já pega também create/update/remove (se você já atualizou o composable)
|
||||
const {
|
||||
loading: loadingEvents,
|
||||
error: eventsError,
|
||||
rows,
|
||||
loadClinicRange,
|
||||
create,
|
||||
update,
|
||||
remove
|
||||
} = useAgendaEvents()
|
||||
|
||||
const tenantId = computed(() => {
|
||||
const t = tenantStore.activeTenantId
|
||||
if (!t) return null
|
||||
if (t === 'null' || t === 'undefined') return null
|
||||
return t
|
||||
})
|
||||
|
||||
function isUuid (v) {
|
||||
return typeof v === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(v)
|
||||
}
|
||||
|
||||
const staffCols = computed(() => {
|
||||
return (staff.value || [])
|
||||
.filter(s => isUuid(s.user_id))
|
||||
.map(s => ({
|
||||
id: s.user_id,
|
||||
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
|
||||
}))
|
||||
})
|
||||
|
||||
// ✅ AQUI está o que faltava: ownerIds
|
||||
const ownerIds = computed(() => staffCols.value.map(p => p.id))
|
||||
|
||||
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
|
||||
|
||||
const ownerOptions = computed(() =>
|
||||
staffCols.value.map(p => ({ label: p.title, value: p.id }))
|
||||
)
|
||||
|
||||
// -------------------- lifecycle --------------------
|
||||
onMounted(async () => {
|
||||
if (!tenantId.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Clínica', detail: 'Nenhum tenant ativo.', life: 4500 })
|
||||
return
|
||||
}
|
||||
|
||||
await loadStaff(tenantId.value)
|
||||
if (staffError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Profissionais', detail: staffError.value, life: 4500 })
|
||||
}
|
||||
})
|
||||
|
||||
// -------------------- toolbar actions --------------------
|
||||
function onToday () { calendarRef.value?.goToday?.() }
|
||||
function onPrev () { calendarRef.value?.prev?.() }
|
||||
function onNext () { calendarRef.value?.next?.() }
|
||||
|
||||
function onChangeView (v) {
|
||||
view.value = v
|
||||
calendarRef.value?.setView?.(v)
|
||||
}
|
||||
|
||||
function onToggleMode (m) {
|
||||
mode.value = m
|
||||
}
|
||||
|
||||
// -------------------- calendar callbacks --------------------
|
||||
async function onRangeChange ({ start, end }) {
|
||||
currentRange.value = { start, end }
|
||||
|
||||
const ids = ownerIds.value
|
||||
if (!ids.length) return
|
||||
|
||||
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
|
||||
|
||||
if (eventsError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
function onEventClick (info) {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ev.id,
|
||||
owner_id: ev.extendedProps?.owner_id,
|
||||
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
|
||||
paciente_id: ev.extendedProps?.paciente_id ?? null,
|
||||
tipo: ev.extendedProps?.tipo,
|
||||
status: ev.extendedProps?.status,
|
||||
titulo: ev.title,
|
||||
observacoes: ev.extendedProps?.observacoes ?? null,
|
||||
inicio_em: ev.start?.toISOString?.() || ev.startStr,
|
||||
fim_em: ev.end?.toISOString?.() || ev.endStr
|
||||
}
|
||||
|
||||
dialogStartISO.value = ''
|
||||
dialogEndISO.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function persistMoveOrResize (info, actionLabel) {
|
||||
try {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
const id = ev.id
|
||||
const startISO = ev.start ? ev.start.toISOString() : null
|
||||
const endISO = ev.end ? ev.end.toISOString() : null
|
||||
|
||||
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
|
||||
|
||||
await update(id, { inicio_em: startISO, fim_em: endISO })
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: actionLabel,
|
||||
detail: 'Alteração salva.',
|
||||
life: 1800
|
||||
})
|
||||
} catch (e) {
|
||||
// desfaz no calendário
|
||||
info?.revert?.()
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Erro',
|
||||
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
|
||||
life: 4500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onEventDrop (info) {
|
||||
persistMoveOrResize(info, 'Movido')
|
||||
}
|
||||
|
||||
function onEventResize (info) {
|
||||
persistMoveOrResize(info, 'Redimensionado')
|
||||
}
|
||||
|
||||
// -------------------- dialog actions (mínimo funcional) --------------------
|
||||
function onCreateClinicEvent () {
|
||||
// cria evento base (depois você troca para "selecionar no calendário", mas aqui é bom pra começar)
|
||||
const start = new Date()
|
||||
const end = new Date(Date.now() + 50 * 60000)
|
||||
|
||||
dialogEventRow.value = null
|
||||
dialogStartISO.value = start.toISOString()
|
||||
dialogEndISO.value = end.toISOString()
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function onDialogSave ({ id, payload }) {
|
||||
try {
|
||||
if (id) await update(id, payload)
|
||||
else await create(payload)
|
||||
|
||||
dialogOpen.value = false
|
||||
|
||||
// recarrega range atual se existir
|
||||
if (currentRange.value.start && currentRange.value.end) {
|
||||
await loadClinicRange(
|
||||
ownerIds.value,
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento salvo.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDialogDelete (id) {
|
||||
try {
|
||||
await remove(id)
|
||||
dialogOpen.value = false
|
||||
|
||||
if (currentRange.value.start && currentRange.value.end) {
|
||||
await loadClinicRange(
|
||||
ownerIds.value,
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
function onSlotSelect ({ ownerId, start, end }) {
|
||||
dialogEventRow.value = null
|
||||
dialogStartISO.value = new Date(start).toISOString()
|
||||
dialogEndISO.value = new Date(end).toISOString()
|
||||
// aqui você pode setar o owner default do dialog via ownerId
|
||||
// o Dialog já tem dropdown, mas você pode passar ownerId no payload quando salvar
|
||||
dialogOpen.value = true
|
||||
|
||||
// opcional: guardar pra preselecionar no dialog (se você implementar isso)
|
||||
// dialogOwnerId.value = ownerId
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<AgendaToolbar
|
||||
title="Agenda da clínica"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
@today="onToday"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@changeView="onChangeView"
|
||||
@toggleMode="onToggleMode"
|
||||
@createSession="onCreateClinicEvent"
|
||||
@createBlock="() => toast.add({ severity: 'info', summary: 'Bloqueio', detail: 'Próximo passo: bloqueio da clínica.', life: 2500 })"
|
||||
/>
|
||||
|
||||
<AgendaClinicMosaic
|
||||
ref="calendarRef"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
:staff="staffCols"
|
||||
:events="allEvents"
|
||||
:loading="loadingStaff || loadingEvents"
|
||||
@rangeChange="onRangeChange"
|
||||
@slotSelect="onSlotSelect"
|
||||
@eventClick="onEventClick"
|
||||
@eventDrop="onEventDrop"
|
||||
@eventResize="onEventResize"
|
||||
/>
|
||||
|
||||
<AgendaEventDialog
|
||||
v-model="dialogOpen"
|
||||
:eventRow="dialogEventRow"
|
||||
:initialStartISO="dialogStartISO"
|
||||
:initialEndISO="dialogEndISO"
|
||||
:ownerId="staffCols?.[0]?.id || ''"
|
||||
:allowOwnerEdit="true"
|
||||
:ownerOptions="ownerOptions"
|
||||
@save="onDialogSave"
|
||||
@delete="onDialogDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
449
src/features/agenda/pages/AgendaTerapeutaPage.vue
Normal file
449
src/features/agenda/pages/AgendaTerapeutaPage.vue
Normal file
@@ -0,0 +1,449 @@
|
||||
<!-- src/features/agenda/pages/AgendaTerapeutaPage.vue -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import Toast from 'primevue/toast'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import AgendaEventDialog from '../components/AgendaEventDialog.vue'
|
||||
|
||||
import AgendaToolbar from '../components/AgendaToolbar.vue'
|
||||
import AgendaCalendar from '../components/AgendaCalendar.vue'
|
||||
import AgendaRightPanel from '../components/AgendaRightPanel.vue'
|
||||
import AgendaNextSessionsCardList from '../components/cards/AgendaNextSessionsCardList.vue'
|
||||
import AgendaPulseCardGrid from '../components/cards/AgendaPulseCardGrid.vue'
|
||||
|
||||
import { useAgendaSettings } from '../composables/useAgendaSettings'
|
||||
import { useAgendaEvents } from '../composables/useAgendaEvents'
|
||||
import {
|
||||
mapAgendaEventosToCalendarEvents,
|
||||
buildNextSessions,
|
||||
buildWeeklyBreakBackgroundEvents,
|
||||
calcDefaultSlotDuration,
|
||||
minutesToDuration
|
||||
} from '../services/agendaMappers'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// -----------------------------
|
||||
// State
|
||||
// -----------------------------
|
||||
const view = ref('day') // 'day' | 'week'
|
||||
const mode = ref('work_hours') // 'full_24h' | 'work_hours'
|
||||
const searchQuery = ref('')
|
||||
|
||||
const calendarRef = ref(null)
|
||||
|
||||
const { loading: loadingSettings, error: settingsError, settings, load: loadSettings } = useAgendaSettings()
|
||||
const { loading: loadingEvents, error: eventsError, rows, loadMyRange, create, update, remove } = useAgendaEvents()
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogEventRow = ref(null)
|
||||
const dialogStartISO = ref('')
|
||||
const dialogEndISO = ref('')
|
||||
const currentRange = ref({ start: null, end: null })
|
||||
|
||||
// Range atual (FullCalendar)
|
||||
const currentRange = ref({ start: new Date(), end: new Date() })
|
||||
|
||||
// -----------------------------
|
||||
// Derived: settings -> calendar behavior
|
||||
// -----------------------------
|
||||
const timezone = computed(() => settings.value?.timezone || 'America/Sao_Paulo')
|
||||
|
||||
const slotDuration = computed(() => {
|
||||
if (!settings.value) return '00:30:00'
|
||||
return calcDefaultSlotDuration(settings.value)
|
||||
})
|
||||
|
||||
// work hours recorte (visual)
|
||||
const slotMinTime = computed(() => {
|
||||
if (!settings.value) return '06:00:00'
|
||||
// Se estiver no modo "work_hours", você quer mostrar um pouco antes
|
||||
// Aqui respeitamos admin_inicio_visualizacao se usar_horario_admin_custom estiver true,
|
||||
// senão tentamos agenda_custom_start, senão default.
|
||||
const s = settings.value
|
||||
const base =
|
||||
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
|
||||
s.agenda_custom_start ||
|
||||
'06:00:00'
|
||||
|
||||
// padding -1h
|
||||
return padTime(base, -60)
|
||||
})
|
||||
|
||||
const slotMaxTime = computed(() => {
|
||||
if (!settings.value) return '22:00:00'
|
||||
const s = settings.value
|
||||
const base =
|
||||
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
|
||||
s.agenda_custom_end ||
|
||||
'22:00:00'
|
||||
|
||||
// padding +1h
|
||||
return padTime(base, +60)
|
||||
})
|
||||
|
||||
// business hours “verdadeiro” (sem padding)
|
||||
const businessHours = computed(() => {
|
||||
if (!settings.value) return []
|
||||
const s = settings.value
|
||||
|
||||
const start =
|
||||
(s.usar_horario_admin_custom && s.admin_inicio_visualizacao) ||
|
||||
s.agenda_custom_start ||
|
||||
'08:00:00'
|
||||
|
||||
const end =
|
||||
(s.usar_horario_admin_custom && s.admin_fim_visualizacao) ||
|
||||
s.agenda_custom_end ||
|
||||
'18:00:00'
|
||||
|
||||
// Semana inteira (você pode trocar isso pra algo vindo de agenda_regras_semanais depois)
|
||||
return [
|
||||
{ daysOfWeek: [1,2,3,4,5], startTime: start, endTime: end }
|
||||
]
|
||||
})
|
||||
|
||||
// Eventos do banco -> FullCalendar
|
||||
const calendarEvents = computed(() => {
|
||||
const base = mapAgendaEventosToCalendarEvents(rows.value || [])
|
||||
|
||||
// Pausas semanais (jsonb) -> background events
|
||||
const breaks = settings.value
|
||||
? buildWeeklyBreakBackgroundEvents(
|
||||
settings.value.pausas_semanais,
|
||||
currentRange.value.start,
|
||||
currentRange.value.end
|
||||
)
|
||||
: []
|
||||
|
||||
return [...base, ...breaks]
|
||||
})
|
||||
|
||||
// Cards de próximas sessões
|
||||
const nextSessions = computed(() => buildNextSessions(rows.value || []))
|
||||
|
||||
// Pulse stats (bem inicial, mas já útil)
|
||||
const pulseStats = computed(() => {
|
||||
const list = rows.value || []
|
||||
const totalSessions = list.filter(r => (r.tipo || '').toLowerCase().includes('sess')).length
|
||||
const totalMinutes = list.reduce((acc, r) => {
|
||||
const ms = new Date(r.fim_em).getTime() - new Date(r.inicio_em).getTime()
|
||||
return acc + Math.max(0, Math.round(ms / 60000))
|
||||
}, 0)
|
||||
|
||||
const pending = list.filter(r => (r.status || '').toLowerCase().includes('pend')).length
|
||||
const reschedules = list.filter(r => (r.status || '').toLowerCase().includes('remarc')).length
|
||||
const attentions = pending + reschedules
|
||||
|
||||
// Sugerir encaixes (placeholder): depois vamos calcular via gaps no range.
|
||||
const suggested1 = '—'
|
||||
const suggested2 = '—'
|
||||
|
||||
const nextBreak = '—' // depois calculamos pela pausa semanal + "agora"
|
||||
|
||||
return {
|
||||
totalSessions,
|
||||
totalMinutes,
|
||||
biggestFreeWindow: '—',
|
||||
pending,
|
||||
reschedules,
|
||||
attentions,
|
||||
suggested1,
|
||||
suggested2,
|
||||
nextBreak
|
||||
}
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Lifecycle
|
||||
// -----------------------------
|
||||
onMounted(async () => {
|
||||
await loadSettings()
|
||||
if (settingsError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Agenda', detail: settingsError.value, life: 4500 })
|
||||
}
|
||||
// aplica modo inicial vindo da config
|
||||
if (settings.value?.agenda_view_mode) {
|
||||
mode.value = settings.value.agenda_view_mode === 'full_24h' ? 'full_24h' : 'work_hours'
|
||||
}
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Actions: toolbar
|
||||
// -----------------------------
|
||||
function onToday() { calendarRef.value?.goToday?.() }
|
||||
function onPrev() { calendarRef.value?.prev?.() }
|
||||
function onNext() { calendarRef.value?.next?.() }
|
||||
|
||||
function onChangeView(v) {
|
||||
view.value = v
|
||||
calendarRef.value?.setView?.(v)
|
||||
}
|
||||
|
||||
function onToggleMode(m) {
|
||||
mode.value = m
|
||||
}
|
||||
|
||||
function onSearch(q) {
|
||||
searchQuery.value = q || ''
|
||||
// Por enquanto a busca não filtra o FullCalendar (isso exige requery ou filtro local).
|
||||
// Vamos plugar isso quando tiver patient join e título mais rico.
|
||||
}
|
||||
|
||||
function onCreateSession() {
|
||||
toast.add({ severity: 'info', summary: 'Nova sessão', detail: 'Abrir modal de criação (próximo passo).', life: 2500 })
|
||||
}
|
||||
function onCreateBlock() {
|
||||
toast.add({ severity: 'info', summary: 'Bloquear horário', detail: 'Abrir modal de bloqueio (próximo passo).', life: 2500 })
|
||||
}
|
||||
|
||||
const staffCols = computed(() => (staff.value || [])
|
||||
.filter(s => typeof s.user_id === 'string' && s.user_id && s.user_id !== 'null' && s.user_id !== 'undefined')
|
||||
.map(s => ({
|
||||
id: s.user_id,
|
||||
title: s.full_name || s.nome || s.name || s.email || 'Profissional'
|
||||
}))
|
||||
)
|
||||
|
||||
const ownerIds = computed(() => staffCols.value.map(s => s.id))
|
||||
|
||||
const allEvents = computed(() => mapAgendaEventosToCalendarEvents(rows.value || []))
|
||||
|
||||
// -----------------------------
|
||||
// FullCalendar callbacks
|
||||
// -----------------------------
|
||||
async function onRangeChange ({ start, end }) {
|
||||
currentRange.value = { start, end }
|
||||
|
||||
const ids = ownerIds.value
|
||||
if (!ids.length) return
|
||||
|
||||
await loadClinicRange(ids, new Date(start).toISOString(), new Date(end).toISOString())
|
||||
|
||||
if (eventsError.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Eventos', detail: eventsError.value, life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
function onSelectTime (selection) {
|
||||
const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50
|
||||
const startISO = new Date(selection.start).toISOString()
|
||||
const endISO = new Date(new Date(selection.start).getTime() + durMin * 60000).toISOString()
|
||||
|
||||
dialogEventRow.value = null
|
||||
dialogStartISO.value = startISO
|
||||
dialogEndISO.value = endISO
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function onEventClick (info) {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
dialogEventRow.value = {
|
||||
id: ev.id,
|
||||
owner_id: ev.extendedProps?.owner_id,
|
||||
terapeuta_id: ev.extendedProps?.terapeuta_id ?? null,
|
||||
paciente_id: ev.extendedProps?.paciente_id ?? null,
|
||||
tipo: ev.extendedProps?.tipo,
|
||||
status: ev.extendedProps?.status,
|
||||
titulo: ev.title,
|
||||
observacoes: ev.extendedProps?.observacoes ?? null,
|
||||
inicio_em: ev.start?.toISOString?.() || ev.startStr,
|
||||
fim_em: ev.end?.toISOString?.() || ev.endStr
|
||||
}
|
||||
|
||||
dialogStartISO.value = ''
|
||||
dialogEndISO.value = ''
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function persistMoveOrResize (info, actionLabel) {
|
||||
try {
|
||||
const ev = info?.event
|
||||
if (!ev) return
|
||||
|
||||
const id = ev.id
|
||||
const startISO = ev.start ? ev.start.toISOString() : null
|
||||
const endISO = ev.end ? ev.end.toISOString() : null
|
||||
|
||||
if (!startISO || !endISO) throw new Error('Evento sem start/end.')
|
||||
|
||||
await update(id, { inicio_em: startISO, fim_em: endISO })
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: actionLabel,
|
||||
detail: 'Alteração salva.',
|
||||
life: 1800
|
||||
})
|
||||
} catch (e) {
|
||||
// desfaz no calendário
|
||||
info?.revert?.()
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Erro',
|
||||
detail: eventsError.value || e?.message || 'Falha ao salvar alteração.',
|
||||
life: 4500
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onEventDrop (info) {
|
||||
persistMoveOrResize(info, 'Movido')
|
||||
}
|
||||
|
||||
function onEventResize (info) {
|
||||
persistMoveOrResize(info, 'Redimensionado')
|
||||
}
|
||||
|
||||
function onOpenFromCard (it) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Evento',
|
||||
detail: it?.title || 'Evento',
|
||||
life: 2500
|
||||
})
|
||||
}
|
||||
|
||||
function onConfirmFromCard () {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Confirmar',
|
||||
detail: 'Ação de confirmar (próximo passo: update no banco).',
|
||||
life: 2500
|
||||
})
|
||||
}
|
||||
|
||||
function onRescheduleFromCard () {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Remarcar',
|
||||
detail: 'Ação de remarcar (próximo passo: fluxo de reagendamento).',
|
||||
life: 2500
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Utils
|
||||
// -----------------------------
|
||||
function padTime(hhmmss, deltaMin) {
|
||||
// hh:mm:ss
|
||||
const [hh, mm, ss] = String(hhmmss || '00:00:00').split(':').map(Number)
|
||||
let total = (hh * 60 + mm) + deltaMin
|
||||
if (total < 0) total = 0
|
||||
if (total > 24 * 60) total = 24 * 60
|
||||
return minutesToDuration(total)
|
||||
}
|
||||
|
||||
async function onDialogSave ({ id, payload }) {
|
||||
try {
|
||||
if (id) {
|
||||
await update(id, payload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Evento atualizado.', life: 2500 })
|
||||
} else {
|
||||
await create(payload)
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Evento criado.', life: 2500 })
|
||||
}
|
||||
|
||||
dialogOpen.value = false
|
||||
|
||||
// recarrega o range atual
|
||||
await loadMyRange(
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao salvar.', life: 4500 })
|
||||
}
|
||||
}
|
||||
|
||||
async function onDialogDelete (id) {
|
||||
try {
|
||||
await remove(id)
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Evento removido.', life: 2500 })
|
||||
|
||||
dialogOpen.value = false
|
||||
await loadMyRange(
|
||||
new Date(currentRange.value.start).toISOString(),
|
||||
new Date(currentRange.value.end).toISOString()
|
||||
)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || 'Falha ao excluir.', life: 4500 })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4 md:p-6">
|
||||
<Toast />
|
||||
|
||||
<AgendaToolbar
|
||||
title="Minha agenda"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
@today="onToday"
|
||||
@prev="onPrev"
|
||||
@next="onNext"
|
||||
@changeView="onChangeView"
|
||||
@toggleMode="onToggleMode"
|
||||
@createSession="onCreateSession"
|
||||
@createBlock="onCreateBlock"
|
||||
@search="onSearch"
|
||||
/>
|
||||
|
||||
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr; align-items: stretch;">
|
||||
<div class="grid gap-3 md:gap-4 md:grid-cols-[1fr_380px]">
|
||||
<!-- LEFT: Calendar -->
|
||||
<AgendaCalendar
|
||||
ref="calendarRef"
|
||||
:view="view"
|
||||
:mode="mode"
|
||||
:timezone="timezone"
|
||||
:slotDuration="slotDuration"
|
||||
:slotMinTime="slotMinTime"
|
||||
:slotMaxTime="slotMaxTime"
|
||||
:businessHours="businessHours"
|
||||
:events="calendarEvents"
|
||||
:loading="loadingSettings || loadingEvents"
|
||||
@rangeChange="onRangeChange"
|
||||
@selectTime="onSelectTime"
|
||||
@eventClick="onEventClick"
|
||||
@eventDrop="onEventDrop"
|
||||
@eventResize="onEventResize"
|
||||
/>
|
||||
|
||||
<!-- RIGHT: Panel -->
|
||||
<AgendaRightPanel>
|
||||
<template #top>
|
||||
<AgendaNextSessionsCardList
|
||||
:items="nextSessions"
|
||||
@open="onOpenFromCard"
|
||||
@confirm="onConfirmFromCard"
|
||||
@reschedule="onRescheduleFromCard"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #bottom>
|
||||
<AgendaPulseCardGrid
|
||||
:stats="pulseStats"
|
||||
@quickBlock="onCreateBlock"
|
||||
@quickCreate="onCreateSession"
|
||||
/>
|
||||
</template>
|
||||
</AgendaRightPanel>
|
||||
</div>
|
||||
</div>
|
||||
<AgendaEventDialog
|
||||
v-model="dialogOpen"
|
||||
:eventRow="dialogEventRow"
|
||||
:initialStartISO="dialogStartISO"
|
||||
:initialEndISO="dialogEndISO"
|
||||
:ownerId="(settings?.owner_id || '')"
|
||||
@save="onDialogSave"
|
||||
@delete="onDialogDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
0
src/features/agenda/services/agenda.service.js
Normal file
0
src/features/agenda/services/agenda.service.js
Normal file
146
src/features/agenda/services/agendaMappers.js
Normal file
146
src/features/agenda/services/agendaMappers.js
Normal file
@@ -0,0 +1,146 @@
|
||||
// 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
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
const nowMs = now.getTime()
|
||||
return (rows || [])
|
||||
.filter((r) => new Date(r.fim_em).getTime() >= nowMs)
|
||||
.slice(0, 6)
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
startISO: r.inicio_em,
|
||||
endISO: r.fim_em,
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
pacienteId: r.paciente_id || null
|
||||
}))
|
||||
}
|
||||
|
||||
export function calcDefaultSlotDuration (settings) {
|
||||
const min =
|
||||
((settings?.usar_granularidade_custom && settings?.granularidade_min) || 0) ||
|
||||
settings?.admin_slot_visual_minutos ||
|
||||
30
|
||||
|
||||
return minutesToDuration(min)
|
||||
}
|
||||
|
||||
export function minutesToDuration (min) {
|
||||
const h = Math.floor(min / 60)
|
||||
const m = min % 60
|
||||
const hh = String(h).padStart(2, '0')
|
||||
const mm = String(m).padStart(2, '0')
|
||||
return `${hh}:${mm}:00`
|
||||
}
|
||||
|
||||
export function tituloFallback (tipo) {
|
||||
const t = String(tipo || '').toLowerCase()
|
||||
if (t.includes('sess')) return 'Sessão'
|
||||
if (t.includes('block') || t.includes('bloq')) return 'Bloqueio'
|
||||
if (t.includes('pessoal')) return 'Pessoal'
|
||||
if (t.includes('clin')) return 'Clínica'
|
||||
return 'Compromisso'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pausas semanais (jsonb) -> background events do FullCalendar.
|
||||
* Leitura flexível:
|
||||
* - esperado: [{ weekday: 1..7 ou 0..6, start:"HH:MM", end:"HH:MM", label }]
|
||||
*/
|
||||
export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd) {
|
||||
if (!Array.isArray(pausas) || pausas.length === 0) return []
|
||||
|
||||
const out = []
|
||||
const dayMs = 24 * 60 * 60 * 1000
|
||||
|
||||
for (let ts = startOfDay(rangeStart).getTime(); ts < rangeEnd.getTime(); ts += dayMs) {
|
||||
const d = new Date(ts)
|
||||
const dow = d.getDay() // 0..6
|
||||
|
||||
for (const p of pausas) {
|
||||
const wd = normalizeWeekday(p?.weekday)
|
||||
if (wd === null) continue
|
||||
if (wd !== dow) continue
|
||||
|
||||
const start = asTime(p?.start ?? p?.inicio ?? p?.from)
|
||||
const end = asTime(p?.end ?? p?.fim ?? p?.to)
|
||||
if (!start || !end) continue
|
||||
|
||||
out.push({
|
||||
id: `break-${ts}-${start}-${end}`,
|
||||
start: combineDateTimeISO(d, start),
|
||||
end: combineDateTimeISO(d, end),
|
||||
display: 'background',
|
||||
overlap: false,
|
||||
extendedProps: { kind: 'break', label: p?.label ?? 'Pausa' }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
function normalizeWeekday (value) {
|
||||
if (value === null || value === undefined) return null
|
||||
const n = Number(value)
|
||||
if (Number.isNaN(n)) return null
|
||||
if (n >= 0 && n <= 6) return n
|
||||
if (n >= 1 && n <= 7) return n === 7 ? 0 : n
|
||||
return null
|
||||
}
|
||||
|
||||
function asTime (v) {
|
||||
if (!v || typeof v !== 'string') return null
|
||||
const s = v.trim()
|
||||
if (/^\d{2}:\d{2}$/.test(s)) return `${s}:00`
|
||||
if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s
|
||||
return null
|
||||
}
|
||||
|
||||
function startOfDay (d) {
|
||||
const x = new Date(d)
|
||||
x.setHours(0, 0, 0, 0)
|
||||
return x
|
||||
}
|
||||
|
||||
function combineDateTimeISO (date, timeHHMMSS) {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}T${timeHHMMSS}`
|
||||
}
|
||||
98
src/features/agenda/services/agendaRepository.js
Normal file
98
src/features/agenda/services/agendaRepository.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// src/features/agenda/services/agendaRepository.js
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export async function getMyAgendaSettings () {
|
||||
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) throw userErr
|
||||
|
||||
const uid = userRes?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function listMyAgendaEvents ({ startISO, endISO }) {
|
||||
const { data: userRes, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) throw userErr
|
||||
|
||||
const uid = userRes?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function listClinicEvents ({ ownerIds, startISO, endISO }) {
|
||||
if (!ownerIds?.length) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.in('owner_id', ownerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function listTenantStaff (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('v_tenant_staff')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
export async function createAgendaEvento (payload) {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert(payload)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateAgendaEvento (id, patch) {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAgendaEvento (id) {
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
|
||||
if (error) throw error
|
||||
return true
|
||||
}
|
||||
1253
src/features/patients/PatientsListPage.vue
Normal file
1253
src/features/patients/PatientsListPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
1952
src/features/patients/cadastro/PatientsCadastroPage.vue
Normal file
1952
src/features/patients/cadastro/PatientsCadastroPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
381
src/features/patients/cadastro/PatientsExternalLinkPage.vue
Normal file
381
src/features/patients/cadastro/PatientsExternalLinkPage.vue
Normal file
@@ -0,0 +1,381 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 justify-start md:justify-end">
|
||||
<Button
|
||||
label="Gerar novo link"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading="rotating"
|
||||
@click="rotateLink"
|
||||
/>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</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 Message from 'primevue/message'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
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
|
||||
|
||||
const origin = computed(() => {
|
||||
if (PUBLIC_BASE_URL) return PUBLIC_BASE_URL
|
||||
return typeof window !== 'undefined' ? window.location.origin : ''
|
||||
})
|
||||
|
||||
const publicUrl = computed(() => {
|
||||
if (!inviteToken.value) return ''
|
||||
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
|
||||
})
|
||||
|
||||
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 () {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const uid = data?.user?.id
|
||||
if (!uid) throw new Error('Usuário não autenticado')
|
||||
return uid
|
||||
}
|
||||
|
||||
async function loadOrCreateInvite () {
|
||||
const uid = await requireUserId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_invites')
|
||||
.select('token, active')
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const token = data?.[0]?.token
|
||||
if (token) {
|
||||
inviteToken.value = token
|
||||
return
|
||||
}
|
||||
|
||||
const t = newToken()
|
||||
const { error: insErr } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
|
||||
if (insErr) throw insErr
|
||||
inviteToken.value = t
|
||||
}
|
||||
|
||||
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() })
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
if (e1) throw e1
|
||||
|
||||
const { error: e2 } = await supabase
|
||||
.from('patient_invites')
|
||||
.insert({ owner_id: uid, token: t, active: true })
|
||||
if (e2) throw e2
|
||||
}
|
||||
|
||||
inviteToken.value = t
|
||||
toast.add({ severity: 'success', summary: 'Pronto', detail: 'Novo link gerado.', life: 2000 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao gerar novo link.', life: 3500 })
|
||||
} finally {
|
||||
rotating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 })
|
||||
} catch {
|
||||
// fallback clássico
|
||||
window.prompt('Copie o link:', publicUrl.value)
|
||||
}
|
||||
}
|
||||
|
||||
function openLink () {
|
||||
if (!publicUrl.value) return
|
||||
window.open(publicUrl.value, '_blank', 'noopener')
|
||||
}
|
||||
|
||||
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}`
|
||||
await navigator.clipboard.writeText(msg)
|
||||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada.', life: 1500 })
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Não foi possível copiar automaticamente.', life: 2500 })
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadOrCreateInvite()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao carregar link.', life: 3500 })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,882 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
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 { brToISO, isoToBR } from '@/utils/dateBR'
|
||||
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const converting = ref(false)
|
||||
const loading = ref(false)
|
||||
const rows = ref([])
|
||||
const q = ref('')
|
||||
|
||||
const dlg = ref({
|
||||
open: false,
|
||||
saving: false,
|
||||
mode: 'view',
|
||||
item: null,
|
||||
reject_note: ''
|
||||
})
|
||||
|
||||
function statusSeverity (s) {
|
||||
if (s === 'new') return 'info'
|
||||
if (s === 'converted') return 'success'
|
||||
if (s === 'rejected') return 'danger'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function statusLabel (s) {
|
||||
if (s === 'new') return 'Novo'
|
||||
if (s === 'converted') return 'Convertido'
|
||||
if (s === 'rejected') return 'Rejeitado'
|
||||
return s || '—'
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Helpers de campo: PT primeiro, fallback EN
|
||||
// -----------------------------
|
||||
function pickField (obj, keys) {
|
||||
for (const k of keys) {
|
||||
const v = obj?.[k]
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '') return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const fNome = (i) => pickField(i, ['nome_completo', 'name'])
|
||||
const fEmail = (i) => pickField(i, ['email_principal', 'email'])
|
||||
const fEmailAlt = (i) => pickField(i, ['email_alternativo', 'email_alt'])
|
||||
const fTel = (i) => pickField(i, ['telefone', 'phone'])
|
||||
const fTelAlt = (i) => pickField(i, ['telefone_alternativo', 'phone_alt'])
|
||||
|
||||
const fNasc = (i) => pickField(i, ['data_nascimento', 'birth_date'])
|
||||
const fGenero = (i) => pickField(i, ['genero', 'gender'])
|
||||
const fEstadoCivil = (i) => pickField(i, ['estado_civil', 'marital_status'])
|
||||
const fProf = (i) => pickField(i, ['profissao', 'profession'])
|
||||
const fNacionalidade = (i) => pickField(i, ['nacionalidade', 'nationality'])
|
||||
const fNaturalidade = (i) => pickField(i, ['naturalidade', 'place_of_birth'])
|
||||
|
||||
const fEscolaridade = (i) => pickField(i, ['escolaridade', 'education_level'])
|
||||
const fOndeConheceu = (i) => pickField(i, ['onde_nos_conheceu', 'lead_source'])
|
||||
const fEncaminhado = (i) => pickField(i, ['encaminhado_por', 'referred_by'])
|
||||
|
||||
const fCep = (i) => pickField(i, ['cep'])
|
||||
const fEndereco = (i) => pickField(i, ['endereco', 'address_street'])
|
||||
const fNumero = (i) => pickField(i, ['numero', 'address_number'])
|
||||
const fComplemento = (i) => pickField(i, ['complemento', 'address_complement'])
|
||||
const fBairro = (i) => pickField(i, ['bairro', 'address_neighborhood'])
|
||||
const fCidade = (i) => pickField(i, ['cidade', 'address_city'])
|
||||
const fEstado = (i) => pickField(i, ['estado', 'address_state'])
|
||||
const fPais = (i) => pickField(i, ['pais', 'country']) || 'Brasil'
|
||||
|
||||
const fObs = (i) => pickField(i, ['observacoes', 'notes_short'])
|
||||
const fNotas = (i) => pickField(i, ['notas_internas', 'notes'])
|
||||
|
||||
// -----------------------------
|
||||
// Filtro
|
||||
// -----------------------------
|
||||
const statusFilter = ref('')
|
||||
|
||||
function toggleStatusFilter (s) {
|
||||
statusFilter.value = (statusFilter.value === s) ? '' : s
|
||||
}
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const term = String(q.value || '').trim().toLowerCase()
|
||||
let list = rows.value || []
|
||||
|
||||
if (statusFilter.value) {
|
||||
list = list.filter(r => r.status === statusFilter.value)
|
||||
}
|
||||
|
||||
if (!term) return list
|
||||
|
||||
return list.filter(r => {
|
||||
const nome = String(fNome(r) || '').toLowerCase()
|
||||
const email = String(fEmail(r) || '').toLowerCase()
|
||||
const tel = String(fTel(r) || '').toLowerCase()
|
||||
return nome.includes(term) || email.includes(term) || tel.includes(term)
|
||||
})
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Avatar
|
||||
// -----------------------------
|
||||
const AVATAR_BUCKET = 'avatars'
|
||||
|
||||
function firstNonEmpty (...vals) {
|
||||
for (const v of vals) {
|
||||
const s = String(v ?? '').trim()
|
||||
if (s) return s
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function looksLikeUrl (s) {
|
||||
return /^https?:\/\//i.test(String(s || ''))
|
||||
}
|
||||
|
||||
function getAvatarUrlFromItem (i) {
|
||||
const p = i?.payload || i?.data || i?.form || null
|
||||
|
||||
const direct = firstNonEmpty(
|
||||
i?.avatar_url, i?.foto_url, i?.photo_url,
|
||||
p?.avatar_url, p?.foto_url, p?.photo_url
|
||||
)
|
||||
|
||||
if (direct && looksLikeUrl(direct)) return direct
|
||||
|
||||
const path = firstNonEmpty(
|
||||
i?.avatar_path, i?.photo_path, i?.foto_path, i?.avatar_file_path,
|
||||
p?.avatar_path, p?.photo_path, p?.foto_path, p?.avatar_file_path,
|
||||
direct
|
||||
)
|
||||
|
||||
if (!path) return null
|
||||
if (looksLikeUrl(path)) return path
|
||||
|
||||
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path)
|
||||
return data?.publicUrl || null
|
||||
}
|
||||
|
||||
// cache simples pra não recalcular 2x por linha (render)
|
||||
const avatarCache = new Map()
|
||||
function avatarUrl (row) {
|
||||
const id = row?.id
|
||||
if (!id) return getAvatarUrlFromItem(row)
|
||||
if (avatarCache.has(id)) return avatarCache.get(id)
|
||||
const url = getAvatarUrlFromItem(row)
|
||||
avatarCache.set(id, url)
|
||||
return url
|
||||
}
|
||||
|
||||
const dlgAvatarUrl = computed(() => {
|
||||
const item = dlg.value?.item
|
||||
if (!item) return null
|
||||
return avatarUrl(item)
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Formatters
|
||||
// -----------------------------
|
||||
function dash (v) {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : '—'
|
||||
}
|
||||
|
||||
function onlyDigits (v) {
|
||||
return String(v ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function fmtPhoneBR (v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`
|
||||
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`
|
||||
return d
|
||||
}
|
||||
|
||||
function fmtCPF (v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
if (d.length !== 11) return d
|
||||
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`
|
||||
}
|
||||
|
||||
function fmtRG (v) {
|
||||
const s = String(v ?? '').trim()
|
||||
return s ? s : '—'
|
||||
}
|
||||
|
||||
// data nascimento (aceita ISO ou BR)
|
||||
function fmtBirth (v) {
|
||||
if (!v) return '—'
|
||||
const s = String(v).trim()
|
||||
|
||||
// já BR
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s
|
||||
|
||||
// ISO date/datetime
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const iso = s.slice(0, 10)
|
||||
return isoToBR(iso) || s
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
function fmtDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return String(iso)
|
||||
return d.toLocaleString('pt-BR')
|
||||
}
|
||||
|
||||
// converte nascimento para ISO date (YYYY-MM-DD) usando teu utils
|
||||
function normalizeBirthToISO (v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (!s) return null
|
||||
|
||||
// BR -> ISO
|
||||
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return brToISO(s)
|
||||
|
||||
// ISO date/datetime
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10)
|
||||
|
||||
// fallback: tenta Date
|
||||
const d = new Date(s)
|
||||
if (Number.isNaN(d.getTime())) return null
|
||||
const yyyy = String(d.getFullYear()).padStart(4, '0')
|
||||
const mm = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(d.getDate()).padStart(2, '0')
|
||||
return `${yyyy}-${mm}-${dd}`
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Seções do modal
|
||||
// -----------------------------
|
||||
const intakeSections = computed(() => {
|
||||
const i = dlg.value.item
|
||||
if (!i) return []
|
||||
|
||||
const section = (title, rows) => ({
|
||||
title,
|
||||
rows: (rows || []).filter(r => r && r.value !== undefined)
|
||||
})
|
||||
|
||||
const row = (label, value, opts = {}) => ({
|
||||
label,
|
||||
value,
|
||||
pre: !!opts.pre
|
||||
})
|
||||
|
||||
return [
|
||||
section('Identificação', [
|
||||
row('Nome completo', dash(fNome(i))),
|
||||
row('Email principal', dash(fEmail(i))),
|
||||
row('Email alternativo', dash(fEmailAlt(i))),
|
||||
row('Telefone', fmtPhoneBR(fTel(i))),
|
||||
row('Telefone alternativo', fmtPhoneBR(fTelAlt(i)))
|
||||
]),
|
||||
|
||||
section('Informações pessoais', [
|
||||
row('Data de nascimento', fmtBirth(fNasc(i))),
|
||||
row('Gênero', dash(fGenero(i))),
|
||||
row('Estado civil', dash(fEstadoCivil(i))),
|
||||
row('Profissão', dash(fProf(i))),
|
||||
row('Nacionalidade', dash(fNacionalidade(i))),
|
||||
row('Naturalidade', dash(fNaturalidade(i))),
|
||||
row('Escolaridade', dash(fEscolaridade(i))),
|
||||
row('Onde nos conheceu?', dash(fOndeConheceu(i))),
|
||||
row('Encaminhado por', dash(fEncaminhado(i)))
|
||||
]),
|
||||
|
||||
section('Documentos', [
|
||||
row('CPF', fmtCPF(i.cpf)),
|
||||
row('RG', fmtRG(i.rg))
|
||||
]),
|
||||
|
||||
section('Endereço', [
|
||||
row('CEP', dash(fCep(i))),
|
||||
row('Endereço', dash(fEndereco(i))),
|
||||
row('Número', dash(fNumero(i))),
|
||||
row('Complemento', dash(fComplemento(i))),
|
||||
row('Bairro', dash(fBairro(i))),
|
||||
row('Cidade', dash(fCidade(i))),
|
||||
row('Estado', dash(fEstado(i))),
|
||||
row('País', dash(fPais(i)))
|
||||
]),
|
||||
|
||||
section('Observações', [
|
||||
row('Observações', dash(fObs(i)), { pre: true }),
|
||||
row('Notas internas', dash(fNotas(i)), { pre: true })
|
||||
]),
|
||||
|
||||
section('Administração', [
|
||||
row('Status', statusLabel(i.status)),
|
||||
row('Consentimento', i.consent ? 'Aceito' : 'Não aceito'),
|
||||
row('Motivo da rejeição', dash(i.rejected_reason), { pre: true }),
|
||||
row('Paciente convertido (ID)', dash(i.converted_patient_id))
|
||||
]),
|
||||
|
||||
section('Metadados', [
|
||||
row('Owner ID', dash(i.owner_id)),
|
||||
row('Token', dash(i.token)),
|
||||
row('Criado em', fmtDate(i.created_at)),
|
||||
row('Atualizado em', fmtDate(i.updated_at)),
|
||||
row('ID do intake', dash(i.id))
|
||||
])
|
||||
]
|
||||
})
|
||||
|
||||
// -----------------------------
|
||||
// Fetch
|
||||
// -----------------------------
|
||||
async function fetchIntakes () {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9)
|
||||
|
||||
rows.value = (data || []).slice().sort((a, b) => {
|
||||
const wa = weight(a.status)
|
||||
const wb = weight(b.status)
|
||||
if (wa !== wb) return wa - wb
|
||||
const da = new Date(a.created_at || 0).getTime()
|
||||
const db = new Date(b.created_at || 0).getTime()
|
||||
return db - da
|
||||
})
|
||||
|
||||
avatarCache.clear()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Dialog
|
||||
// -----------------------------
|
||||
function openDetails (row) {
|
||||
dlg.value.open = true
|
||||
dlg.value.mode = 'view'
|
||||
dlg.value.item = row
|
||||
dlg.value.reject_note = row?.rejected_reason || ''
|
||||
}
|
||||
|
||||
function closeDlg () {
|
||||
dlg.value.open = false
|
||||
dlg.value.saving = false
|
||||
dlg.value.item = null
|
||||
dlg.value.reject_note = ''
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Rejeitar
|
||||
// -----------------------------
|
||||
async function markRejected () {
|
||||
const item = dlg.value.item
|
||||
if (!item) return
|
||||
|
||||
confirm.require({
|
||||
message: 'Marcar este cadastro como rejeitado?',
|
||||
header: 'Confirmar rejeição',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Rejeitar',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
dlg.value.saving = true
|
||||
try {
|
||||
const reason = String(dlg.value.reject_note || '').trim() || null
|
||||
|
||||
const { error } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.update({
|
||||
status: 'rejected',
|
||||
rejected_reason: reason,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', item.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Rejeitado', detail: 'Solicitação rejeitada.', life: 2500 })
|
||||
await fetchIntakes()
|
||||
|
||||
const updated = rows.value.find(r => r.id === item.id)
|
||||
if (updated) openDetails(updated)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 3500 })
|
||||
} finally {
|
||||
dlg.value.saving = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Converter
|
||||
// -----------------------------
|
||||
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') {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Só é possível converter cadastros com status "Novo".',
|
||||
life: 3000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
converting.value = true
|
||||
|
||||
try {
|
||||
const { data: userData, error: userErr } = await supabase.auth.getUser()
|
||||
if (userErr) throw userErr
|
||||
|
||||
const ownerId = userData?.user?.id
|
||||
if (!ownerId) throw new Error('Sessão inválida.')
|
||||
|
||||
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)
|
||||
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null
|
||||
|
||||
const patientPayload = {
|
||||
owner_id: ownerId,
|
||||
|
||||
// identificação/contato
|
||||
nome_completo: cleanStr(fNome(item)),
|
||||
email_principal: cleanStr(fEmail(item))?.toLowerCase() || null,
|
||||
email_alternativo: cleanStr(fEmailAlt(item))?.toLowerCase() || null,
|
||||
|
||||
telefone: digitsOnly(fTel(item)),
|
||||
telefone_alternativo: digitsOnly(fTelAlt(item)),
|
||||
|
||||
// pessoais
|
||||
data_nascimento: normalizeBirthToISO(fNasc(item)), // ✅ agora é sempre ISO date
|
||||
naturalidade: cleanStr(fNaturalidade(item)),
|
||||
genero: cleanStr(fGenero(item)),
|
||||
estado_civil: cleanStr(fEstadoCivil(item)),
|
||||
|
||||
// docs
|
||||
cpf: digitsOnly(item.cpf),
|
||||
rg: cleanStr(item.rg),
|
||||
|
||||
// endereço (PT)
|
||||
pais: cleanStr(fPais(item)) || 'Brasil',
|
||||
cep: digitsOnly(fCep(item)),
|
||||
cidade: cleanStr(fCidade(item)),
|
||||
estado: cleanStr(fEstado(item)) || 'SP',
|
||||
endereco: cleanStr(fEndereco(item)),
|
||||
numero: cleanStr(fNumero(item)),
|
||||
bairro: cleanStr(fBairro(item)),
|
||||
complemento: cleanStr(fComplemento(item)),
|
||||
|
||||
// adicionais (PT)
|
||||
escolaridade: cleanStr(fEscolaridade(item)),
|
||||
profissao: cleanStr(fProf(item)),
|
||||
onde_nos_conheceu: cleanStr(fOndeConheceu(item)),
|
||||
encaminhado_por: cleanStr(fEncaminhado(item)),
|
||||
|
||||
// observações (PT)
|
||||
observacoes: cleanStr(fObs(item)),
|
||||
notas_internas: cleanStr(fNotas(item)),
|
||||
|
||||
// avatar
|
||||
avatar_url: intakeAvatar
|
||||
}
|
||||
|
||||
// remove undefined
|
||||
Object.keys(patientPayload).forEach(k => {
|
||||
if (patientPayload[k] === undefined) delete patientPayload[k]
|
||||
})
|
||||
|
||||
const { data: created, error: insErr } = await supabase
|
||||
.from('patients')
|
||||
.insert(patientPayload)
|
||||
.select('id')
|
||||
.single()
|
||||
|
||||
if (insErr) throw insErr
|
||||
|
||||
const patientId = created?.id
|
||||
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.')
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('patient_intake_requests')
|
||||
.update({
|
||||
status: 'converted',
|
||||
converted_patient_id: patientId,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', item.id)
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 })
|
||||
|
||||
dlg.value.open = false
|
||||
await fetchIntakes()
|
||||
} catch (err) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Falha ao converter',
|
||||
detail: err?.message || 'Não foi possível converter o cadastro.',
|
||||
life: 4500
|
||||
})
|
||||
} finally {
|
||||
converting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const totals = computed(() => {
|
||||
const all = rows.value || []
|
||||
const total = all.length
|
||||
const nNew = all.filter(r => r.status === 'new').length
|
||||
const nConv = all.filter(r => r.status === 'converted').length
|
||||
const nRej = all.filter(r => r.status === 'rejected').length
|
||||
return { total, nNew, nConv, nRej }
|
||||
})
|
||||
|
||||
onMounted(fetchIntakes)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</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" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="filteredRows"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
responsiveLayout="scroll"
|
||||
stripedRows
|
||||
class="!border-0"
|
||||
>
|
||||
<Column header="Status" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Paciente">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar v-if="avatarUrl(data)" :image="avatarUrl(data)" shape="circle" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
|
||||
<div class="text-color-secondary text-sm truncate">{{ fEmail(data) || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Contato" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="text-sm">
|
||||
<div class="font-medium">{{ fmtPhoneBR(fTel(data)) }}</div>
|
||||
<div class="text-color-secondary">{{ fTelAlt(data) ? fmtPhoneBR(fTelAlt(data)) : '—' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Criado em" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ fmtDate(data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="" style="width: 10rem; text-align: right">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
icon="pi pi-eye"
|
||||
label="Ver"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="openDetails(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-6 text-center">
|
||||
Nenhum cadastro encontrado.
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- MODAL -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
modal
|
||||
:header="null"
|
||||
:style="{ width: 'min(940px, 96vw)' }"
|
||||
:contentStyle="{ padding: 0 }"
|
||||
@hide="closeDlg"
|
||||
>
|
||||
<div v-if="dlg.item" class="relative">
|
||||
<div class="max-h-[70vh] overflow-auto p-5 bg-[var(--surface-ground)]">
|
||||
<!-- topo -->
|
||||
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 mb-4">
|
||||
<div class="flex flex-col items-center text-center gap-3">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300"></div>
|
||||
<div class="relative">
|
||||
<Avatar v-if="dlgAvatarUrl" :image="dlgAvatarUrl" alt="avatar" shape="circle" size="xlarge" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
<div class="text-xl font-semibold text-slate-900 truncate">
|
||||
{{ fNome(dlg.item) || '—' }}
|
||||
</div>
|
||||
<div class="text-slate-500 text-sm truncate">
|
||||
{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
|
||||
<Tag
|
||||
:value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'"
|
||||
:severity="dlg.item.consent ? 'success' : 'danger'"
|
||||
/>
|
||||
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="(sec, sidx) in intakeSections"
|
||||
:key="sidx"
|
||||
class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4"
|
||||
>
|
||||
<div class="font-semibold text-slate-900 mb-3">
|
||||
{{ sec.title }}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div
|
||||
v-for="(r, ridx) in sec.rows"
|
||||
:key="ridx"
|
||||
class="min-w-0"
|
||||
>
|
||||
<div class="text-xs text-slate-500 mb-1">
|
||||
{{ r.label }}
|
||||
</div>
|
||||
<div
|
||||
class="text-sm text-slate-900"
|
||||
:class="r.pre ? 'whitespace-pre-wrap leading-relaxed' : 'truncate'"
|
||||
>
|
||||
{{ r.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- rejeição: nota -->
|
||||
<div class="mt-5 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-4">
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="font-semibold text-slate-900">Rejeição</div>
|
||||
<Tag
|
||||
:value="dlg.item.status === 'rejected' ? 'Este cadastro já foi rejeitado' : 'Opcional'"
|
||||
:severity="dlg.item.status === 'rejected' ? 'danger' : 'secondary'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label class="block text-sm text-slate-600 mb-2">Motivo (anotação interna)</label>
|
||||
<Textarea
|
||||
v-model="dlg.reject_note"
|
||||
autoResize
|
||||
rows="2"
|
||||
class="w-full"
|
||||
:disabled="dlg.saving || converting"
|
||||
placeholder="Ex.: dados incompletos, pediu para não seguir, duplicado…"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-24"></div>
|
||||
</div>
|
||||
|
||||
<!-- ações fixas -->
|
||||
<div class="sticky bottom-0 z-10 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<div class="px-5 py-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 justify-end flex-wrap">
|
||||
<Button
|
||||
label="Rejeitar"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled="dlg.saving || dlg.item.status === 'rejected' || converting"
|
||||
@click="markRejected"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Converter"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
:loading="converting"
|
||||
:disabled="dlg.item.status === 'converted' || dlg.saving || converting"
|
||||
@click="convertToPatient"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Fechar"
|
||||
icon="pi pi-times-circle"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="dlg.saving || converting"
|
||||
@click="closeDlg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
664
src/features/patients/grupos/GruposPacientesPage.vue
Normal file
664
src/features/patients/grupos/GruposPacientesPage.vue
Normal file
@@ -0,0 +1,664 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- LEFT: TABLE -->
|
||||
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<DataTable
|
||||
ref="dt"
|
||||
v-model:selection="selectedGroups"
|
||||
:value="groups"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[5, 10, 25]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
paginatorTemplate="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
|
||||
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>
|
||||
</template>
|
||||
|
||||
<!-- seleção (desabilita grupos do sistema) -->
|
||||
<Column selectionMode="multiple" style="width: 3rem" :exportable="false">
|
||||
<template #body="{ data }">
|
||||
<Checkbox
|
||||
:binary="true"
|
||||
:disabled="!!data.is_system"
|
||||
:modelValue="isSelected(data)"
|
||||
@update:modelValue="toggleRowSelection(data, $event)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 16rem" />
|
||||
|
||||
<Column header="Origem" sortable sortField="is_system" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data.is_system ? 'Padrão' : 'Criado por você'"
|
||||
:severity="data.is_system ? 'info' : 'success'"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Pacientes" sortable sortField="patients_count" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">
|
||||
{{ patientsLabel(Number(data.patients_count ?? data.patient_count ?? 0)) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :exportable="false" header="Ações" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button
|
||||
v-if="!data.is_system"
|
||||
icon="pi pi-pencil"
|
||||
outlined
|
||||
rounded
|
||||
@click="openEdit(data)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!data.is_system"
|
||||
icon="pi pi-trash"
|
||||
outlined
|
||||
rounded
|
||||
severity="danger"
|
||||
@click="confirmDeleteOne(data)"
|
||||
/>
|
||||
<Button
|
||||
v-if="data.is_system"
|
||||
icon="pi pi-lock"
|
||||
outlined
|
||||
rounded
|
||||
disabled
|
||||
v-tooltip.top="'Grupo padrão do sistema (inalterável)'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
Nenhum grupo encontrado.
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: CARDS -->
|
||||
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
|
||||
<Card class="h-full">
|
||||
<template #title>Pacientes por grupo</template>
|
||||
<template #subtitle>Os cards aparecem apenas quando há 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-users text-3xl"></i>
|
||||
<div class="mt-1 font-medium">Sem pacientes associados</div>
|
||||
<small class="text-color-secondary">
|
||||
Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="g in cards"
|
||||
:key="g.id"
|
||||
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
|
||||
@mouseenter="hovered = g.id"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<div class="flex justify-between items-start gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="font-bold truncate max-w-[230px]">
|
||||
{{ g.nome }}
|
||||
</div>
|
||||
<small class="text-color-secondary">
|
||||
{{ patientsLabel(Number(g.patients_count ?? g.patient_count ?? 0)) }}
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<Tag
|
||||
:value="g.is_system ? 'Padrão' : 'Criado por você'"
|
||||
:severity="g.is_system ? 'info' : 'success'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hovered === g.id"
|
||||
class="absolute inset-0 rounded-xl bg-emerald-500/15 backdrop-blur-sm flex items-center justify-center"
|
||||
>
|
||||
<Button
|
||||
label="Ver pacientes"
|
||||
icon="pi pi-users"
|
||||
severity="success"
|
||||
:disabled="!(g.patients_count ?? g.patient_count)"
|
||||
@click="openGroupPatientsModal(g)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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>
|
||||
|
||||
<!-- ✅ DIALOG PACIENTES (com botão Abrir) -->
|
||||
<Dialog
|
||||
v-model:visible="patientsDialog.open"
|
||||
:header="patientsDialog.group?.nome ? `Pacientes do grupo: ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
|
||||
modal
|
||||
:style="{ width: '900px', maxWidth: '95vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-color-secondary">
|
||||
Grupo: <span class="font-medium text-color">{{ patientsDialog.group?.nome || '—' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<IconField class="w-full md:w-80">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText
|
||||
v-model="patientsDialog.search"
|
||||
placeholder="Buscar paciente..."
|
||||
class="w-full"
|
||||
:disabled="patientsDialog.loading"
|
||||
/>
|
||||
</IconField>
|
||||
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="patientsDialog.loading" class="text-color-secondary">Carregando…</div>
|
||||
|
||||
<Message v-else-if="patientsDialog.error" severity="error">
|
||||
{{ patientsDialog.error }}
|
||||
</Message>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="patientsDialog.items.length === 0" class="text-color-secondary">
|
||||
Nenhum paciente associado a este grupo.
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<DataTable
|
||||
:value="patientsDialogFiltered"
|
||||
dataKey="id"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
paginator
|
||||
:rows="8"
|
||||
:rowsPerPageOptions="[8, 15, 30]"
|
||||
>
|
||||
<Column header="Paciente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
|
||||
<Avatar v-else :label="initials(data.full_name)" shape="circle" />
|
||||
<div class="min-w-0">
|
||||
<div class="font-medium truncate max-w-[420px]">{{ data.full_name }}</div>
|
||||
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="min-width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ fmtPhone(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
label="Abrir"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
outlined
|
||||
@click="abrirPaciente(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum resultado para "{{ patientsDialog.search }}".</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="patientsDialog.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
|
||||
import Checkbox from 'primevue/checkbox'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import {
|
||||
listGroupsWithCounts,
|
||||
createGroup,
|
||||
updateGroup,
|
||||
deleteGroup
|
||||
} from '@/services/GruposPacientes.service.js'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const dt = ref(null)
|
||||
const loading = ref(false)
|
||||
const groups = ref([])
|
||||
const selectedGroups = ref([])
|
||||
const hovered = ref(null)
|
||||
|
||||
const filters = ref({
|
||||
global: { value: null, matchMode: 'contains' }
|
||||
})
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
saving: false
|
||||
})
|
||||
|
||||
const patientsDialog = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
group: null,
|
||||
items: [],
|
||||
search: ''
|
||||
})
|
||||
|
||||
function applyRealCountsToGroups (groupsArr, countMap) {
|
||||
return (groupsArr || []).map(g => ({
|
||||
...g,
|
||||
patients_count: Number(countMap[g.id] || 0) // força a verdade aqui
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchRealGroupCountsForOwner () {
|
||||
const ownerId = (await supabase.auth.getUser())?.data?.user?.id
|
||||
if (!ownerId) throw new Error('Sessão inválida.')
|
||||
|
||||
// Busca todas as associações (group <-> patient) apenas de pacientes do owner logado
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select(`
|
||||
patient_group_id,
|
||||
patient:patients!inner (
|
||||
id,
|
||||
owner_id
|
||||
)
|
||||
`)
|
||||
.eq('patient.owner_id', ownerId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
// Conta em JS por group_id
|
||||
const map = Object.create(null)
|
||||
for (const row of (data || [])) {
|
||||
const gid = row.patient_group_id
|
||||
if (!gid) continue
|
||||
map[gid] = (map[gid] || 0) + 1
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
const cards = computed(() => {
|
||||
const arr = groups.value || []
|
||||
return arr
|
||||
.filter(g => {
|
||||
const raw = g.patients_count ?? g.patient_count ?? 0
|
||||
const n = Number.parseInt(String(raw), 10)
|
||||
return Number.isFinite(n) && n > 0
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const na = Number.parseInt(String(a.patients_count ?? a.patient_count ?? 0), 10) || 0
|
||||
const nb = Number.parseInt(String(b.patients_count ?? b.patient_count ?? 0), 10) || 0
|
||||
return nb - na
|
||||
})
|
||||
})
|
||||
|
||||
const patientsDialogFiltered = computed(() => {
|
||||
const s = String(patientsDialog.search || '').trim().toLowerCase()
|
||||
if (!s) return patientsDialog.items || []
|
||||
return (patientsDialog.items || []).filter(p => {
|
||||
const name = String(p.full_name || '').toLowerCase()
|
||||
const email = String(p.email || '').toLowerCase()
|
||||
const phone = String(p.phone || '').toLowerCase()
|
||||
return name.includes(s) || email.includes(s) || phone.includes(s)
|
||||
})
|
||||
})
|
||||
|
||||
function patientsLabel (n) {
|
||||
return n === 1 ? '1 paciente' : `${n} pacientes`
|
||||
}
|
||||
|
||||
function humanizeError (err) {
|
||||
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
|
||||
const code = err?.code
|
||||
if (code === '23505' || /duplicate key value/i.test(msg)) {
|
||||
return 'Já existe um grupo com esse nome (para você). Tente outro nome.'
|
||||
}
|
||||
if (/Grupo padrão/i.test(msg)) {
|
||||
return 'Esse é um grupo padrão do sistema e não pode ser alterado.'
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
async function fetchAll () {
|
||||
loading.value = true
|
||||
try {
|
||||
// 1) carrega grupos (com ou sem count vindo do service)
|
||||
const baseGroups = await listGroupsWithCounts()
|
||||
|
||||
// 2) recalcula counts reais (por owner) e sobrescreve
|
||||
const realCountMap = await fetchRealGroupCountsForOwner()
|
||||
groups.value = applyRealCountsToGroups(baseGroups, realCountMap)
|
||||
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Seleção: ignora grupos do sistema
|
||||
-------------------------------- */
|
||||
function isSelected (row) {
|
||||
return (selectedGroups.value || []).some(s => s.id === row.id)
|
||||
}
|
||||
|
||||
function toggleRowSelection (row, checked) {
|
||||
if (row.is_system) return
|
||||
const sel = selectedGroups.value || []
|
||||
if (checked) {
|
||||
if (!sel.some(s => s.id === row.id)) selectedGroups.value = [...sel, row]
|
||||
} else {
|
||||
selectedGroups.value = sel.filter(s => s.id !== row.id)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
CRUD
|
||||
-------------------------------- */
|
||||
function openCreate () {
|
||||
dlg.open = true
|
||||
dlg.mode = 'create'
|
||||
dlg.id = ''
|
||||
dlg.nome = ''
|
||||
}
|
||||
|
||||
function openEdit (row) {
|
||||
dlg.open = true
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
dlg.nome = row.nome
|
||||
}
|
||||
|
||||
async function saveDialog () {
|
||||
const nome = String(dlg.nome || '').trim()
|
||||
if (!nome) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 })
|
||||
return
|
||||
}
|
||||
if (nome.length < 2) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 })
|
||||
return
|
||||
}
|
||||
|
||||
dlg.saving = true
|
||||
try {
|
||||
if (dlg.mode === 'create') {
|
||||
await createGroup(nome)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
|
||||
} else {
|
||||
await updateGroup(dlg.id, nome)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo atualizado.', life: 2500 })
|
||||
}
|
||||
dlg.open = false
|
||||
await fetchAll()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
} finally {
|
||||
dlg.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteOne (row) {
|
||||
confirm.require({
|
||||
message: `Excluir "${row.nome}"?`,
|
||||
header: 'Excluir grupo',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
await deleteGroup(row.id)
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo excluído.', life: 2500 })
|
||||
await fetchAll()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function confirmDeleteSelected () {
|
||||
const sel = selectedGroups.value || []
|
||||
if (!sel.length) return
|
||||
|
||||
const deletables = sel.filter(g => !g.is_system)
|
||||
const blocked = sel.filter(g => g.is_system)
|
||||
|
||||
if (!deletables.length) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atenção',
|
||||
detail: 'Os itens selecionados são grupos do sistema e não podem ser excluídos.',
|
||||
life: 3500
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const msgBlocked = blocked.length ? ` (${blocked.length} grupo(s) padrão serão ignorados)` : ''
|
||||
|
||||
confirm.require({
|
||||
message: `Excluir ${deletables.length} grupo(s) selecionado(s)?${msgBlocked}`,
|
||||
header: 'Excluir selecionados',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => {
|
||||
try {
|
||||
for (const g of deletables) await deleteGroup(g.id)
|
||||
selectedGroups.value = []
|
||||
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Exclusão concluída.', life: 2500 })
|
||||
await fetchAll()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Helpers (avatar/telefone)
|
||||
-------------------------------- */
|
||||
function initials (name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||
if (!parts.length) return '—'
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
|
||||
}
|
||||
|
||||
function onlyDigits (v) {
|
||||
return String(v ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function fmtPhone (v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7)}`
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6)}`
|
||||
return d
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Modal: Pacientes do Grupo
|
||||
-------------------------------- */
|
||||
async function openGroupPatientsModal (groupRow) {
|
||||
patientsDialog.open = true
|
||||
patientsDialog.loading = true
|
||||
patientsDialog.error = ''
|
||||
patientsDialog.group = groupRow
|
||||
patientsDialog.items = []
|
||||
patientsDialog.search = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('patient_group_patient')
|
||||
.select(`
|
||||
patient_id,
|
||||
patient:patients (
|
||||
id,
|
||||
nome_completo,
|
||||
email_principal,
|
||||
telefone,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('patient_group_id', groupRow.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const patients = (data || [])
|
||||
.map(r => r.patient)
|
||||
.filter(Boolean)
|
||||
|
||||
patientsDialog.items = patients
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
full_name: p.nome_completo || '—',
|
||||
email: p.email_principal || '—',
|
||||
phone: p.telefone || '—',
|
||||
avatar_url: p.avatar_url || null
|
||||
}))
|
||||
.sort((a, b) => String(a.full_name).localeCompare(String(b.full_name), 'pt-BR'))
|
||||
} catch (err) {
|
||||
patientsDialog.error = humanizeError(err)
|
||||
} finally {
|
||||
patientsDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) {
|
||||
router.push(`/features/patients/cadastro/${patient.id}`)
|
||||
}
|
||||
|
||||
onMounted(fetchAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity .14s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
1005
src/features/patients/prontuario/PatientProntuario.vue
Normal file
1005
src/features/patients/prontuario/PatientProntuario.vue
Normal file
File diff suppressed because it is too large
Load Diff
815
src/features/patients/tags/TagsPage.vue
Normal file
815
src/features/patients/tags/TagsPage.vue
Normal file
@@ -0,0 +1,815 @@
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-4">
|
||||
<!-- LEFT: tabela -->
|
||||
<div class="w-full lg:basis-[70%] lg:max-w-[70%]">
|
||||
<Card class="h-full">
|
||||
<template #content>
|
||||
<DataTable
|
||||
ref="dt"
|
||||
:value="etiquetas"
|
||||
dataKey="id"
|
||||
:loading="carregando"
|
||||
paginator
|
||||
:rows="10"
|
||||
:rowsPerPageOptions="[10, 20, 50]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
:filters="filtros"
|
||||
filterDisplay="menu"
|
||||
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 }">
|
||||
<Checkbox
|
||||
:binary="true"
|
||||
:disabled="!!data.is_padrao"
|
||||
:modelValue="estaSelecionada(data)"
|
||||
@update:modelValue="alternarSelecao(data, $event)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="nome" header="Tag" sortable style="min-width: 18rem;">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="inline-block rounded-full"
|
||||
:style="{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: data.cor || '#94a3b8'
|
||||
}"
|
||||
/>
|
||||
<span class="font-medium truncate">{{ data.nome }}</span>
|
||||
<span v-if="data.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Pacientes" sortable sortField="pacientes_count" style="width: 10rem;">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
class="p-0"
|
||||
link
|
||||
:label="String(data.pacientes_count ?? 0)"
|
||||
:disabled="Number(data.pacientes_count ?? 0) <= 0"
|
||||
@click="abrirModalPacientesDaTag(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 10.5rem;">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="data.is_padrao"
|
||||
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser editadas' : 'Editar'"
|
||||
@click="abrirEditar(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
size="small"
|
||||
:disabled="data.is_padrao"
|
||||
v-tooltip.top="data.is_padrao ? 'Tags padrão não podem ser excluídas' : 'Excluir'"
|
||||
@click="confirmarExclusaoUma(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhuma tag encontrada.</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: cards -->
|
||||
<div class="w-full lg:basis-[30%] lg:max-w-[30%]">
|
||||
<Card class="h-full">
|
||||
<template #title>Mais usadas</template>
|
||||
<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>
|
||||
|
||||
<div v-else class="flex flex-col gap-3">
|
||||
<div
|
||||
v-for="t in cards"
|
||||
:key="t.id"
|
||||
class="relative p-4 rounded-xl border border-[var(--surface-border)] transition-all duration-150 hover:-translate-y-1 hover:shadow-[var(--card-shadow)]"
|
||||
@mouseenter="hovered = t.id"
|
||||
@mouseleave="hovered = null"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
class="inline-block rounded-full"
|
||||
:style="{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
background: t.cor || '#94a3b8'
|
||||
}"
|
||||
/>
|
||||
<div class="font-semibold truncate">{{ t.nome }}</div>
|
||||
<span v-if="t.is_padrao" class="text-xs text-color-secondary">(padrão)</span>
|
||||
</div>
|
||||
<div class="text-sm text-color-secondary mt-1">
|
||||
{{ Number(t.pacientes_count ?? 0) }} paciente(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition name="fade">
|
||||
<div v-if="hovered === t.id" class="flex items-center justify-content-center">
|
||||
<Button
|
||||
label="Ver pacientes"
|
||||
icon="pi pi-users"
|
||||
severity="success"
|
||||
:disabled="Number(t.pacientes_count ?? 0) <= 0"
|
||||
@click.stop="abrirModalPacientesDaTag(t)"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- DIALOG CREATE / EDIT -->
|
||||
<Dialog
|
||||
v-model:visible="dlg.open"
|
||||
:header="dlg.mode === 'create' ? 'Criar Tag' : 'Editar Tag'"
|
||||
modal
|
||||
:style="{ width: '520px', maxWidth: '92vw' }"
|
||||
>
|
||||
<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"
|
||||
/>
|
||||
|
||||
<span
|
||||
class="inline-block rounded-lg"
|
||||
:style="{
|
||||
width: '34px',
|
||||
height: '34px',
|
||||
border: '1px solid var(--surface-border)',
|
||||
background: corPreview(dlg.cor)
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<small class="text-color-secondary">
|
||||
Pode usar HEX (#rrggbb). Se vazio, usamos uma cor neutra.
|
||||
</small>
|
||||
</div>
|
||||
</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>
|
||||
</Dialog>
|
||||
|
||||
<!-- MODAL: pacientes da tag -->
|
||||
<Dialog
|
||||
v-model:visible="modalPacientes.open"
|
||||
:header="modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'"
|
||||
modal
|
||||
:style="{ width: '900px', maxWidth: '96vw' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
|
||||
<IconField class="w-full md:w-80">
|
||||
<InputIcon><i class="pi pi-search" /></InputIcon>
|
||||
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" />
|
||||
</IconField>
|
||||
|
||||
<div class="flex items-center gap-2 justify-end">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined @click="recarregarModalPacientes" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="modalPacientes.error" severity="error">
|
||||
{{ modalPacientes.error }}
|
||||
</Message>
|
||||
|
||||
<DataTable
|
||||
:value="modalPacientesFiltrado"
|
||||
:loading="modalPacientes.loading"
|
||||
dataKey="id"
|
||||
paginator
|
||||
:rows="8"
|
||||
:rowsPerPageOptions="[8, 15, 30]"
|
||||
stripedRows
|
||||
responsiveLayout="scroll"
|
||||
>
|
||||
<Column field="name" header="Paciente" sortable>
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-3">
|
||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
|
||||
<Avatar v-else icon="pi pi-user" shape="circle" />
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span class="font-medium truncate">{{ data.name }}</span>
|
||||
<small class="text-color-secondary truncate">{{ data.email || '—' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Telefone" style="width: 14rem;">
|
||||
<template #body="{ data }">
|
||||
<span class="text-color-secondary">{{ fmtPhoneBR(data.phone) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ações" style="width: 12rem;">
|
||||
<template #body="{ data }">
|
||||
<Button
|
||||
label="Abrir"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
outlined
|
||||
@click="abrirPaciente(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-color-secondary py-5">Nenhum paciente encontrado.</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" icon="pi pi-times" text @click="modalPacientes.open = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } 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 { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
|
||||
const dt = ref(null)
|
||||
const carregando = ref(false)
|
||||
|
||||
const etiquetas = ref([])
|
||||
const etiquetasSelecionadas = ref([])
|
||||
const hovered = ref(null)
|
||||
|
||||
const filtros = ref({
|
||||
global: { value: null, matchMode: 'contains' }
|
||||
})
|
||||
|
||||
const dlg = reactive({
|
||||
open: false,
|
||||
mode: 'create', // 'create' | 'edit'
|
||||
id: '',
|
||||
nome: '',
|
||||
cor: '',
|
||||
saving: false
|
||||
})
|
||||
|
||||
const modalPacientes = reactive({
|
||||
open: false,
|
||||
loading: false,
|
||||
error: '',
|
||||
tag: null,
|
||||
items: [],
|
||||
search: ''
|
||||
})
|
||||
|
||||
const cards = computed(() =>
|
||||
(etiquetas.value || [])
|
||||
.filter(t => Number(t.pacientes_count ?? 0) > 0)
|
||||
.sort((a, b) => Number(b.pacientes_count ?? 0) - Number(a.pacientes_count ?? 0))
|
||||
)
|
||||
|
||||
const modalPacientesFiltrado = computed(() => {
|
||||
const s = String(modalPacientes.search || '').trim().toLowerCase()
|
||||
if (!s) return modalPacientes.items || []
|
||||
return (modalPacientes.items || []).filter(p => {
|
||||
const name = String(p.name || '').toLowerCase()
|
||||
const email = String(p.email || '').toLowerCase()
|
||||
const phone = String(p.phone || '').toLowerCase()
|
||||
return name.includes(s) || email.includes(s) || phone.includes(s)
|
||||
})
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
buscarEtiquetas()
|
||||
})
|
||||
|
||||
async function getOwnerId() {
|
||||
const { data, error } = await supabase.auth.getUser()
|
||||
if (error) throw error
|
||||
const user = data?.user
|
||||
if (!user) throw new Error('Você precisa estar logado.')
|
||||
return user.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)
|
||||
const nome = r?.nome ?? r?.name ?? ''
|
||||
const cor = r?.cor ?? r?.color ?? null
|
||||
const is_padrao = Boolean(r?.is_padrao ?? r?.is_native ?? false)
|
||||
|
||||
const pacientes_count = Number(
|
||||
r?.pacientes_count ?? r?.patient_count ?? r?.patients_count ?? 0
|
||||
)
|
||||
|
||||
return {
|
||||
...r,
|
||||
nome,
|
||||
cor,
|
||||
is_padrao,
|
||||
pacientes_count
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueViolation(e) {
|
||||
return e?.code === '23505' || /duplicate key value/i.test(String(e?.message || ''))
|
||||
}
|
||||
|
||||
function friendlyDupMessage(nome) {
|
||||
return `Já existe uma tag chamada “${nome}”. Tente outro nome.`
|
||||
}
|
||||
|
||||
function corPreview(raw) {
|
||||
const r = String(raw || '').trim()
|
||||
if (!r) return '#94a3b8'
|
||||
const hex = r.replace('#', '')
|
||||
return `#${hex}`
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Seleção (bloqueia tags padrão)
|
||||
-------------------------------- */
|
||||
function estaSelecionada(row) {
|
||||
return (etiquetasSelecionadas.value || []).some(s => s.id === row.id)
|
||||
}
|
||||
|
||||
function alternarSelecao(row, checked) {
|
||||
if (row.is_padrao) return
|
||||
const sel = etiquetasSelecionadas.value || []
|
||||
if (checked) {
|
||||
if (!sel.some(s => s.id === row.id)) etiquetasSelecionadas.value = [...sel, row]
|
||||
} else {
|
||||
etiquetasSelecionadas.value = sel.filter(s => s.id !== row.id)
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Fetch tags
|
||||
-------------------------------- */
|
||||
async function buscarEtiquetas() {
|
||||
carregando.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// 1) tenta view (contagem pronta)
|
||||
const v = await supabase
|
||||
.from('v_tag_patient_counts')
|
||||
.select('*')
|
||||
.eq('owner_id', ownerId)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
if (!v.error) {
|
||||
etiquetas.value = (v.data || []).map(normalizarEtiquetaRow)
|
||||
return
|
||||
}
|
||||
|
||||
// 2) fallback tabela
|
||||
const t = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id, owner_id, nome, cor, is_padrao, name, color, is_native, created_at, updated_at')
|
||||
.eq('owner_id', ownerId)
|
||||
.order('nome', { ascending: true })
|
||||
|
||||
// se der erro porque ainda não tem 'nome', tenta por 'name'
|
||||
if (t.error && /column .*nome/i.test(String(t.error.message || ''))) {
|
||||
const t2 = await supabase
|
||||
.from('patient_tags')
|
||||
.select('id, owner_id, name, color, is_native, created_at, updated_at')
|
||||
.eq('owner_id', ownerId)
|
||||
.order('name', { ascending: true })
|
||||
if (t2.error) throw t2.error
|
||||
etiquetas.value = (t2.data || []).map(r => normalizarEtiquetaRow({ ...r, patient_count: 0 }))
|
||||
return
|
||||
}
|
||||
|
||||
if (t.error) throw t.error
|
||||
|
||||
etiquetas.value = (t.data || []).map(r => normalizarEtiquetaRow({ ...r, pacientes_count: 0 }))
|
||||
} catch (e) {
|
||||
console.error('[TagsPacientesPage] buscarEtiquetas error', e)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Erro ao carregar tags',
|
||||
detail: e?.message || 'Não consegui carregar as tags. Verifique se as tabelas/views existem no Supabase local.',
|
||||
life: 6000
|
||||
})
|
||||
} finally {
|
||||
carregando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Dialog create/edit
|
||||
-------------------------------- */
|
||||
function abrirCriar() {
|
||||
dlg.open = true
|
||||
dlg.mode = 'create'
|
||||
dlg.id = ''
|
||||
dlg.nome = ''
|
||||
dlg.cor = ''
|
||||
}
|
||||
|
||||
function abrirEditar(row) {
|
||||
dlg.open = true
|
||||
dlg.mode = 'edit'
|
||||
dlg.id = row.id
|
||||
dlg.nome = row.nome || ''
|
||||
dlg.cor = row.cor || ''
|
||||
}
|
||||
|
||||
function fecharDlg() {
|
||||
dlg.open = false
|
||||
}
|
||||
|
||||
async function salvarDlg() {
|
||||
const nome = String(dlg.nome || '').trim()
|
||||
if (!nome) return
|
||||
|
||||
dlg.saving = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
// salva sempre "#rrggbb" ou null
|
||||
const raw = String(dlg.cor || '').trim()
|
||||
const hex = raw ? raw.replace('#', '') : ''
|
||||
const cor = hex ? `#${hex}` : null
|
||||
|
||||
if (dlg.mode === 'create') {
|
||||
// tenta pt-BR
|
||||
let res = await supabase.from('patient_tags').insert({
|
||||
owner_id: ownerId,
|
||||
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 {
|
||||
// update pt-BR
|
||||
let res = await supabase
|
||||
.from('patient_tags')
|
||||
.update({
|
||||
nome,
|
||||
cor,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', dlg.id)
|
||||
.eq('owner_id', ownerId)
|
||||
|
||||
// legado
|
||||
if (res.error && /column .*nome/i.test(String(res.error.message || ''))) {
|
||||
res = await supabase
|
||||
.from('patient_tags')
|
||||
.update({
|
||||
name: nome,
|
||||
color: cor,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('id', dlg.id)
|
||||
.eq('owner_id', ownerId)
|
||||
}
|
||||
|
||||
if (res.error) throw res.error
|
||||
toast.add({ severity: 'success', summary: 'Tag atualizada', detail: nome, life: 2500 })
|
||||
}
|
||||
|
||||
dlg.open = false
|
||||
await buscarEtiquetas()
|
||||
} catch (e) {
|
||||
console.error('[TagsPacientesPage] salvarDlg error', e)
|
||||
|
||||
const nome = String(dlg.nome || '').trim()
|
||||
|
||||
if (isUniqueViolation(e)) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Tag já existe',
|
||||
detail: friendlyDupMessage(nome),
|
||||
life: 4500
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Não consegui salvar',
|
||||
detail: e?.message || 'Erro ao salvar a tag.',
|
||||
life: 6000
|
||||
})
|
||||
} finally {
|
||||
dlg.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Delete
|
||||
-------------------------------- */
|
||||
function confirmarExclusaoUma(row) {
|
||||
confirm.require({
|
||||
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',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => excluirTags([row])
|
||||
})
|
||||
}
|
||||
|
||||
function confirmarExclusaoSelecionadas() {
|
||||
const rows = etiquetasSelecionadas.value || []
|
||||
if (!rows.length) return
|
||||
|
||||
const nomes = rows.slice(0, 5).map(r => r.nome).join(', ')
|
||||
confirm.require({
|
||||
message:
|
||||
rows.length <= 5
|
||||
? `Excluir: ${nomes}? (remove também os vínculos)`
|
||||
: `Excluir ${rows.length} tags selecionadas? (remove também os vínculos)`,
|
||||
header: 'Confirmar exclusão',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir',
|
||||
rejectLabel: 'Cancelar',
|
||||
accept: async () => excluirTags(rows)
|
||||
})
|
||||
}
|
||||
|
||||
async function excluirTags(rows) {
|
||||
if (!rows?.length) return
|
||||
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const ids = rows.filter(r => !r.is_padrao).map(r => r.id)
|
||||
|
||||
if (!ids.length) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Nada para excluir',
|
||||
detail: 'Tags padrão não podem ser removidas.',
|
||||
life: 4000
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 1) apaga pivots
|
||||
const pivotDel = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.delete()
|
||||
.eq('owner_id', ownerId)
|
||||
.in('tag_id', ids)
|
||||
|
||||
if (pivotDel.error) throw pivotDel.error
|
||||
|
||||
// 2) apaga tags
|
||||
const tagDel = await supabase
|
||||
.from('patient_tags')
|
||||
.delete()
|
||||
.eq('owner_id', ownerId)
|
||||
.in('id', ids)
|
||||
|
||||
if (tagDel.error) throw tagDel.error
|
||||
|
||||
etiquetasSelecionadas.value = []
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: `${ids.length} tag(s) removida(s).`, life: 3000 })
|
||||
await buscarEtiquetas()
|
||||
} catch (e) {
|
||||
console.error('[TagsPacientesPage] excluirTags error', e)
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Não consegui excluir',
|
||||
detail: e?.message || 'Erro ao excluir tags.',
|
||||
life: 6000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------
|
||||
Modal pacientes
|
||||
-------------------------------- */
|
||||
async function abrirModalPacientesDaTag(tag) {
|
||||
modalPacientes.open = true
|
||||
modalPacientes.tag = tag
|
||||
modalPacientes.items = []
|
||||
modalPacientes.search = ''
|
||||
modalPacientes.error = ''
|
||||
await carregarPacientesDaTag(tag)
|
||||
}
|
||||
|
||||
async function recarregarModalPacientes() {
|
||||
if (!modalPacientes.tag) return
|
||||
await carregarPacientesDaTag(modalPacientes.tag)
|
||||
}
|
||||
|
||||
async function carregarPacientesDaTag(tag) {
|
||||
modalPacientes.loading = true
|
||||
modalPacientes.error = ''
|
||||
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('patient_patient_tag')
|
||||
.select(`
|
||||
patient_id,
|
||||
patients:patients(
|
||||
id,
|
||||
nome_completo,
|
||||
email_principal,
|
||||
telefone,
|
||||
avatar_url
|
||||
)
|
||||
`)
|
||||
.eq('owner_id', ownerId)
|
||||
.eq('tag_id', tag.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const normalizados = (data || [])
|
||||
.map(r => r.patients)
|
||||
.filter(Boolean)
|
||||
.map(p => ({
|
||||
id: p.id,
|
||||
name: p.nome_completo || '—',
|
||||
email: p.email_principal || '—',
|
||||
phone: p.telefone || '—',
|
||||
avatar_url: p.avatar_url || null
|
||||
}))
|
||||
.sort((a, b) => String(a.name).localeCompare(String(b.name), 'pt-BR'))
|
||||
|
||||
modalPacientes.items = normalizados
|
||||
} catch (e) {
|
||||
console.error('[TagsPacientesPage] carregarPacientesDaTag error', e)
|
||||
modalPacientes.error =
|
||||
e?.message ||
|
||||
'Não consegui carregar os pacientes desta tag. Verifique RLS/policies e se as tabelas existem.'
|
||||
} finally {
|
||||
modalPacientes.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onlyDigits(v) {
|
||||
return String(v ?? '').replace(/\D/g, '')
|
||||
}
|
||||
|
||||
function fmtPhoneBR(v) {
|
||||
const d = onlyDigits(v)
|
||||
if (!d) return '—'
|
||||
|
||||
// opcional: se vier com DDI 55 grudado (ex.: 5511999999999)
|
||||
if ((d.length === 12 || d.length === 13) && d.startsWith('55')) {
|
||||
return fmtPhoneBR(d.slice(2))
|
||||
}
|
||||
|
||||
// (11) 9xxxx-xxxx
|
||||
if (d.length === 11) return `(${d.slice(0, 2)}) ${d.slice(2, 7)}-${d.slice(7, 11)}`
|
||||
// (11) xxxx-xxxx
|
||||
if (d.length === 10) return `(${d.slice(0, 2)}) ${d.slice(2, 6)}-${d.slice(6, 10)}`
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
function abrirPaciente (patient) {
|
||||
router.push(`/features/patients/cadastro/${patient.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Mantido apenas porque Transition name="fade" precisa das classes */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active { transition: opacity 0.15s ease; }
|
||||
.fade-enter-from,
|
||||
.fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user