ZERADO
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,15 @@
|
||||
<!-- src/features/agenda/components/AgendaClinicMosaic.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
|
||||
|
||||
const props = defineProps({
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week'
|
||||
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
|
||||
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
|
||||
timezone: { type: String, default: 'America/Sao_Paulo' },
|
||||
|
||||
@@ -22,33 +25,63 @@ const props = defineProps({
|
||||
|
||||
loading: { type: Boolean, default: false },
|
||||
|
||||
// controla quantas colunas "visíveis" por vez (resto vai por scroll horizontal)
|
||||
minColWidth: { type: Number, default: 360 }
|
||||
// largura mínima de cada coluna (terapeutas)
|
||||
minColWidth: { type: Number, default: 360 },
|
||||
|
||||
// ✅ coluna da clínica
|
||||
showClinicColumn: { type: Boolean, default: true },
|
||||
clinicId: { type: String, default: '' },
|
||||
clinicTitle: { type: String, default: 'Clínica' },
|
||||
clinicSubtitle: { type: String, default: 'Agenda da clínica' },
|
||||
|
||||
// subtitle terapeutas
|
||||
staffSubtitle: { type: String, default: 'Visão diária operacional' }
|
||||
})
|
||||
|
||||
// ✅ rangeChange = mudança de range (carregar eventos)
|
||||
// ✅ slotSelect = seleção de intervalo em uma coluna específica (criar evento)
|
||||
// ✅ eventClick/Drop/Resize = ações em evento
|
||||
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize'])
|
||||
const emit = defineEmits([
|
||||
'rangeChange',
|
||||
'slotSelect',
|
||||
'eventClick',
|
||||
'eventDrop',
|
||||
'eventResize',
|
||||
// ✅ debug
|
||||
'debugColumn'
|
||||
])
|
||||
|
||||
const calendarRefs = ref([])
|
||||
|
||||
function setCalendarRef (el, idx) {
|
||||
if (!el) return
|
||||
calendarRefs.value[idx] = el
|
||||
}
|
||||
|
||||
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
|
||||
const initialView = computed(() => {
|
||||
if (props.view === 'week') return 'timeGridWeek'
|
||||
if (props.view === 'month') return 'dayGridMonth'
|
||||
return 'timeGridDay'
|
||||
})
|
||||
|
||||
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime))
|
||||
// ✅ 23:59:59 para evitar edge-case de 24:00:00
|
||||
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
|
||||
|
||||
// ✅ coluna fixa (clínica)
|
||||
const clinicColumn = computed(() => {
|
||||
if (!props.showClinicColumn) return null
|
||||
const id = String(props.clinicId || '').trim()
|
||||
if (!id) return null
|
||||
return { id, title: props.clinicTitle || 'Clínica', __kind: 'clinic' }
|
||||
})
|
||||
|
||||
const staffColumns = computed(() => {
|
||||
const base = Array.isArray(props.staff) ? props.staff : []
|
||||
return base
|
||||
.filter(s => s?.id)
|
||||
.map(s => ({ ...s, __kind: 'staff' }))
|
||||
})
|
||||
|
||||
function apiAt (idx) {
|
||||
const fc = calendarRefs.value[idx]
|
||||
return fc?.getApi?.()
|
||||
}
|
||||
|
||||
function forEachApi (fn) {
|
||||
for (let i = 0; i < calendarRefs.value.length; i++) {
|
||||
const api = apiAt(i)
|
||||
@@ -59,18 +92,24 @@ function forEachApi (fn) {
|
||||
function goToday () { forEachApi(api => api.today()) }
|
||||
function prev () { forEachApi(api => api.prev()) }
|
||||
function next () { forEachApi(api => api.next()) }
|
||||
|
||||
function setView (v) {
|
||||
const target = v === 'week' ? 'timeGridWeek' : 'timeGridDay'
|
||||
forEachApi(api => api.changeView(target))
|
||||
function gotoDate (date) {
|
||||
if (!date) return
|
||||
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
|
||||
dt.setHours(12, 0, 0, 0) // anti “voltar dia”
|
||||
forEachApi(api => api.gotoDate(dt))
|
||||
}
|
||||
|
||||
defineExpose({ goToday, prev, next, setView })
|
||||
function setView (v) {
|
||||
const target = v === 'week' ? 'timeGridWeek' : (v === 'month' ? 'dayGridMonth' : 'timeGridDay')
|
||||
forEachApi(api => api.changeView(target))
|
||||
}
|
||||
function setMode () {}
|
||||
|
||||
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
|
||||
|
||||
// Eventos por profissional (owner)
|
||||
function eventsFor (ownerId) {
|
||||
const list = props.events || []
|
||||
return list.filter(e => e?.extendedProps?.owner_id === ownerId)
|
||||
return list.filter(e => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''))
|
||||
}
|
||||
|
||||
// ---- range sync ----
|
||||
@@ -82,35 +121,93 @@ function onDatesSet (arg) {
|
||||
if (key === lastRangeKey) return
|
||||
lastRangeKey = key
|
||||
|
||||
// dispara carregamento no pai
|
||||
emit('rangeChange', {
|
||||
start: arg.start,
|
||||
end: arg.end,
|
||||
startStr: arg.startStr,
|
||||
endStr: arg.endStr,
|
||||
viewType: arg.view.type
|
||||
viewType: arg.view.type,
|
||||
currentDate: arg.view?.currentStart || arg.start
|
||||
})
|
||||
|
||||
// mantém todos os calendários na mesma data
|
||||
if (suppressSync) return
|
||||
suppressSync = true
|
||||
|
||||
const masterDate = arg.start
|
||||
const masterDate = arg.view?.currentStart || arg.start
|
||||
forEachApi((api) => {
|
||||
const cur = api.view?.currentStart
|
||||
if (!cur) return
|
||||
if (!cur || !masterDate) return
|
||||
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
|
||||
})
|
||||
|
||||
// libera no próximo tick (evita loops)
|
||||
Promise.resolve().then(() => { suppressSync = false })
|
||||
}
|
||||
|
||||
// Se trocar view, garante que todos estão no mesmo
|
||||
watch(() => props.view, async () => {
|
||||
await nextTick()
|
||||
setView(props.view)
|
||||
})
|
||||
|
||||
// ---------- helpers UI ----------
|
||||
function colSubtitle (p) {
|
||||
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
|
||||
}
|
||||
|
||||
// ✅ debug emitter (cabeçalho clicável)
|
||||
function emitDebug (col) {
|
||||
emit('debugColumn', {
|
||||
staffCol: col,
|
||||
staffUserId: col?.id || null,
|
||||
staffTitle: col?.title || null,
|
||||
kind: col?.__kind || null,
|
||||
at: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
|
||||
function buildFcOptions (ownerId) {
|
||||
const base = {
|
||||
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
|
||||
locale: ptBrLocale,
|
||||
timeZone: props.timezone,
|
||||
|
||||
headerToolbar: false,
|
||||
initialView: initialView.value,
|
||||
|
||||
nowIndicator: true,
|
||||
editable: true,
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
|
||||
slotDuration: props.slotDuration,
|
||||
slotMinTime: computedSlotMinTime.value,
|
||||
slotMaxTime: computedSlotMaxTime.value,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false,
|
||||
|
||||
events: eventsFor(ownerId),
|
||||
datesSet: onDatesSet,
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
}
|
||||
|
||||
base.select = (selection) => {
|
||||
emit('slotSelect', {
|
||||
ownerId,
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
startStr: selection.startStr,
|
||||
endStr: selection.endStr,
|
||||
jsEvent: selection.jsEvent || null,
|
||||
viewType: selection.view?.type || initialView.value
|
||||
})
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,74 +216,105 @@ watch(() => props.view, async () => {
|
||||
Carregando agenda da clínica…
|
||||
</div>
|
||||
|
||||
<!-- Mosaic -->
|
||||
<div
|
||||
class="p-2 md:p-3 overflow-x-auto"
|
||||
:style="{ display: 'grid', gridAutoFlow: 'column', gridAutoColumns: `minmax(${minColWidth}px, 1fr)`, gap: '12px' }"
|
||||
>
|
||||
<div
|
||||
v-for="(p, idx) in staff"
|
||||
:key="p.id"
|
||||
class="rounded-[1.25rem] border border-[var(--surface-border)] bg-[color-mix(in_srgb,var(--surface-card),transparent_12%)] overflow-hidden"
|
||||
>
|
||||
<!-- Header da coluna -->
|
||||
<div class="p-3 border-b border-[var(--surface-border)] flex items-center justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ p.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">Visão diária operacional</div>
|
||||
<div class="mosaic-shell">
|
||||
<!-- Coluna fixa: Clínica -->
|
||||
<div v-if="clinicColumn" class="mosaic-fixed">
|
||||
<div class="mosaic-col">
|
||||
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(clinicColumn)" title="Debug desta coluna">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ clinicColumn.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">{{ colSubtitle(clinicColumn) }}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, 0)"
|
||||
:options="buildFcOptions(clinicColumn.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, idx)"
|
||||
:options="{
|
||||
plugins: [timeGridPlugin, interactionPlugin],
|
||||
initialView: initialView,
|
||||
timeZone: timezone,
|
||||
<!-- Área rolável: Terapeutas -->
|
||||
<div class="mosaic-scroll">
|
||||
<div
|
||||
class="mosaic-grid"
|
||||
:style="{ gridAutoColumns: `minmax(${minColWidth}px, 1fr)` }"
|
||||
>
|
||||
<div
|
||||
v-for="(p, sIdx) in staffColumns"
|
||||
:key="p.id"
|
||||
class="mosaic-col"
|
||||
>
|
||||
<div class="mosaic-col-head cursor-pointer" @click="emitDebug(p)" title="Debug desta coluna">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold truncate">{{ p.title }}</div>
|
||||
<div class="text-xs opacity-70 truncate">{{ colSubtitle(p) }}</div>
|
||||
</div>
|
||||
<div class="text-xs opacity-70 whitespace-nowrap">
|
||||
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
headerToolbar: false,
|
||||
nowIndicator: true,
|
||||
|
||||
editable: true,
|
||||
|
||||
// ✅ seleção para criar evento (por coluna)
|
||||
selectable: true,
|
||||
selectMirror: true,
|
||||
select: (selection) => {
|
||||
emit('slotSelect', {
|
||||
ownerId: p.id,
|
||||
start: selection.start,
|
||||
end: selection.end,
|
||||
startStr: selection.startStr,
|
||||
endStr: selection.endStr,
|
||||
jsEvent: selection.jsEvent || null,
|
||||
viewType: selection.view?.type || initialView
|
||||
})
|
||||
},
|
||||
|
||||
slotDuration: slotDuration,
|
||||
slotMinTime: computedSlotMinTime,
|
||||
slotMaxTime: computedSlotMaxTime,
|
||||
|
||||
height: 'auto',
|
||||
expandRows: true,
|
||||
allDaySlot: false,
|
||||
|
||||
events: eventsFor(p.id),
|
||||
|
||||
datesSet: onDatesSet,
|
||||
|
||||
eventClick: (info) => emit('eventClick', info),
|
||||
eventDrop: (info) => emit('eventDrop', info),
|
||||
eventResize: (info) => emit('eventResize', info)
|
||||
}"
|
||||
/>
|
||||
<div class="p-2">
|
||||
<FullCalendar
|
||||
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
|
||||
:options="buildFcOptions(p.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mosaic-shell{
|
||||
display:flex;
|
||||
gap:12px;
|
||||
padding: 8px;
|
||||
}
|
||||
@media (min-width: 768px){
|
||||
.mosaic-shell{ padding: 12px; }
|
||||
}
|
||||
|
||||
.mosaic-fixed{
|
||||
flex: 0 0 auto;
|
||||
width: 420px;
|
||||
min-width: 320px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.mosaic-scroll{
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.mosaic-grid{
|
||||
display:grid;
|
||||
grid-auto-flow: column;
|
||||
gap:12px;
|
||||
}
|
||||
|
||||
.mosaic-col{
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: color-mix(in_srgb, var(--surface-card), transparent 12%);
|
||||
overflow:hidden;
|
||||
}
|
||||
|
||||
.mosaic-col-head{
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,5 @@
|
||||
<!-- src/features/agenda/components/AgendaRightPanel.vue -->
|
||||
<script setup>
|
||||
import Card from 'primevue/card'
|
||||
import Divider from 'primevue/divider'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Painel' },
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import ToggleButton from 'primevue/togglebutton'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Agenda' },
|
||||
|
||||
@@ -0,0 +1,523 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model:visible="visible"
|
||||
modal
|
||||
:draggable="false"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
class="dc-dialog w-[96vw] max-w-2xl"
|
||||
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<!-- Dot de cor -->
|
||||
<span
|
||||
class="dc-header-dot shrink-0"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="text-base font-semibold truncate">
|
||||
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
|
||||
</div>
|
||||
<div class="text-xs opacity-50">
|
||||
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button
|
||||
v-if="mode === 'edit' && canDelete"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="saving"
|
||||
v-tooltip.top="'Excluir'"
|
||||
@click="emitDelete"
|
||||
/>
|
||||
<Button
|
||||
label="Cancelar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
:disabled="saving"
|
||||
@click="close"
|
||||
/>
|
||||
<Button
|
||||
label="Salvar"
|
||||
icon="pi pi-check"
|
||||
class="rounded-full"
|
||||
:loading="saving"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Banner de preview -->
|
||||
<div
|
||||
class="dc-banner"
|
||||
:style="{ backgroundColor: previewBgColor }"
|
||||
>
|
||||
<span
|
||||
class="dc-banner__pill"
|
||||
:style="{ color: form.text_color || '#ffffff' }"
|
||||
>
|
||||
{{ form.name || 'Nome do compromisso' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Corpo -->
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
|
||||
<!-- Nome + Ativo -->
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
id="cr-nome"
|
||||
v-model="form.name"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isEditLocked"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-nome">Nome *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0 pt-1">
|
||||
<span class="text-sm font-medium">Ativo</span>
|
||||
<InputSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção Cor -->
|
||||
<div class="dc-section">
|
||||
<div class="dc-section__label">Cor</div>
|
||||
|
||||
<!-- Paleta predefinida -->
|
||||
<div class="dc-palette">
|
||||
<button
|
||||
v-for="p in presetColors"
|
||||
:key="p.bg"
|
||||
class="dc-swatch"
|
||||
:class="{ 'dc-swatch--active': form.bg_color === p.bg }"
|
||||
:style="{ backgroundColor: `#${p.bg}` }"
|
||||
:title="p.name"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="applyPreset(p)"
|
||||
>
|
||||
<i v-if="form.bg_color === p.bg" class="pi pi-check dc-swatch__check" />
|
||||
</button>
|
||||
|
||||
<!-- Custom ColorPicker -->
|
||||
<div class="dc-swatch dc-swatch--custom" title="Cor personalizada">
|
||||
<ColorPicker
|
||||
v-model="form.bg_color"
|
||||
format="hex"
|
||||
:disabled="saving || isEditLocked"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Texto -->
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="dc-text-opt"
|
||||
:class="{ 'dc-text-opt--active': form.text_color === '#ffffff' }"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="form.text_color = '#ffffff'"
|
||||
>
|
||||
<span class="dc-text-opt__dot" style="background:#ffffff; border: 1px solid #ccc;" />
|
||||
Branco
|
||||
</button>
|
||||
<button
|
||||
class="dc-text-opt"
|
||||
:class="{ 'dc-text-opt--active': form.text_color === '#000000' }"
|
||||
:disabled="saving || isEditLocked"
|
||||
@click="form.text_color = '#000000'"
|
||||
>
|
||||
<span class="dc-text-opt__dot" style="background:#000000;" />
|
||||
Preto
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Descrição -->
|
||||
<FloatLabel variant="on">
|
||||
<Textarea
|
||||
id="cr-descricao"
|
||||
v-model="form.description"
|
||||
autoResize
|
||||
rows="2"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isEditLocked"
|
||||
/>
|
||||
<label for="cr-descricao">Descrição</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- Campos adicionais -->
|
||||
<div class="dc-section">
|
||||
<div class="flex items-center justify-between gap-2 mb-3">
|
||||
<div class="dc-section__label mb-0">Campos adicionais</div>
|
||||
<Button
|
||||
label="Adicionar campo"
|
||||
icon="pi pi-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full"
|
||||
:disabled="saving || isFieldsLocked"
|
||||
@click="addField"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">
|
||||
Nenhum campo adicional configurado.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="(f, idx) in form.fields"
|
||||
:key="f.key"
|
||||
class="grid grid-cols-1 gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
|
||||
>
|
||||
<div class="md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
:id="`cr-field-label-${idx}`"
|
||||
v-model="f.label"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isFieldsLocked"
|
||||
@keydown.enter.prevent="submit"
|
||||
@blur="syncKey(f)"
|
||||
/>
|
||||
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<Dropdown
|
||||
:id="`cr-field-type-${idx}`"
|
||||
v-model="f.type"
|
||||
:options="fieldTypeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving || isFieldsLocked"
|
||||
/>
|
||||
<label :for="`cr-field-type-${idx}`">Tipo</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2 flex items-center justify-end">
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="saving || isFieldsLocked"
|
||||
@click="removeField(idx)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-12 text-xs opacity-40 font-mono">
|
||||
key: {{ f.key }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import ColorPicker from 'primevue/colorpicker'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
mode: { type: String, default: 'create' }, // 'create' | 'edit'
|
||||
saving: { type: Boolean, default: false },
|
||||
commitment: { type: Object, default: null } // quando edit
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'delete'])
|
||||
|
||||
const fieldTypeOptions = [
|
||||
{ label: 'Texto', value: 'text' },
|
||||
{ label: 'Texto longo', value: 'textarea' }
|
||||
]
|
||||
|
||||
const presetColors = [
|
||||
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
|
||||
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
|
||||
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
|
||||
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
|
||||
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
|
||||
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
|
||||
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
|
||||
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
|
||||
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
|
||||
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
|
||||
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
|
||||
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
|
||||
]
|
||||
|
||||
function applyPreset (p) {
|
||||
if (props.saving) return
|
||||
form.bg_color = p.bg
|
||||
form.text_color = p.text
|
||||
}
|
||||
|
||||
const visible = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (v) => emit('update:modelValue', v)
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
description: '',
|
||||
native: false,
|
||||
locked: false,
|
||||
active: true,
|
||||
bg_color: '6366f1',
|
||||
text_color: '#ffffff',
|
||||
fields: []
|
||||
})
|
||||
|
||||
const previewBgColor = computed(() => {
|
||||
if (!form.bg_color) return '#6366f1'
|
||||
return form.bg_color.startsWith('#') ? form.bg_color : `#${form.bg_color}`
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(open) => {
|
||||
if (!open) return
|
||||
hydrate()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.commitment,
|
||||
() => {
|
||||
if (!props.modelValue) return
|
||||
hydrate()
|
||||
}
|
||||
)
|
||||
|
||||
function hydrate () {
|
||||
const c = props.commitment
|
||||
if (props.mode === 'edit' && c) {
|
||||
form.id = c.id
|
||||
form.name = c.name || ''
|
||||
form.description = c.description || ''
|
||||
form.native = !!c.native
|
||||
form.locked = !!c.locked
|
||||
form.active = !!c.active
|
||||
form.bg_color = c.bg_color || '6366f1'
|
||||
form.text_color = c.text_color || '#ffffff'
|
||||
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : []
|
||||
} else {
|
||||
form.id = null
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.native = false
|
||||
form.locked = false
|
||||
form.active = true
|
||||
form.bg_color = '6366f1'
|
||||
form.text_color = '#ffffff'
|
||||
form.fields = []
|
||||
}
|
||||
}
|
||||
|
||||
const isActiveLocked = computed(() => !!form.locked) // nativo+locked → sempre ativo, nunca pode desativar
|
||||
const isEditLocked = computed(() => false) // edição sempre permitida
|
||||
const isFieldsLocked = computed(() => false) // campos sempre editáveis
|
||||
const canDelete = computed(() => !form.native)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (props.saving) return false
|
||||
if (!String(form.name || '').trim()) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function close () {
|
||||
if (props.saving) return
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function submit () {
|
||||
if (!canSubmit.value) return
|
||||
|
||||
const payload = {
|
||||
id: form.id,
|
||||
name: String(form.name || '').trim(),
|
||||
description: String(form.description || '').trim(),
|
||||
active: form.locked ? true : !!form.active,
|
||||
bg_color: form.bg_color || null,
|
||||
text_color: form.text_color || null,
|
||||
fields: (form.fields || []).map(f => ({
|
||||
key: f.key,
|
||||
label: String(f.label || '').trim() || 'Campo',
|
||||
type: f.type === 'textarea' ? 'textarea' : 'text',
|
||||
required: !!f.required
|
||||
}))
|
||||
}
|
||||
|
||||
emit('save', payload)
|
||||
}
|
||||
|
||||
function emitDelete () {
|
||||
if (props.saving) return
|
||||
emit('delete', { id: form.id })
|
||||
visible.value = false
|
||||
}
|
||||
|
||||
function addField () {
|
||||
const base = `campo-${form.fields.length + 1}`
|
||||
form.fields.push({
|
||||
key: makeKey(base),
|
||||
label: 'Observação',
|
||||
type: 'textarea',
|
||||
required: false
|
||||
})
|
||||
}
|
||||
|
||||
function removeField (idx) {
|
||||
form.fields.splice(idx, 1)
|
||||
}
|
||||
|
||||
function syncKey (field) {
|
||||
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
|
||||
const next = makeKey(field.label)
|
||||
field.key = next
|
||||
}
|
||||
|
||||
function makeKey (label) {
|
||||
|
||||
const k = String(label || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
|
||||
return k
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Header ─────────────────────────────── */
|
||||
.dc-header-dot {
|
||||
width: 14px; height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Banner de preview ───────────────────── */
|
||||
.dc-banner {
|
||||
height: 72px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background-color 0.25s ease;
|
||||
}
|
||||
.dc-banner__pill {
|
||||
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
|
||||
padding: 0.35rem 1.1rem;
|
||||
background: rgba(0,0,0,0.15);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(4px);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────── */
|
||||
.dc-section {
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 1.25rem;
|
||||
background: var(--surface-card);
|
||||
padding: 1rem;
|
||||
}
|
||||
.dc-section__label {
|
||||
font-size: 0.7rem; font-weight: 700;
|
||||
text-transform: uppercase; letter-spacing: 0.06em;
|
||||
opacity: 0.45; margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* ── Paleta ──────────────────────────────── */
|
||||
.dc-palette {
|
||||
display: flex; flex-wrap: wrap; gap: 0.45rem;
|
||||
}
|
||||
.dc-swatch {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid transparent;
|
||||
display: grid; place-items: center;
|
||||
cursor: pointer;
|
||||
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
|
||||
position: relative;
|
||||
}
|
||||
.dc-swatch:hover:not(:disabled) {
|
||||
transform: scale(1.18);
|
||||
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
|
||||
}
|
||||
.dc-swatch--active {
|
||||
border-color: var(--surface-0, #fff);
|
||||
box-shadow: 0 0 0 2px var(--text-color);
|
||||
}
|
||||
.dc-swatch__check {
|
||||
font-size: 0.6rem; color: #fff; font-weight: 900;
|
||||
}
|
||||
.dc-swatch--custom {
|
||||
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
|
||||
overflow: hidden;
|
||||
}
|
||||
.dc-swatch--custom :deep(.p-colorpicker-preview) {
|
||||
width: 100%; height: 100%;
|
||||
border: none; border-radius: 50%;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* ── Texto toggle ────────────────────────── */
|
||||
.dc-text-opt {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--surface-border);
|
||||
font-size: 0.8rem; font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
.dc-text-opt:hover:not(:disabled) { background: var(--surface-hover); }
|
||||
.dc-text-opt--active {
|
||||
background: var(--surface-section, var(--surface-100));
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
.dc-text-opt__dot {
|
||||
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
|
||||
}
|
||||
</style>
|
||||
@@ -2,13 +2,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import SelectButton from 'primevue/selectbutton'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: 'Agenda' },
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Tag from 'primevue/tag'
|
||||
|
||||
const props = defineProps({
|
||||
stats: { type: Object, default: () => ({}) }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
// src/features/agenda/composables/useAgendaClinicEvents.js
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
listClinicEvents,
|
||||
createClinicAgendaEvento,
|
||||
updateClinicAgendaEvento,
|
||||
deleteClinicAgendaEvento
|
||||
} from '@/features/agenda/services/agendaClinicRepository'
|
||||
|
||||
export function useAgendaClinicEvents () {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
async function loadClinicRange ({ tenantId, ownerIds, startISO, endISO }) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
rows.value = await listClinicEvents({ tenantId, ownerIds, startISO, endISO })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar eventos.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createClinic (payload, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await createClinicAgendaEvento(payload, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao criar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateClinic (id, patch, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await updateClinicAgendaEvento(id, patch, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao atualizar evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeClinic (id, { tenantId } = {}) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
return await deleteClinicAgendaEvento(id, { tenantId })
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao remover evento.'
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
rows,
|
||||
loadClinicRange,
|
||||
createClinic,
|
||||
updateClinic,
|
||||
removeClinic
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
export function useDeterminedCommitments (tenantIdRef) {
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref([])
|
||||
|
||||
const tenantId = computed(() => {
|
||||
const v = tenantIdRef?.value ?? tenantIdRef
|
||||
return v ? String(v) : ''
|
||||
})
|
||||
|
||||
async function load () {
|
||||
try {
|
||||
if (!tenantId.value) {
|
||||
rows.value = []
|
||||
error.value = ''
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id,tenant_id,created_by,is_native,native_key,is_locked,active,name,description,bg_color,text_color,created_at,determined_commitment_fields(id,key,label,field_type,required,sort_order)')
|
||||
.eq('tenant_id', tenantId.value) // ✅ SOMENTE tenant corrente
|
||||
.eq('active', true)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (err) throw err
|
||||
rows.value = data || []
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Falha ao carregar compromissos determinísticos.'
|
||||
rows.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { loading, error, rows, load }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,753 @@
|
||||
<!-- src/features/agenda/pages/CompromissosDeterminados.vue -->
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel para detecção de sticky -->
|
||||
<div ref="headerSentinelRef" class="cmpr-sentinel" />
|
||||
|
||||
<!-- Hero Header sticky -->
|
||||
<div ref="headerEl" class="cmpr-hero mx-3 md:mx-5 mb-4" :class="{ 'cmpr-hero--stuck': headerStuck }">
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="cmpr-hero__blobs" aria-hidden="true">
|
||||
<div class="cmpr-hero__blob cmpr-hero__blob--1" />
|
||||
<div class="cmpr-hero__blob cmpr-hero__blob--2" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: brand + controles -->
|
||||
<div class="cmpr-hero__row1">
|
||||
<div class="cmpr-hero__brand">
|
||||
<div class="cmpr-hero__icon">
|
||||
<i class="pi pi-list text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="cmpr-hero__title">Compromissos</div>
|
||||
<div class="cmpr-hero__sub">Configure tipos de compromissos e campos adicionais</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles desktop (≥1200px) -->
|
||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
||||
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="fetchAll()" />
|
||||
</div>
|
||||
|
||||
<!-- Menu mobile (<1200px) -->
|
||||
<div class="flex xl:hidden items-center shrink-0">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divisor -->
|
||||
<Divider class="cmpr-hero__divider my-2" />
|
||||
|
||||
<!-- Linha 2: filtros + busca (oculta no mobile) -->
|
||||
<div class="cmpr-hero__row2">
|
||||
<SelectButton v-model="typeFilter" :options="typeOptions" optionLabel="label" optionValue="value" :disabled="loading" />
|
||||
<InputGroup class="w-72">
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Buscar compromisso" :disabled="loading" />
|
||||
<Button
|
||||
v-if="filters.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar busca"
|
||||
@click="clearSearch"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialog de busca (mobile) -->
|
||||
<Dialog v-model:visible="searchDlgOpen" modal :draggable="false" header="Buscar compromisso" class="w-[94vw] max-w-sm">
|
||||
<div class="pt-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
|
||||
<InputText v-model="filters.global.value" placeholder="Nome ou descrição..." autofocus />
|
||||
<Button
|
||||
v-if="filters.global.value"
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
title="Limpar"
|
||||
@click="filters.global.value = null"
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="searchDlgOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="mb-4 px-3 md:px-5 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
<Card
|
||||
v-for="c in cardsCommitments"
|
||||
:key="c.id"
|
||||
class="rounded-3xl border border-[var(--surface-border)] shadow-sm"
|
||||
>
|
||||
<template #content>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="c.bg_color"
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold"
|
||||
:style="{ backgroundColor: `#${c.bg_color}`, color: c.text_color || '#ffffff' }"
|
||||
>{{ c.name }}</span>
|
||||
<span v-else class="truncate text-base font-semibold">{{ c.name }}</span>
|
||||
<Tag v-if="c.is_native" value="Nativo" severity="info" />
|
||||
</div>
|
||||
<div class="mt-1 line-clamp-2 text-sm opacity-70">
|
||||
{{ c.description || '—' }}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-sm">
|
||||
<span class="opacity-70">Tempo total:</span>
|
||||
<span class="ml-2 font-semibold">{{ formatMinutes(getTotalMinutes(c.id)) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs opacity-70">Ativo</span>
|
||||
<InputSwitch
|
||||
v-model="c.active"
|
||||
:disabled="isActiveLocked(c) || saving"
|
||||
@change="onToggleActive(c)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
:disabled="isEditLocked(c) || saving"
|
||||
@click="openEdit(c)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="isDeleteLocked(c) || saving"
|
||||
@click="confirmDelete(c)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="mx-3 md:mx-5 mb-5 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<div class="text-base font-semibold">Lista de compromissos</div>
|
||||
<div class="text-sm opacity-60">
|
||||
{{ visibleCommitments.length }} itens
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="visibleCommitments"
|
||||
dataKey="id"
|
||||
:loading="loading"
|
||||
:paginator="true"
|
||||
:rows="10"
|
||||
responsiveLayout="scroll"
|
||||
class="p-datatable-sm"
|
||||
:filters="filters"
|
||||
filterDisplay="menu"
|
||||
:globalFilterFields="['name','description']"
|
||||
>
|
||||
|
||||
<Column field="name" header="Nome" sortable filter filterPlaceholder="Filtrar nome" style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold">{{ data.name }}</span>
|
||||
<Tag v-if="data.is_native" value="Nativo" severity="info" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descrição" sortable filter filterPlaceholder="Filtrar descrição" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<span class="opacity-80">{{ data.description || '—' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tempo total" sortable style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
{{ formatMinutes(getTotalMinutes(data.id)) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="active" header="Ativo" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<InputSwitch
|
||||
v-model="data.active"
|
||||
:disabled="isActiveLocked(data) || saving"
|
||||
@change="onToggleActive(data)"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Ação" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-1">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
:disabled="isEditLocked(data) || saving"
|
||||
@click="openEdit(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
:disabled="isDeleteLocked(data) || saving"
|
||||
@click="confirmDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="py-10 text-center">
|
||||
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-2xl bg-[var(--primary-color)]/10 text-[var(--primary-color)]">
|
||||
<i class="pi pi-search text-xl" />
|
||||
</div>
|
||||
<div class="font-semibold">Nenhum compromisso determinístico encontrado</div>
|
||||
<div class="mt-1 text-sm text-color-secondary">
|
||||
Tente limpar filtros ou mudar o termo de busca.
|
||||
</div>
|
||||
<div class="mt-4 flex justify-center gap-2">
|
||||
<Button severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar filtros" @click="clearSearch" />
|
||||
<Button icon="pi pi-plus" label="Cadastrar compromisso" @click="openCreate()" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Dialog -->
|
||||
<DeterminedCommitmentDialog
|
||||
v-model="dlgOpen"
|
||||
:mode="dlgMode"
|
||||
:saving="saving"
|
||||
:commitment="editing"
|
||||
@save="onSave"
|
||||
@delete="onDelete"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import DeterminedCommitmentDialog from '@/features/agenda/components/DeterminedCommitmentDialog.vue'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
// ── Hero sticky ───────────────────────────────────────────
|
||||
const headerEl = ref(null)
|
||||
const headerSentinelRef = ref(null)
|
||||
const headerStuck = ref(false)
|
||||
let _observer = null
|
||||
|
||||
// ── Mobile menu ───────────────────────────────────────────
|
||||
const mobileMenuRef = ref(null)
|
||||
const searchDlgOpen = ref(false)
|
||||
|
||||
const mobileMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Novo compromisso',
|
||||
icon: 'pi pi-plus',
|
||||
command: () => openCreate()
|
||||
},
|
||||
{
|
||||
label: 'Buscar',
|
||||
icon: 'pi pi-search',
|
||||
command: () => { searchDlgOpen.value = true }
|
||||
},
|
||||
{ separator: true },
|
||||
{
|
||||
label: 'Recarregar',
|
||||
icon: 'pi pi-refresh',
|
||||
command: () => fetchAll()
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
|
||||
_observer = new IntersectionObserver(
|
||||
([entry]) => { headerStuck.value = !entry.isIntersecting },
|
||||
{ threshold: 0, rootMargin }
|
||||
)
|
||||
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
|
||||
|
||||
await tenantStore.loadSessionAndTenant()
|
||||
await fetchAll()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const filters = reactive({
|
||||
global: { value: null, matchMode: 'contains' },
|
||||
name: { value: null, matchMode: 'contains' },
|
||||
description: { value: null, matchMode: 'contains' }
|
||||
})
|
||||
|
||||
/**
|
||||
* Filtro por tipo (Todos / Nativos / Meus)
|
||||
* - aplica na tabela (via computed) e nos cards
|
||||
*/
|
||||
const typeFilter = ref('all')
|
||||
const typeOptions = [
|
||||
{ label: 'Todos', value: 'all' },
|
||||
{ label: 'Nativos', value: 'native' },
|
||||
{ label: 'Meus', value: 'custom' }
|
||||
]
|
||||
|
||||
/**
|
||||
* Modelo de compromisso (tipo determinístico):
|
||||
* - is_native: template do sistema
|
||||
* - is_locked: trava comportamento (ex: Sessão)
|
||||
* - fields: campos adicionais (dinâmicos)
|
||||
*/
|
||||
const commitments = ref([])
|
||||
|
||||
// Totais reais (minutos) agregados de commitment_time_logs
|
||||
const totalsByCommitmentId = ref({})
|
||||
|
||||
/**
|
||||
* Lista base para tabela:
|
||||
* - aplica filtro por tipo (Todos / Nativos / Meus)
|
||||
* - (global search do DataTable continua via :filters)
|
||||
*/
|
||||
const visibleCommitments = computed(() => {
|
||||
let list = commitments.value
|
||||
|
||||
if (typeFilter.value === 'native') list = list.filter(c => !!c.is_native)
|
||||
if (typeFilter.value === 'custom') list = list.filter(c => !c.is_native)
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
/**
|
||||
* Lista para cards:
|
||||
* - aplica o mesmo filtro de tipo
|
||||
* - + aplica busca global (para cards acompanharem a barra de busca)
|
||||
*/
|
||||
const cardsCommitments = computed(() => {
|
||||
let list = visibleCommitments.value
|
||||
|
||||
const q = String(filters.global?.value ?? '').trim().toLowerCase()
|
||||
if (q) {
|
||||
list = list.filter(c =>
|
||||
String(c.name || '').toLowerCase().includes(q) ||
|
||||
String(c.description || '').toLowerCase().includes(q)
|
||||
)
|
||||
}
|
||||
|
||||
return list
|
||||
})
|
||||
|
||||
function clearSearch () {
|
||||
filters.global.value = null
|
||||
}
|
||||
|
||||
const dlgOpen = ref(false)
|
||||
const dlgMode = ref('create') // 'create' | 'edit'
|
||||
const editing = ref(null)
|
||||
|
||||
function getTenantId () {
|
||||
// ✅ sem fallback (evita vazamento clinic↔therapist)
|
||||
return tenantStore.activeTenantId || null
|
||||
}
|
||||
|
||||
|
||||
async function fetchAll () {
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) {
|
||||
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Tenant inválido.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
// 1) commitments
|
||||
const { data: cData, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('is_native', { ascending: false })
|
||||
.order('name', { ascending: true })
|
||||
|
||||
if (cErr) throw cErr
|
||||
|
||||
const ids = (cData || []).map(x => x.id)
|
||||
|
||||
// 2) fields
|
||||
let fieldsByCommitmentId = {}
|
||||
if (ids.length > 0) {
|
||||
const { data: fData, error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.select('id, tenant_id, commitment_id, key, label, field_type, required, sort_order')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('commitment_id', ids)
|
||||
.order('sort_order', { ascending: true })
|
||||
|
||||
if (fErr) throw fErr
|
||||
|
||||
fieldsByCommitmentId = (fData || []).reduce((acc, row) => {
|
||||
const k = row.commitment_id
|
||||
if (!acc[k]) acc[k] = []
|
||||
acc[k].push({
|
||||
id: row.id,
|
||||
key: row.key,
|
||||
label: row.label,
|
||||
type: row.field_type,
|
||||
required: !!row.required,
|
||||
sort_order: row.sort_order
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
// 3) totals (logs)
|
||||
const { data: lData, error: lErr } = await supabase
|
||||
.from('commitment_time_logs')
|
||||
.select('commitment_id, minutes')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (lErr) throw lErr
|
||||
|
||||
const totals = {}
|
||||
for (const row of (lData || [])) {
|
||||
const cid = row.commitment_id
|
||||
const m = Number(row.minutes ?? 0) || 0
|
||||
totals[cid] = (totals[cid] || 0) + m
|
||||
}
|
||||
|
||||
totalsByCommitmentId.value = totals
|
||||
|
||||
// 4) merge
|
||||
commitments.value = (cData || []).map(c => ({
|
||||
...c,
|
||||
fields: fieldsByCommitmentId[c.id] || []
|
||||
}))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar compromissos.', life: 4500 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTotalMinutes (commitmentId) {
|
||||
return Number(totalsByCommitmentId.value?.[commitmentId] ?? 0)
|
||||
}
|
||||
|
||||
function formatMinutes (minutes) {
|
||||
const m = Math.max(0, Number(minutes) || 0)
|
||||
const h = Math.floor(m / 60)
|
||||
const mm = m % 60
|
||||
if (h <= 0) return `${mm}m`
|
||||
return `${h}h ${String(mm).padStart(2, '0')}m`
|
||||
}
|
||||
|
||||
function isActiveLocked (c) {
|
||||
return !!c.is_locked
|
||||
}
|
||||
|
||||
function isDeleteLocked (c) {
|
||||
return !!c.is_native
|
||||
}
|
||||
|
||||
function isEditLocked (_c) {
|
||||
return false // edição sempre permitida; só o "ativo" fica travado
|
||||
}
|
||||
|
||||
function openCreate () {
|
||||
dlgMode.value = 'create'
|
||||
editing.value = null
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit (c) {
|
||||
dlgMode.value = 'edit'
|
||||
editing.value = JSON.parse(JSON.stringify(c))
|
||||
dlgOpen.value = true
|
||||
}
|
||||
|
||||
async function onToggleActive (c) {
|
||||
if (isActiveLocked(c)) return
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('determined_commitments')
|
||||
.update({ active: !!c.active })
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', c.id)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Atualizado',
|
||||
detail: `“${c.name}” agora está ${c.active ? 'ativo' : 'inativo'}.`,
|
||||
life: 2500
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
c.active = !c.active
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao atualizar.', life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave (payload) {
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
// pega usuário atual (se quiser auditoria futura)
|
||||
await supabase.auth.getUser()
|
||||
|
||||
if (dlgMode.value === 'create') {
|
||||
const insertRow = {
|
||||
tenant_id: tenantId,
|
||||
is_native: false,
|
||||
native_key: null,
|
||||
is_locked: false,
|
||||
active: !!payload.active,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
}
|
||||
|
||||
const { data: newC, error: cErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.insert(insertRow)
|
||||
.select('id, tenant_id, is_native, native_key, is_locked, active, name, description, bg_color, text_color, created_at, updated_at')
|
||||
.single()
|
||||
|
||||
if (cErr) throw cErr
|
||||
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : []
|
||||
if (fields.length > 0) {
|
||||
const rows = fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: newC.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.type,
|
||||
required: !!f.required,
|
||||
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
|
||||
}))
|
||||
|
||||
const { error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.insert(rows)
|
||||
|
||||
if (fErr) throw fErr
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
} else {
|
||||
const updateRow = {
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
active: !!payload.active,
|
||||
bg_color: payload.bg_color || null,
|
||||
text_color: payload.text_color || null
|
||||
}
|
||||
|
||||
const { error: upErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.update(updateRow)
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', payload.id)
|
||||
|
||||
if (upErr) throw upErr
|
||||
|
||||
const fields = Array.isArray(payload.fields) ? payload.fields : []
|
||||
|
||||
const { error: delErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', payload.id)
|
||||
|
||||
if (delErr) throw delErr
|
||||
|
||||
if (fields.length > 0) {
|
||||
const rows = fields.map((f, idx) => ({
|
||||
tenant_id: tenantId,
|
||||
commitment_id: payload.id,
|
||||
key: f.key,
|
||||
label: f.label,
|
||||
field_type: f.type,
|
||||
required: !!f.required,
|
||||
sort_order: Number(f.sort_order ?? ((idx + 1) * 10))
|
||||
}))
|
||||
|
||||
const { error: insErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.insert(rows)
|
||||
|
||||
if (insErr) throw insErr
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Alterações salvas.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
const msg = e?.message || ''
|
||||
const detail = (e?.code === '23505' || /duplicate key value/i.test(msg))
|
||||
? 'Já existe um compromisso com esse nome neste tenant. Escolha outro nome.'
|
||||
: (msg || 'Falha ao salvar compromisso.')
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete (c) {
|
||||
if (isDeleteLocked(c)) return
|
||||
const ok = window.confirm(`Excluir “${c.name}”? Essa ação não pode ser desfeita.`)
|
||||
if (!ok) return
|
||||
onDelete(c)
|
||||
}
|
||||
|
||||
async function onDelete (c) {
|
||||
if (isDeleteLocked(c)) return
|
||||
const tenantId = getTenantId()
|
||||
if (!tenantId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const { error: fErr } = await supabase
|
||||
.from('determined_commitment_fields')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', c.id)
|
||||
if (fErr) throw fErr
|
||||
|
||||
const { error: lErr } = await supabase
|
||||
.from('commitment_time_logs')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('commitment_id', c.id)
|
||||
if (lErr) throw lErr
|
||||
|
||||
const { data: delRows, error: dErr } = await supabase
|
||||
.from('determined_commitments')
|
||||
.delete()
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('id', c.id)
|
||||
.eq('is_native', false)
|
||||
.select('id')
|
||||
if (dErr) throw dErr
|
||||
|
||||
if (!delRows || delRows.length === 0) {
|
||||
throw new Error('DELETE bloqueado por RLS (0 linhas). Confirme policy dc_delete_custom_for_active_member.')
|
||||
}
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 })
|
||||
dlgOpen.value = false
|
||||
await fetchAll()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao excluir compromisso.', life: 4500 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── Hero Header ─────────────────────────────────── */
|
||||
.cmpr-sentinel { height: 1px; }
|
||||
|
||||
.cmpr-hero {
|
||||
position: sticky;
|
||||
top: var(--layout-sticky-top, 56px);
|
||||
z-index: 20;
|
||||
overflow: hidden;
|
||||
border-radius: 1.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
.cmpr-hero--stuck {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
|
||||
/* Blobs */
|
||||
.cmpr-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
.cmpr-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
|
||||
.cmpr-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
|
||||
.cmpr-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
|
||||
|
||||
/* Linha 1 */
|
||||
.cmpr-hero__row1 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
}
|
||||
.cmpr-hero__brand {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
flex: 1; min-width: 0;
|
||||
}
|
||||
.cmpr-hero__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
}
|
||||
.cmpr-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.cmpr-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
|
||||
|
||||
/* Linha 2 (oculta no mobile) */
|
||||
.cmpr-hero__row2 {
|
||||
position: relative; z-index: 1;
|
||||
display: flex; align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.cmpr-hero__divider,
|
||||
.cmpr-hero__row2 { display: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,131 @@
|
||||
// src/features/agenda/services/agendaClinicRepository.js
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
function assertValidTenantId (tenantId) {
|
||||
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
|
||||
throw new Error('Tenant ativo inválido. Selecione a clínica/tenant antes de carregar a agenda.')
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidIsoRange (startISO, endISO) {
|
||||
if (!startISO || !endISO) throw new Error('Intervalo inválido (startISO/endISO).')
|
||||
}
|
||||
|
||||
function sanitizeOwnerIds (ownerIds) {
|
||||
return (ownerIds || [])
|
||||
.filter(id => typeof id === 'string' && id && id !== 'null' && id !== 'undefined')
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista eventos para mosaico da clínica (admin/secretaria) dentro de um tenant específico.
|
||||
* IMPORTANTE: SEM tenant_id aqui vira vazamento multi-tenant.
|
||||
*/
|
||||
export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO } = {}) {
|
||||
assertValidTenantId(tenantId)
|
||||
if (!ownerIds?.length) return []
|
||||
assertValidIsoRange(startISO, endISO)
|
||||
|
||||
const safeOwnerIds = sanitizeOwnerIds(ownerIds)
|
||||
if (!safeOwnerIds.length) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
.lt('inicio_em', endISO)
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista profissionais/membros para montar colunas no mosaico.
|
||||
* Usando view "v_tenant_staff" (como você já tem).
|
||||
*/
|
||||
export async function listTenantStaff (tenantId) {
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('v_tenant_staff')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
}
|
||||
|
||||
/**
|
||||
* Criação para a área da clínica (admin/secretária):
|
||||
* - exige tenantId explícito
|
||||
* - permite definir owner_id (terapeuta dono do compromisso)
|
||||
*
|
||||
* Segurança real deve ser garantida por RLS:
|
||||
* - clinic_admin/tenant_admin pode criar para qualquer owner dentro do tenant
|
||||
* - therapist não deve conseguir passar daqui (guard + RLS)
|
||||
*/
|
||||
export async function createClinicAgendaEvento (payload, { tenantId } = {}) {
|
||||
assertValidTenantId(tenantId)
|
||||
if (!payload) throw new Error('Payload vazio.')
|
||||
|
||||
const ownerId = payload.owner_id
|
||||
if (!ownerId || ownerId === 'null' || ownerId === 'undefined') {
|
||||
throw new Error('owner_id é obrigatório para criação pela clínica.')
|
||||
}
|
||||
|
||||
const insertPayload = {
|
||||
...payload,
|
||||
tenant_id: tenantId
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.insert(insertPayload)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualização segura para clínica:
|
||||
* - filtra por id + tenant_id (evita update cruzado)
|
||||
* - permite editar owner_id (caso você mova evento para outro profissional)
|
||||
*/
|
||||
export async function updateClinicAgendaEvento (id, patch, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
if (!patch) throw new Error('Patch vazio.')
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update(patch)
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete seguro para clínica:
|
||||
* - filtra por id + tenant_id
|
||||
*/
|
||||
export async function deleteClinicAgendaEvento (id, { tenantId } = {}) {
|
||||
if (!id) throw new Error('ID inválido.')
|
||||
assertValidTenantId(tenantId)
|
||||
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.delete()
|
||||
.eq('id', id)
|
||||
.eq('tenant_id', tenantId)
|
||||
|
||||
if (error) throw error
|
||||
return true
|
||||
}
|
||||
@@ -1,20 +1,52 @@
|
||||
// src/features/agenda/services/agendaMappers.js
|
||||
|
||||
export function mapAgendaEventosToCalendarEvents (rows) {
|
||||
return (rows || []).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
extendedProps: {
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
paciente_id: r.paciente_id,
|
||||
terapeuta_id: r.terapeuta_id,
|
||||
observacoes: r.observacoes,
|
||||
owner_id: r.owner_id
|
||||
return (rows || []).map((r) => {
|
||||
// 🔥 regra importante:
|
||||
// prioridade: owner_id
|
||||
// fallback: terapeuta_id
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
extendedProps: {
|
||||
// 🔥 ESSENCIAL PARA O MOSAICO
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
paciente_nome: r.patients?.nome_completo ?? null,
|
||||
paciente_avatar: r.patients?.avatar_url ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
// ✅ usados na clínica p/ mascarar/privacidade
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
// ✅ compromisso determinístico
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null,
|
||||
|
||||
// ✅ campos customizados
|
||||
titulo_custom: r.titulo_custom ?? null,
|
||||
extra_fields: r.extra_fields ?? null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export function buildNextSessions (rows, now = new Date()) {
|
||||
@@ -98,21 +130,52 @@ export function buildWeeklyBreakBackgroundEvents (pausas, rangeStart, rangeEnd)
|
||||
}
|
||||
|
||||
export function mapAgendaEventosToClinicResourceEvents (rows) {
|
||||
return (rows || []).map((r) => ({
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
resourceId: r.owner_id, // 🔥 coluna = dono da agenda (profissional)
|
||||
extendedProps: {
|
||||
tipo: r.tipo,
|
||||
status: r.status,
|
||||
paciente_id: r.paciente_id,
|
||||
terapeuta_id: r.terapeuta_id,
|
||||
observacoes: r.observacoes,
|
||||
owner_id: r.owner_id
|
||||
return (rows || []).map((r) => {
|
||||
const ownerId = normalizeId(r?.owner_id ?? r?.terapeuta_id ?? null)
|
||||
|
||||
const commitment = r.determined_commitments
|
||||
const bgColor = commitment?.bg_color ? `#${commitment.bg_color}` : undefined
|
||||
const txtColor = commitment?.text_color || undefined
|
||||
|
||||
return {
|
||||
id: r.id,
|
||||
title: r.titulo || tituloFallback(r.tipo),
|
||||
start: r.inicio_em,
|
||||
end: r.fim_em,
|
||||
|
||||
// 🔥 resourceId também precisa ser confiável
|
||||
resourceId: ownerId,
|
||||
|
||||
...(bgColor && { backgroundColor: bgColor, borderColor: bgColor }),
|
||||
...(txtColor && { textColor: txtColor }),
|
||||
|
||||
extendedProps: {
|
||||
owner_id: ownerId,
|
||||
|
||||
tipo: r.tipo ?? null,
|
||||
status: r.status ?? null,
|
||||
|
||||
paciente_id: r.paciente_id ?? null,
|
||||
terapeuta_id: r.terapeuta_id ?? null,
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
visibility_scope: r.visibility_scope ?? null,
|
||||
masked: !!r.masked,
|
||||
|
||||
determined_commitment_id: r.determined_commitment_id ?? null,
|
||||
commitment_bg_color: bgColor ?? null,
|
||||
commitment_text_color: txtColor ?? null
|
||||
}
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------- helpers --------------------
|
||||
|
||||
function normalizeId (v) {
|
||||
if (v === null || v === undefined) return null
|
||||
const s = String(v).trim()
|
||||
return s ? s : null
|
||||
}
|
||||
|
||||
function normalizeWeekday (value) {
|
||||
|
||||
@@ -28,7 +28,9 @@ export async function getMyAgendaSettings () {
|
||||
.from('agenda_configuracoes')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.single()
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.maybeSingle()
|
||||
|
||||
if (error) throw error
|
||||
return data
|
||||
@@ -49,7 +51,7 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, patients(id, nome_completo, avatar_url), determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('owner_id', uid)
|
||||
.gte('inicio_em', startISO)
|
||||
@@ -57,7 +59,27 @@ export async function listMyAgendaEvents ({ startISO, endISO, tenantId: tenantId
|
||||
.order('inicio_em', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
return data || []
|
||||
const rows = data || []
|
||||
|
||||
// Eventos antigos têm paciente_id mas patient_id=null (sem FK) → join retorna null.
|
||||
// Fazemos um segundo fetch para esses casos e mesclamos.
|
||||
const orphanIds = [...new Set(
|
||||
rows.filter(r => r.paciente_id && !r.patients).map(r => r.paciente_id)
|
||||
)]
|
||||
if (orphanIds.length) {
|
||||
const { data: pts } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, avatar_url')
|
||||
.in('id', orphanIds)
|
||||
if (pts?.length) {
|
||||
const map = Object.fromEntries(pts.map(p => [p.id, p]))
|
||||
for (const r of rows) {
|
||||
if (r.paciente_id && !r.patients) r.patients = map[r.paciente_id] || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +99,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*')
|
||||
.select('*, determined_commitments!determined_commitment_id(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
Reference in New Issue
Block a user