Correcao Sidebar Classico e Rail, Correcao Layout, Ajuste de Breakpoint para Tailwind, Ajuste AppTopbar, Ajuste Menu PopOver, Recriado Paleta de Cores, Inserido algumas animações leves, Reajuste Cor items NOVOS da tabela, Drawer Ajuda Corrigido no Logout, Whatsapp, sms, email, recursos extras

This commit is contained in:
Leonardo
2026-03-24 21:26:58 -03:00
parent a89d1f5560
commit 53a4980396
453 changed files with 121427 additions and 174407 deletions
+141 -144
View File
@@ -15,213 +15,210 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, onMounted } from 'vue'
import { computed, ref, watch, onMounted } from 'vue';
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import interactionPlugin from '@fullcalendar/interaction'
import dayGridPlugin from '@fullcalendar/daygrid'
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import dayGridPlugin from '@fullcalendar/daygrid';
import ProgressSpinner from 'primevue/progressspinner'
import ProgressSpinner from 'primevue/progressspinner';
const props = defineProps({
// UI
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
// UI
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
// calendar behavior
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' },
businessHours: { type: [Array, Object], default: () => [] },
// calendar behavior
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' },
businessHours: { type: [Array, Object], default: () => [] },
// data
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
})
// data
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }
});
const emit = defineEmits([
'rangeChange',
'selectTime',
'eventClick',
'eventDrop',
'eventResize'
])
const emit = defineEmits(['rangeChange', 'selectTime', 'eventClick', 'eventDrop', 'eventResize']);
const fcRef = ref(null)
const fcRef = ref(null);
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'))
const initialView = computed(() => (props.view === 'week' ? 'timeGridWeek' : 'timeGridDay'));
function getApi () {
const inst = fcRef.value
return inst?.getApi?.() || null
function getApi() {
const inst = fcRef.value;
return inst?.getApi?.() || null;
}
function emitRange () {
const api = getApi()
if (!api) return
const v = api.view
emit('rangeChange', { start: v.activeStart, end: v.activeEnd })
function emitRange() {
const api = getApi();
if (!api) return;
const v = api.view;
emit('rangeChange', { start: v.activeStart, end: v.activeEnd });
}
// -----------------------------
// Calendar options
// -----------------------------
const calendarOptions = computed(() => {
const isWorkHours = props.mode !== 'full_24h'
const isWorkHours = props.mode !== 'full_24h';
// No modo 24h, não recorta — mas mantemos min/max caso você queira ainda controlar
const minTime = isWorkHours ? props.slotMinTime : '00:00:00'
// FullCalendar timeGrid costuma aceitar 24:00:00, mas 23:59:59 evita edge-case
const maxTime = isWorkHours ? props.slotMaxTime : '23:59:59'
// No modo 24h, não recorta — mas mantemos min/max caso você queira ainda controlar
const minTime = isWorkHours ? props.slotMinTime : '00:00:00';
// FullCalendar timeGrid costuma aceitar 24:00:00, mas 23:59:59 evita edge-case
const maxTime = isWorkHours ? props.slotMaxTime : '23:59:59';
return {
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
initialView: initialView.value,
return {
plugins: [timeGridPlugin, interactionPlugin, dayGridPlugin],
initialView: initialView.value,
timeZone: props.timezone,
timeZone: props.timezone,
// Header desativado (você controla no Toolbar)
headerToolbar: false,
// Header desativado (você controla no Toolbar)
headerToolbar: false,
// Visão "produto": blocos com linhas suaves
nowIndicator: true,
allDaySlot: false,
expandRows: true,
height: 'auto',
// Visão "produto": blocos com linhas suaves
nowIndicator: true,
allDaySlot: false,
expandRows: true,
height: 'auto',
// Seleção / DnD / Resize
selectable: true,
selectMirror: true,
editable: true,
eventStartEditable: true,
eventDurationEditable: true,
// Seleção / DnD / Resize
selectable: true,
selectMirror: true,
editable: true,
eventStartEditable: true,
eventDurationEditable: true,
// Intervalos visuais
slotDuration: props.slotDuration,
slotMinTime: minTime,
slotMaxTime: maxTime,
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
// Intervalos visuais
slotDuration: props.slotDuration,
slotMinTime: minTime,
slotMaxTime: maxTime,
slotLabelFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false
},
// Horário "verdadeiro" de funcionamento (se você usar)
businessHours: props.businessHours,
// Horário "verdadeiro" de funcionamento (se você usar)
businessHours: props.businessHours,
// Dados
events: props.events,
// Dados
events: props.events,
// Melhor UX
weekends: true,
firstDay: 1, // segunda
// Melhor UX
weekends: true,
firstDay: 1, // segunda
// Callbacks
datesSet: () => {
// dispara quando muda o intervalo exibido (prev/next/today/view)
emitRange()
},
// Callbacks
datesSet: () => {
// dispara quando muda o intervalo exibido (prev/next/today/view)
emitRange();
},
select: (selection) => {
// selection: { start, end, allDay, ... }
emit('selectTime', selection)
},
select: (selection) => {
// selection: { start, end, allDay, ... }
emit('selectTime', selection);
},
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
}
})
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info)
};
});
// -----------------------------
// Exposed methods (para Toolbar/Page)
// -----------------------------
function goToday () { getApi()?.today?.() }
function prev () { getApi()?.prev?.() }
function next () { getApi()?.next?.() }
function setView (v) {
const api = getApi()
if (!api) return
api.changeView(v === 'week' ? 'timeGridWeek' : 'timeGridDay')
function goToday() {
getApi()?.today?.();
}
function prev() {
getApi()?.prev?.();
}
function next() {
getApi()?.next?.();
}
defineExpose({ goToday, prev, next, setView })
function setView(v) {
const api = getApi();
if (!api) return;
api.changeView(v === 'week' ? 'timeGridWeek' : 'timeGridDay');
}
defineExpose({ goToday, prev, next, setView });
// Se a prop view mudar, sincroniza
watch(
() => props.view,
(v) => setView(v)
)
() => props.view,
(v) => setView(v)
);
// Emite o range inicial assim que montar
onMounted(() => {
// garante que o FullCalendar já criou a view
setTimeout(() => emitRange(), 0)
})
// garante que o FullCalendar já criou a view
setTimeout(() => emitRange(), 0);
});
</script>
<template>
<div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading">
<div class="flex flex-col gap-2 w-full px-2 py-2">
<Skeleton height="2rem" class="mb-1" />
<div class="grid grid-cols-7 gap-1">
<Skeleton v-for="n in 7" :key="n" height="1.25rem" />
<div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading">
<div class="flex flex-col gap-2 w-full px-2 py-2">
<Skeleton height="2rem" class="mb-1" />
<div class="grid grid-cols-7 gap-1">
<Skeleton v-for="n in 7" :key="n" height="1.25rem" />
</div>
<Skeleton v-for="n in 8" :key="'row' + n" height="3rem" />
</div>
</div>
<Skeleton v-for="n in 8" :key="'row' + n" height="3rem" />
</div>
</div>
<FullCalendar
ref="fcRef"
:options="calendarOptions"
/>
</div>
<FullCalendar ref="fcRef" :options="calendarOptions" />
</div>
</template>
<style scoped>
.agenda-calendar-wrap{
position: relative;
width: 100%;
.agenda-calendar-wrap {
position: relative;
width: 100%;
}
.agenda-calendar-loading{
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
background: color-mix(in srgb, var(--surface-card) 85%, transparent);
border-radius: 16px;
.agenda-calendar-loading {
position: absolute;
inset: 0;
z-index: 20;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
backdrop-filter: blur(3px);
background: color-mix(in srgb, var(--surface-card) 85%, transparent);
border-radius: 16px;
}
/* Deixa o calendário "respirar" dentro de cards/layouts */
:deep(.fc){
font-size: 0.95rem;
:deep(.fc) {
font-size: 0.95rem;
}
:deep(.fc .fc-timegrid-slot){
height: 2.2rem;
:deep(.fc .fc-timegrid-slot) {
height: 2.2rem;
}
:deep(.fc .fc-timegrid-now-indicator-line){
opacity: .75;
:deep(.fc .fc-timegrid-now-indicator-line) {
opacity: 0.75;
}
:deep(.fc .fc-scrollgrid){
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--surface-border);
:deep(.fc .fc-scrollgrid) {
border-radius: 16px;
overflow: hidden;
border: 1px solid var(--surface-border);
}
:deep(.fc .fc-timegrid-axis-cushion),
:deep(.fc .fc-timegrid-slot-label-cushion){
color: var(--text-color-secondary);
:deep(.fc .fc-timegrid-slot-label-cushion) {
color: var(--text-color-secondary);
}
</style>
</style>
@@ -15,101 +15,108 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref } from 'vue'
import { computed, ref } from 'vue';
import FullCalendar from '@fullcalendar/vue3'
import resourceTimeGridPlugin from '@fullcalendar/resource-timegrid'
import interactionPlugin from '@fullcalendar/interaction'
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' },
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' },
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
resources: { type: Array, default: () => [] }, // [{ id, title }]
events: { type: Array, default: () => [] }, // event.resourceId = resource.id
loading: { type: Boolean, default: false }
})
loading: { type: Boolean, default: false }
});
const emit = defineEmits([
'rangeChange',
'eventClick',
'eventDrop',
'eventResize'
])
const emit = defineEmits(['rangeChange', 'eventClick', 'eventDrop', 'eventResize']);
const calendarRef = ref(null)
const calendarRef = ref(null);
const initialView = computed(() => (props.view === 'week' ? 'resourceTimeGridWeek' : 'resourceTimeGridDay'))
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 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,
plugins: [resourceTimeGridPlugin, interactionPlugin],
initialView: initialView.value,
timeZone: props.timezone,
headerToolbar: false,
headerToolbar: false,
nowIndicator: true,
editable: true,
nowIndicator: true,
editable: true,
slotDuration: props.slotDuration,
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotDuration: props.slotDuration,
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
resourceAreaWidth: '280px',
resourceAreaHeaderContent: 'Profissionais',
resources: props.resources,
resourceAreaWidth: '280px',
resourceAreaHeaderContent: 'Profissionais',
resources: props.resources,
events: props.events,
events: props.events,
datesSet(arg) {
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type
})
},
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) },
eventClick(info) {
emit('eventClick', info);
},
eventDrop(info) {
emit('eventDrop', info);
},
eventResize(info) {
emit('eventResize', info);
},
height: 'auto',
expandRows: true,
allDaySlot: false
}))
height: 'auto',
expandRows: true,
allDaySlot: false
}));
function api () {
const fc = calendarRef.value
return fc?.getApi?.()
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') }
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 })
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="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 class="p-2 md:p-3">
<FullCalendar ref="calendarRef" :options="options" />
</div>
</div>
</div>
</template>
</template>
@@ -15,327 +15,328 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import { computed, ref, watch, nextTick } from 'vue';
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
import FullCalendar from '@fullcalendar/vue3';
import timeGridPlugin from '@fullcalendar/timegrid';
import dayGridPlugin from '@fullcalendar/daygrid';
import listPlugin from '@fullcalendar/list';
import interactionPlugin from '@fullcalendar/interaction';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
const props = defineProps({
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'local' },
view: { type: String, default: 'day' }, // 'day' | 'week' | 'month'
mode: { type: String, default: 'work_hours' }, // 'full_24h' | 'work_hours'
timezone: { type: String, default: 'local' },
slotDuration: { type: String, default: '00:15:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
slotDuration: { type: String, default: '00:15:00' },
slotMinTime: { type: String, default: '06:00:00' },
slotMaxTime: { type: String, default: '22:00:00' },
// [{ id, title }]
staff: { type: Array, default: () => [] },
// [{ id, title }]
staff: { type: Array, default: () => [] },
// todos os eventos (com extendedProps.owner_id)
events: { type: Array, default: () => [] },
// todos os eventos (com extendedProps.owner_id)
events: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
// largura mínima de cada coluna (terapeutas)
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' },
// ✅ 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' },
// subtitle terapeutas
staffSubtitle: { type: String, default: 'Visão diária operacional' },
// jornada por dia: [{ daysOfWeek:[n], startTime:'HH:MM', endTime:'HH:MM' }]
businessHours: { type: Array, default: () => [] },
// jornada por dia: [{ daysOfWeek:[n], startTime:'HH:MM', endTime:'HH:MM' }]
businessHours: { type: Array, default: () => [] },
// Array de ISO strings (yyyy-mm-dd) de dias totalmente bloqueados.
// Exibe banner vermelho no topo de cada coluna (view "day") e fundo no calendário.
blockedDates: { type: Array, default: () => [] }
})
// Array de ISO strings (yyyy-mm-dd) de dias totalmente bloqueados.
// Exibe banner vermelho no topo de cada coluna (view "day") e fundo no calendário.
blockedDates: { type: Array, default: () => [] }
});
const emit = defineEmits([
'rangeChange',
'slotSelect',
'eventClick',
'eventDrop',
'eventResize',
'debugColumn'
])
const emit = defineEmits(['rangeChange', 'slotSelect', 'eventClick', 'eventDrop', 'eventResize', 'debugColumn']);
const calendarRefs = ref([])
function setCalendarRef (el, idx) {
if (!el) return
calendarRefs.value[idx] = el
const calendarRefs = ref([]);
function setCalendarRef(el, idx) {
if (!el) return;
calendarRefs.value[idx] = el;
}
const initialView = computed(() => {
if (props.view === 'week') return 'timeGridWeek'
if (props.view === 'month') return 'dayGridMonth'
return 'timeGridDay'
})
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))
const computedSlotMaxTime = computed(() => (props.mode === 'full_24h' ? '23:59:59' : props.slotMaxTime))
const computedSlotMinTime = computed(() => (props.mode === 'full_24h' ? '00:00:00' : props.slotMinTime));
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' }
})
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' }))
})
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 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 forEachApi(fn) {
for (let i = 0; i < calendarRefs.value.length; i++) {
const api = apiAt(i);
if (api) fn(api, i);
}
}
function goToday () {
const d = new Date(); d.setHours(12, 0, 0, 0)
trackedDate = d
forEachApi(api => api.today())
function goToday() {
const d = new Date();
d.setHours(12, 0, 0, 0);
trackedDate = d;
forEachApi((api) => api.today());
}
function prev () { trackedDate = null; forEachApi(api => api.prev()) }
function next () { trackedDate = null; forEachApi(api => api.next()) }
function gotoDate (date) {
if (!date) return
const dt = (date instanceof Date) ? new Date(date) : new Date(date)
dt.setHours(12, 0, 0, 0)
trackedDate = new Date(dt)
forEachApi(api => api.gotoDate(dt))
function prev() {
trackedDate = null;
forEachApi((api) => api.prev());
}
function next() {
trackedDate = null;
forEachApi((api) => api.next());
}
function gotoDate(date) {
if (!date) return;
const dt = date instanceof Date ? new Date(date) : new Date(date);
dt.setHours(12, 0, 0, 0);
trackedDate = new Date(dt);
forEachApi((api) => api.gotoDate(dt));
}
function setView (v) {
const target = v === 'week' ? 'timeGridWeek' : v === 'month' ? 'dayGridMonth' : v === 'list' ? 'listWeek' : 'timeGridDay'
forEachApi(api => api.changeView(target))
function setView(v) {
const target = v === 'week' ? 'timeGridWeek' : v === 'month' ? 'dayGridMonth' : v === 'list' ? 'listWeek' : 'timeGridDay';
forEachApi((api) => api.changeView(target));
}
function setMode () {}
function setMode() {}
defineExpose({ goToday, prev, next, gotoDate, setView, setMode })
defineExpose({ goToday, prev, next, gotoDate, setView, setMode });
// ── Dias bloqueados ────────────────────────────────────────────
const blockedSet = computed(() => new Set(props.blockedDates || []))
const blockedSet = computed(() => new Set(props.blockedDates || []));
// Eventos de background que colorem o dia inteiro de vermelho suave no FullCalendar
const blockedBgEvents = computed(() =>
(props.blockedDates || []).map(iso => ({
id: `_blocked_bg_${iso}`,
start: iso,
allDay: true,
display: 'background',
color: 'rgba(239,68,68,0.13)',
classNames: ['fc-blocked-day']
}))
)
(props.blockedDates || []).map((iso) => ({
id: `_blocked_bg_${iso}`,
start: iso,
allDay: true,
display: 'background',
color: 'rgba(239,68,68,0.13)',
classNames: ['fc-blocked-day']
}))
);
// ISO do dia sendo exibido atualmente — atualizado pelo datesSet
const currentViewISO = ref('')
const currentViewISO = ref('');
// Retorna true se o dia exibido está bloqueado (banner só aparece na view "day")
function isCurrentDayBlocked () {
return props.view === 'day' && !!currentViewISO.value && blockedSet.value.has(currentViewISO.value)
function isCurrentDayBlocked() {
return props.view === 'day' && !!currentViewISO.value && blockedSet.value.has(currentViewISO.value);
}
function eventsFor (ownerId) {
const list = (props.events || []).filter(
e => String(e?.extendedProps?.owner_id || '') === String(ownerId || '')
)
return [...list, ...blockedBgEvents.value]
function eventsFor(ownerId) {
const list = (props.events || []).filter((e) => String(e?.extendedProps?.owner_id || '') === String(ownerId || ''));
return [...list, ...blockedBgEvents.value];
}
// ---- range sync ----
let lastRangeKey = ''
let suppressSync = false
let trackedDate = null // data-alvo atual (definida via gotoDate/goToday; limpa em prev/next)
let lastRangeKey = '';
let suppressSync = false;
let trackedDate = null; // data-alvo atual (definida via gotoDate/goToday; limpa em prev/next)
function sameDay (a, b) {
if (!a || !b) return false
return a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
function sameDay(a, b) {
if (!a || !b) return false;
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
}
function onDatesSet (arg) {
const cd = arg.view?.currentStart || arg.start
if (cd) {
currentViewISO.value = `${cd.getFullYear()}-${String(cd.getMonth()+1).padStart(2,'0')}-${String(cd.getDate()).padStart(2,'0')}`
}
// Calendário recém-montado disparou datesSet com data antiga (ex: hoje) enquanto
// a agenda já estava em outra data (trackedDate). Navega silenciosamente para a
// data correta sem emitir rangeChange nem atualizar lastRangeKey.
if (trackedDate && cd && !sameDay(cd, trackedDate)) {
if (!suppressSync) {
suppressSync = true
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur || sameDay(cur, trackedDate)) return
api.gotoDate(trackedDate)
})
Promise.resolve().then(() => { suppressSync = false })
function onDatesSet(arg) {
const cd = arg.view?.currentStart || arg.start;
if (cd) {
currentViewISO.value = `${cd.getFullYear()}-${String(cd.getMonth() + 1).padStart(2, '0')}-${String(cd.getDate()).padStart(2, '0')}`;
}
return
}
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`
if (key === lastRangeKey) return
lastRangeKey = key
// Calendário recém-montado disparou datesSet com data antiga (ex: hoje) enquanto
// a agenda já estava em outra data (trackedDate). Navega silenciosamente para a
// data correta sem emitir rangeChange nem atualizar lastRangeKey.
if (trackedDate && cd && !sameDay(cd, trackedDate)) {
if (!suppressSync) {
suppressSync = true;
forEachApi((api) => {
const cur = api.view?.currentStart;
if (!cur || sameDay(cur, trackedDate)) return;
api.gotoDate(trackedDate);
});
Promise.resolve().then(() => {
suppressSync = false;
});
}
return;
}
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type,
currentDate: cd
})
const key = `${arg.startStr}__${arg.endStr}__${arg.view?.type || ''}`;
if (key === lastRangeKey) return;
lastRangeKey = key;
if (suppressSync) return
suppressSync = true
emit('rangeChange', {
start: arg.start,
end: arg.end,
startStr: arg.startStr,
endStr: arg.endStr,
viewType: arg.view.type,
currentDate: cd
});
const masterDate = cd
forEachApi((api) => {
const cur = api.view?.currentStart
if (!cur || !masterDate) return
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate)
})
if (suppressSync) return;
suppressSync = true;
Promise.resolve().then(() => { suppressSync = false })
const masterDate = cd;
forEachApi((api) => {
const cur = api.view?.currentStart;
if (!cur || !masterDate) return;
if (cur.getTime() !== masterDate.getTime()) api.gotoDate(masterDate);
});
Promise.resolve().then(() => {
suppressSync = false;
});
}
watch(() => props.view, async () => {
await nextTick()
setView(props.view)
})
watch(
() => props.view,
async () => {
await nextTick();
setView(props.view);
}
);
// ✅ Fix: watch combinado — evita render intermediário que colapsava labels de hora cheia
watch([computedSlotMinTime, computedSlotMaxTime], async ([minT, maxT]) => {
await nextTick()
forEachApi(api => {
api.setOption?.('slotMinTime', minT)
api.setOption?.('slotMaxTime', maxT)
api.updateSize?.()
})
})
await nextTick();
forEachApi((api) => {
api.setOption?.('slotMinTime', minT);
api.setOption?.('slotMaxTime', maxT);
api.updateSize?.();
});
});
// ---------- helpers UI ----------
function colSubtitle (p) {
return p?.__kind === 'clinic' ? props.clinicSubtitle : props.staffSubtitle
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 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, listPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: props.timezone,
function buildFcOptions(ownerId) {
const base = {
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: props.timezone,
headerToolbar: false,
initialView: initialView.value,
headerToolbar: false,
initialView: initialView.value,
nowIndicator: true,
editable: true,
selectable: true,
selectMirror: true,
nowIndicator: true,
editable: true,
selectable: true,
selectMirror: true,
slotDuration: props.slotDuration,
snapDuration: '00:15:00',
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotLabelInterval: '00:30',
slotLabelContent: (arg) => {
const min = arg.date.getMinutes()
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0')
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` }
}
return { html: `<span class="fc-slot-label-half">:${String(min).padStart(2, '0')}</span>` }
},
slotDuration: props.slotDuration,
snapDuration: '00:15:00',
slotMinTime: computedSlotMinTime.value,
slotMaxTime: computedSlotMaxTime.value,
slotLabelInterval: '00:30',
slotLabelContent: (arg) => {
const min = arg.date.getMinutes();
if (min === 0) {
const h = String(arg.date.getHours()).padStart(2, '0');
return { html: `<span class="fc-slot-label-hour">${h}:00</span>` };
}
return { html: `<span class="fc-slot-label-half">:${String(min).padStart(2, '0')}</span>` };
},
businessHours: props.businessHours,
businessHours: props.businessHours,
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' },
},
},
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' }
}
},
height: 'auto',
expandRows: true,
allDaySlot: false,
height: 'auto',
expandRows: true,
allDaySlot: false,
events: eventsFor(ownerId),
datesSet: onDatesSet,
events: eventsFor(ownerId),
datesSet: onDatesSet,
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info),
eventClick: (info) => emit('eventClick', info),
eventDrop: (info) => emit('eventDrop', info),
eventResize: (info) => emit('eventResize', info),
eventContent: (arg) => {
const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
eventContent: (arg) => {
const ext = arg.event.extendedProps || {};
const avatarUrl = ext.paciente_avatar || '';
const nome = ext.paciente_nome || '';
const obs = ext.observacoes || '';
const title = arg.event.title || '';
const timeText = arg.timeText || '';
const pacienteStatus = ext.paciente_status || '';
const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;')
const esc = (s) =>
String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
const initials = (n) => {
const p = String(n).trim().split(/\s+/).filter(Boolean)
if (!p.length) return '?'
if (p.length === 1) return p[0].slice(0, 2).toUpperCase()
return (p[0][0] + p[p.length - 1][0]).toUpperCase()
}
const initials = (n) => {
const p = String(n).trim().split(/\s+/).filter(Boolean);
if (!p.length) return '?';
if (p.length === 1) return p[0].slice(0, 2).toUpperCase();
return (p[0][0] + p[p.length - 1][0]).toUpperCase();
};
const avatarHtml = avatarUrl
? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />`
: nome
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: ''
const avatarHtml = avatarUrl ? `<img src="${esc(avatarUrl)}" class="ev-avatar ev-avatar-img" />` : nome ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` : '';
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const statusBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '';
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : '';
const statusBadge =
pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado'
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: '';
return {
html: `<div class="ev-custom">
return {
html: `<div class="ev-custom">
${avatarHtml}
<div class="ev-body">
${timeHtml}
@@ -344,229 +345,242 @@ function buildFcOptions (ownerId) {
${obsHtml}
</div>
</div>`
}
},
};
},
eventClassNames: (arg) => {
const classes = []
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
return classes
},
eventClassNames: (arg) => {
const classes = [];
if (arg?.event?.backgroundColor) classes.push('evt-has-color');
return classes;
},
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important')
info.el.style.setProperty('border-color', bgColor, 'important')
}
}
}
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color;
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important');
info.el.style.setProperty('border-color', bgColor, 'important');
}
}
};
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
})
}
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
return base;
}
</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="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="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 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>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar :ref="(el) => setCalendarRef(el, 0)" :options="buildFcOptions(clinicColumn.id)" />
</div>
</div>
</div>
<div class="text-xs opacity-70 whitespace-nowrap">
{{ mode === 'full_24h' ? '24h' : 'Horário' }}
<!-- Á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>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar :ref="(el) => setCalendarRef(el, clinicColumn ? sIdx + 1 : sIdx)" :options="buildFcOptions(p.id)" />
</div>
</div>
</div>
</div>
</div>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, 0)"
:options="buildFcOptions(clinicColumn.id)"
/>
</div>
</div>
</div>
<!-- Á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>
<!-- Banner: dia bloqueado aparece apenas na view "day" -->
<div v-if="isCurrentDayBlocked()" class="mosaic-blocked-banner">
<i class="pi pi-lock text-xs" />
<span>Dia bloqueado apenas compromissos de sessão não são permitidos</span>
</div>
<div class="p-2">
<FullCalendar
:ref="(el) => setCalendarRef(el, (clinicColumn ? (sIdx + 1) : sIdx))"
:options="buildFcOptions(p.id)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style>
/* Evento customizado — unscoped pois é HTML injetado pelo FullCalendar */
.ev-custom {
display: flex;
align-items: flex-start;
gap: 5px;
overflow: hidden;
padding: 1px 2px;
height: 100%;
width: 100%;
display: flex;
align-items: flex-start;
gap: 5px;
overflow: hidden;
padding: 1px 2px;
height: 100%;
width: 100%;
}
.ev-avatar {
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 1px;
width: 22px;
height: 22px;
border-radius: 50%;
flex-shrink: 0;
margin-top: 1px;
}
.ev-avatar-img {
object-fit: cover;
}
.ev-avatar-img { object-fit: cover; }
.ev-avatar-initials {
background: rgba(255,255,255,0.25);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
letter-spacing: .5px;
background: rgba(255, 255, 255, 0.25);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
font-weight: 700;
letter-spacing: 0.5px;
}
.ev-body {
min-width: 0;
flex: 1;
overflow: hidden;
}
.ev-time {
font-size: 10px;
opacity: 0.8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ev-title {
font-size: 11px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.ev-obs {
font-size: 10px;
opacity: 0.75;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 1px;
}
.ev-body { min-width: 0; flex: 1; overflow: hidden; }
.ev-time { font-size: 10px; opacity: 0.8; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ev-title { font-size: 11px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3; }
.ev-obs { font-size: 10px; opacity: 0.75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
</style>
<style scoped>
.mosaic-shell {
display: flex;
gap: 12px;
padding: 8px;
display: flex;
gap: 12px;
padding: 8px;
}
@media (min-width: 768px) {
.mosaic-shell { padding: 12px; }
.mosaic-shell {
padding: 12px;
}
}
.mosaic-fixed {
flex: 0 0 auto;
width: 420px;
min-width: 320px;
max-width: 460px;
flex: 0 0 auto;
width: 420px;
min-width: 320px;
max-width: 460px;
}
.mosaic-scroll {
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
}
.mosaic-grid {
display: grid;
grid-auto-flow: column;
gap: 12px;
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;
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;
padding: 12px;
border-bottom: 1px solid var(--surface-border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Banner vermelho de dia bloqueado no topo de cada coluna */
.mosaic-blocked-banner {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 0.75rem;
font-weight: 600;
color: var(--red-700, #b91c1c);
background: color-mix(in srgb, var(--red-400, #f87171) 15%, var(--surface-card));
border-bottom: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
font-size: 0.75rem;
font-weight: 600;
color: var(--red-700, #b91c1c);
background: color-mix(in srgb, var(--red-400, #f87171) 15%, var(--surface-card));
border-bottom: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
}
</style>
<style>
.fc-slot-label-hour {
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
line-height: 1;
display: inline-block;
font-size: 0.8rem;
font-weight: 700;
color: var(--text-color);
letter-spacing: -0.01em;
line-height: 1;
}
.fc-slot-label-half {
display: inline-block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-color-secondary);
opacity: 0.5;
line-height: 1;
padding-left: 2px;
display: inline-block;
font-size: 0.68rem;
font-weight: 400;
color: var(--text-color-secondary);
opacity: 0.5;
line-height: 1;
padding-left: 2px;
}
/* Garante opacidade total nos dias bloqueados (background event do FullCalendar) */
.fc-blocked-day {
opacity: 1 !important;
opacity: 1 !important;
}
</style>
</style>
File diff suppressed because it is too large Load Diff
@@ -15,15 +15,14 @@
|--------------------------------------------------------------------------
-->
<script setup>
const props = defineProps({
title: { type: String, default: 'Painel' },
subtitle: { type: String, default: 'Visão rápida do dia e ações de triagem.' },
sticky: { type: Boolean, default: true },
showHeaderActions: { type: Boolean, default: false }
})
title: { type: String, default: 'Painel' },
subtitle: { type: String, default: 'Visão rápida do dia e ações de triagem.' },
sticky: { type: Boolean, default: true },
showHeaderActions: { type: Boolean, default: false }
});
const emit = defineEmits(['refresh', 'collapse'])
const emit = defineEmits(['refresh', 'collapse']);
/**
* Este componente é propositalmente "burro".
@@ -33,85 +32,71 @@ const emit = defineEmits(['refresh', 'collapse'])
</script>
<template>
<div
class="agenda-right-panel"
:class="{ 'is-sticky': sticky }"
>
<Card class="h-full">
<template #title>
<div class="flex align-items-start justify-content-between gap-2">
<div class="min-w-0">
<div class="text-base md:text-lg font-semibold truncate">
{{ title }}
</div>
<div class="text-xs mt-1" style="color: var(--text-color-secondary);">
{{ subtitle }}
</div>
</div>
<div class="agenda-right-panel" :class="{ 'is-sticky': sticky }">
<Card class="h-full">
<template #title>
<div class="flex align-items-start justify-content-between gap-2">
<div class="min-w-0">
<div class="text-base md:text-lg font-semibold truncate">
{{ title }}
</div>
<div class="text-xs mt-1" style="color: var(--text-color-secondary)">
{{ subtitle }}
</div>
</div>
<div v-if="showHeaderActions" class="flex align-items-center gap-1">
<Button
size="small"
text
icon="pi pi-refresh"
v-tooltip.top="'Atualizar painel'"
@click="emit('refresh')"
/>
<Button
size="small"
text
icon="pi pi-window-minimize"
v-tooltip.top="'Recolher (UI)'"
@click="emit('collapse')"
/>
</div>
</div>
</template>
<div v-if="showHeaderActions" class="flex align-items-center gap-1">
<Button size="small" text icon="pi pi-refresh" v-tooltip.top="'Atualizar painel'" @click="emit('refresh')" />
<Button size="small" text icon="pi pi-window-minimize" v-tooltip.top="'Recolher (UI)'" @click="emit('collapse')" />
</div>
</div>
</template>
<template #content>
<div class="content-wrap">
<!-- TOP slot (ex.: próximas sessões) -->
<div class="slot-top">
<slot name="top" />
</div>
<template #content>
<div class="content-wrap">
<!-- TOP slot (ex.: próximas sessões) -->
<div class="slot-top">
<slot name="top" />
</div>
<Divider class="my-3" />
<Divider class="my-3" />
<!-- BOTTOM slot (ex.: pulse / atalhos) -->
<div class="slot-bottom">
<slot name="bottom" />
</div>
</div>
</template>
</Card>
</div>
<!-- BOTTOM slot (ex.: pulse / atalhos) -->
<div class="slot-bottom">
<slot name="bottom" />
</div>
</div>
</template>
</Card>
</div>
</template>
<style scoped>
.agenda-right-panel{
width: 100%;
.agenda-right-panel {
width: 100%;
}
.agenda-right-panel.is-sticky{
position: sticky;
top: 1rem;
align-self: start;
.agenda-right-panel.is-sticky {
position: sticky;
top: 1rem;
align-self: start;
}
/* Deixa o painel com scroll interno sem estourar a página */
.content-wrap{
display: flex;
flex-direction: column;
gap: .25rem;
max-height: calc(100vh - 220px);
overflow: auto;
padding-right: .25rem;
.content-wrap {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: calc(100vh - 220px);
overflow: auto;
padding-right: 0.25rem;
}
/* Melhor sensação de "sessões" */
.slot-top, .slot-bottom{
display: flex;
flex-direction: column;
gap: .75rem;
.slot-top,
.slot-bottom {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>
</style>
@@ -15,111 +15,86 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
import ToggleButton from 'primevue/togglebutton'
import ToggleButton from 'primevue/togglebutton';
const props = defineProps({
title: { type: String, default: 'Agenda' },
title: { type: String, default: 'Agenda' },
// 'day' | 'week'
view: { type: String, default: 'day' },
// 'day' | 'week'
view: { type: String, default: 'day' },
// 'full_24h' | 'work_hours'
mode: { type: String, default: 'work_hours' },
// 'full_24h' | 'work_hours'
mode: { type: String, default: 'work_hours' },
showSearch: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: ' ' },
showSearch: { type: Boolean, default: true },
searchPlaceholder: { type: String, default: ' ' },
// controla se exibe botões de ação
showActions: { type: Boolean, default: true }
})
// 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 emit = defineEmits(['today', 'prev', 'next', 'changeView', 'toggleMode', 'createSession', 'createBlock', 'search']);
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
]
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
];
const search = ref('')
const search = ref('');
watch(search, (v) => emit('search', v))
watch(search, (v) => emit('search', v));
const modeLabel = computed(() => (props.mode === 'full_24h' ? '24h' : 'Horário'))
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 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 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>
</template>
File diff suppressed because it is too large Load Diff
@@ -14,462 +14,372 @@
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' }, footer: { class: 'pt-0' } }"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor -->
<span
class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200"
:style="{ backgroundColor: previewBgColor }"
/>
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
</div>
<div class="text-xs opacity-50">
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="mode === 'edit' && canDelete"
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving"
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
</div>
</div>
</template>
<!-- Banner de preview -->
<div
class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]"
:style="{ backgroundColor: previewBgColor }"
>
<span
class="text-base font-bold tracking-[-0.02em] px-[1.1rem] py-[0.35rem] bg-black/15 rounded-full backdrop-blur-sm transition-colors duration-200"
:style="{ color: form.text_color || '#ffffff' }"
>
{{ form.name || 'Nome do compromisso' }}
</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 mt-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
<div class="flex-1">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText
id="cr-nome"
v-model="form.name"
class="w-full"
variant="filled"
:disabled="saving || isEditLocked"
@keydown.enter.prevent="submit"
/>
</IconField>
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<!-- Toggle Ativo -->
<div class="shrink-0 flex items-center gap-2">
<span class="text-sm font-medium">Ativo</span>
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea
id="cr-descricao"
v-model="form.description"
autoResize
rows="2"
class="w-full"
variant="filled"
:disabled="saving || isEditLocked"
/>
<label for="cr-descricao">Descrição</label>
</FloatLabel>
<!-- Seção Cor -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45 mb-3">Cor</div>
<!-- Paleta predefinida -->
<div class="flex flex-wrap gap-[0.45rem]">
<button
v-for="p in presetColors"
:key="p.bg"
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)] disabled:cursor-not-allowed"
:class="form.bg_color === p.bg ? 'shadow-[0_0_0_2px_var(--text-color)] border-2 border-[var(--surface-0,#fff)]' : 'border-0'"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check !text-[13px] text-white font-black p-1" />
</button>
<!-- Custom ColorPicker -->
<div
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer overflow-hidden relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)]"
:class="isCustomColor ? 'shadow-[0_0_0_2px_var(--text-color)]' : ''"
style="background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);"
title="Cor personalizada"
>
<i
v-if="isCustomColor"
class="pi pi-check !text-[13px] text-white font-black absolute z-10 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]"
/>
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
class="absolute inset-0 [&_.p-colorpicker-preview]:w-full [&_.p-colorpicker-preview]:h-full [&_.p-colorpicker-preview]:border-0 [&_.p-colorpicker-preview]:rounded-full [&_.p-colorpicker-preview]:opacity-0"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#ffffff'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-[#ccc]" style="background:#ffffff;" />
Branco
</button>
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#000000'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block" style="background:#000000;" />
Preto
</button>
</div>
</div>
</div>
<!-- Campos adicionais -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
<Button
label="Adicionar campo"
icon="pi pi-plus"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="saving || isFieldsLocked"
@click="addField"
/>
</div>
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">
Nenhum campo adicional configurado.
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="(f, idx) in form.fields"
:key="f.key"
class="grid grid-cols-1 gap-2 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
>
<div class="md:col-span-6">
<FloatLabel variant="on">
<InputText
:id="`cr-field-label-${idx}`"
v-model="f.label"
class="w-full"
variant="filled"
:disabled="saving || isFieldsLocked"
@keydown.enter.prevent="submit"
@blur="syncKey(f)"
/>
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
</FloatLabel>
</div>
<div class="md:col-span-4">
<FloatLabel variant="on">
<Dropdown
:id="`cr-field-type-${idx}`"
v-model="f.type"
:options="fieldTypeOptions"
optionLabel="label"
optionValue="value"
class="w-full"
variant="filled"
:disabled="saving || isFieldsLocked"
/>
<label :for="`cr-field-type-${idx}`">Tipo</label>
</FloatLabel>
</div>
<div class="md:col-span-2 flex items-center justify-end">
<Button
icon="pi pi-trash"
severity="danger"
text
rounded
:disabled="saving || isFieldsLocked"
@click="removeField(idx)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Footer com botões Cancelar / Salvar -->
<template #footer>
<div class="flex items-center justify-end gap-2 pt-2">
<Button
label="Cancelar"
severity="secondary"
outlined
class="rounded-full"
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
icon="pi pi-check"
class="rounded-full"
:loading="saving"
:disabled="!canSubmit"
@click="submit"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, reactive, watch } from 'vue'
import Textarea from 'primevue/textarea'
import Dropdown from 'primevue/dropdown'
import ColorPicker from 'primevue/colorpicker'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed, reactive, watch } from 'vue';
import Textarea from 'primevue/textarea';
import Dropdown from 'primevue/dropdown';
import ColorPicker from 'primevue/colorpicker';
import ToggleSwitch from 'primevue/toggleswitch';
const props = defineProps({
modelValue: { type: Boolean, default: false },
mode: { type: String, default: 'create' }, // 'create' | 'edit'
saving: { type: Boolean, default: false },
commitment: { type: Object, default: null } // quando edit
})
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 emit = defineEmits(['update:modelValue', 'save', 'delete']);
const fieldTypeOptions = [
{ label: 'Texto', value: 'text' },
{ label: 'Texto longo', value: 'textarea' }
]
{ label: 'Texto', value: 'text' },
{ label: 'Texto longo', value: 'textarea' }
];
const presetColors = [
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
]
{ bg: '6366f1', text: '#ffffff', name: 'Índigo' },
{ bg: '8b5cf6', text: '#ffffff', name: 'Violeta' },
{ bg: 'ec4899', text: '#ffffff', name: 'Rosa' },
{ bg: 'ef4444', text: '#ffffff', name: 'Vermelho' },
{ bg: 'f97316', text: '#ffffff', name: 'Laranja' },
{ bg: 'eab308', text: '#000000', name: 'Amarelo' },
{ bg: '22c55e', text: '#ffffff', name: 'Verde' },
{ bg: '14b8a6', text: '#ffffff', name: 'Teal' },
{ bg: '3b82f6', text: '#ffffff', name: 'Azul' },
{ bg: '06b6d4', text: '#ffffff', name: 'Ciano' },
{ bg: '64748b', text: '#ffffff', name: 'Ardósia' },
{ bg: '292524', text: '#ffffff', name: 'Escuro' }
];
// bg_colors dos presets (sem #) para comparação
const presetBgValues = presetColors.map(p => p.bg)
const presetBgValues = presetColors.map((p) => p.bg);
// Verdadeiro quando a cor atual não bate com nenhum preset
const isCustomColor = computed(() => {
if (!form.bg_color) return false
const clean = String(form.bg_color).replace('#', '').toLowerCase()
return !presetBgValues.includes(clean)
})
if (!form.bg_color) return false;
const clean = String(form.bg_color).replace('#', '').toLowerCase();
return !presetBgValues.includes(clean);
});
function applyPreset (p) {
if (props.saving) return
form.bg_color = p.bg
form.text_color = p.text
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)
})
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: []
})
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}`
})
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()
}
)
() => props.modelValue,
(open) => {
if (!open) return;
hydrate();
}
);
watch(
() => props.commitment,
() => {
if (!props.modelValue) return
hydrate()
}
)
() => 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 = []
}
function hydrate() {
const c = props.commitment;
if (props.mode === 'edit' && c) {
form.id = c.id;
form.name = c.name || '';
form.description = c.description || '';
form.native = !!c.native;
form.locked = !!c.locked;
form.active = !!c.active;
form.bg_color = c.bg_color || '6366f1';
form.text_color = c.text_color || '#ffffff';
form.fields = Array.isArray(c.fields) ? JSON.parse(JSON.stringify(c.fields)) : [];
} else {
form.id = null;
form.name = '';
form.description = '';
form.native = false;
form.locked = false;
form.active = true;
form.bg_color = '6366f1';
form.text_color = '#ffffff';
form.fields = [];
}
}
const isActiveLocked = computed(() => !!form.locked)
const isEditLocked = computed(() => false)
const isFieldsLocked = computed(() => false)
const canDelete = computed(() => !form.native)
const isActiveLocked = computed(() => !!form.locked);
const isEditLocked = computed(() => false);
const isFieldsLocked = computed(() => false);
const canDelete = computed(() => !form.native);
const canSubmit = computed(() => {
if (props.saving) return false
if (!String(form.name || '').trim()) return false
return true
})
if (props.saving) return false;
if (!String(form.name || '').trim()) return false;
return true;
});
function close () {
if (props.saving) return
visible.value = false
function close() {
if (props.saving) return;
visible.value = false;
}
function submit () {
if (!canSubmit.value) return
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
}))
}
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)
emit('save', payload);
}
function emitDelete () {
if (props.saving) return
emit('delete', { id: form.id })
visible.value = false
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 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 removeField(idx) {
form.fields.splice(idx, 1);
}
function syncKey (field) {
const next = makeKey(field.label)
field.key = next
function syncKey(field) {
const next = makeKey(field.label);
field.key = next;
}
function makeKey (label) {
const k = String(label || '')
.trim()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '_')
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
return k
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>
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' }, footer: { class: 'pt-0' } }"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex w-full items-center justify-between gap-3 px-1">
<div class="flex items-center gap-3 min-w-0">
<!-- Dot de cor -->
<span class="shrink-0 w-3.5 h-3.5 rounded-full border-2 border-white/30 shadow-[0_0_0_3px_rgba(0,0,0,0.08)] transition-colors duration-200" :style="{ backgroundColor: previewBgColor }" />
<div class="min-w-0">
<div class="text-base font-semibold truncate">
{{ form.name || (mode === 'create' ? 'Novo compromisso' : 'Editar compromisso') }}
</div>
<div class="text-xs opacity-50">
{{ mode === 'create' ? 'Novo tipo de compromisso' : 'Editando tipo de compromisso' }}
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button v-if="mode === 'edit' && canDelete" icon="pi pi-trash" severity="danger" text rounded :disabled="saving" v-tooltip.top="'Excluir'" @click="emitDelete" />
</div>
</div>
</template>
<!-- Banner de preview -->
<div class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]" :style="{ backgroundColor: previewBgColor }">
<span class="text-base font-bold tracking-[-0.02em] px-[1.1rem] py-[0.35rem] bg-black/15 rounded-full backdrop-blur-sm transition-colors duration-200" :style="{ color: form.text_color || '#ffffff' }">
{{ form.name || 'Nome do compromisso' }}
</span>
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 mt-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
<div class="flex-1">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-tag" />
<InputText id="cr-nome" v-model="form.name" class="w-full" variant="filled" :disabled="saving || isEditLocked" @keydown.enter.prevent="submit" />
</IconField>
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<!-- Toggle Ativo -->
<div class="shrink-0 flex items-center gap-2">
<span class="text-sm font-medium">Ativo</span>
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea id="cr-descricao" v-model="form.description" autoResize rows="2" class="w-full" variant="filled" :disabled="saving || isEditLocked" />
<label for="cr-descricao">Descrição</label>
</FloatLabel>
<!-- Seção Cor -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45 mb-3">Cor</div>
<!-- Paleta predefinida -->
<div class="flex flex-wrap gap-[0.45rem]">
<button
v-for="p in presetColors"
:key="p.bg"
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)] disabled:cursor-not-allowed"
:class="form.bg_color === p.bg ? 'shadow-[0_0_0_2px_var(--text-color)] border-2 border-[var(--surface-0,#fff)]' : 'border-0'"
:style="{ backgroundColor: `#${p.bg}` }"
:title="p.name"
:disabled="saving || isEditLocked"
@click="applyPreset(p)"
>
<i v-if="form.bg_color === p.bg" class="pi pi-check !text-[13px] text-white font-black p-1" />
</button>
<!-- Custom ColorPicker -->
<div
class="w-7 h-7 rounded-full grid place-items-center cursor-pointer overflow-hidden relative transition-transform duration-[120ms] ease-in-out hover:scale-[1.18] hover:shadow-[0_3px_10px_rgba(0,0,0,0.2)]"
:class="isCustomColor ? 'shadow-[0_0_0_2px_var(--text-color)]' : ''"
style="background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red)"
title="Cor personalizada"
>
<i v-if="isCustomColor" class="pi pi-check !text-[13px] text-white font-black absolute z-10 pointer-events-none drop-shadow-[0_1px_2px_rgba(0,0,0,0.6)]" />
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
class="absolute inset-0 [&_.p-colorpicker-preview]:w-full [&_.p-colorpicker-preview]:h-full [&_.p-colorpicker-preview]:border-0 [&_.p-colorpicker-preview]:rounded-full [&_.p-colorpicker-preview]:opacity-0"
/>
</div>
</div>
<!-- Texto -->
<div class="flex items-center gap-3 mt-2">
<span class="text-xs font-medium opacity-60 uppercase tracking-wide">Texto</span>
<div class="flex gap-1">
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#ffffff'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-[#ccc]" style="background: #ffffff" />
Branco
</button>
<button
class="inline-flex items-center gap-[0.4rem] px-3 py-1 rounded-full border text-sm font-medium cursor-pointer transition-colors duration-[120ms] disabled:cursor-not-allowed"
:class="
form.text_color === '#000000'
? 'bg-[var(--surface-section,var(--surface-100))] border-[var(--primary-color)] text-[var(--primary-color)] font-bold'
: 'bg-transparent border-[var(--surface-border)] text-[var(--text-color)] hover:bg-[var(--surface-hover)]'
"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="w-2.5 h-2.5 rounded-full inline-block" style="background: #000000" />
Preto
</button>
</div>
</div>
</div>
<!-- Campos adicionais -->
<div class="border border-[var(--surface-border)] rounded-[6px] bg-[var(--surface-card)] p-4">
<div class="flex items-center justify-between gap-2 mb-3">
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
<Button label="Adicionar campo" icon="pi pi-plus" severity="secondary" outlined size="small" class="rounded-full" :disabled="saving || isFieldsLocked" @click="addField" />
</div>
<div v-if="!form.fields.length" class="py-3 text-center text-sm opacity-50">Nenhum campo adicional configurado.</div>
<div v-else class="flex flex-col gap-2">
<div v-for="(f, idx) in form.fields" :key="f.key" class="grid grid-cols-1 gap-2 rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12">
<div class="md:col-span-6">
<FloatLabel variant="on">
<InputText :id="`cr-field-label-${idx}`" v-model="f.label" class="w-full" variant="filled" :disabled="saving || isFieldsLocked" @keydown.enter.prevent="submit" @blur="syncKey(f)" />
<label :for="`cr-field-label-${idx}`">Nome do campo</label>
</FloatLabel>
</div>
<div class="md:col-span-4">
<FloatLabel variant="on">
<Dropdown :id="`cr-field-type-${idx}`" v-model="f.type" :options="fieldTypeOptions" optionLabel="label" optionValue="value" class="w-full" variant="filled" :disabled="saving || isFieldsLocked" />
<label :for="`cr-field-type-${idx}`">Tipo</label>
</FloatLabel>
</div>
<div class="md:col-span-2 flex items-center justify-end">
<Button icon="pi pi-trash" severity="danger" text rounded :disabled="saving || isFieldsLocked" @click="removeField(idx)" />
</div>
</div>
</div>
</div>
</div>
<!-- Footer com botões Cancelar / Salvar -->
<template #footer>
<div class="flex items-center justify-end gap-2 pt-2">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="close" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="saving" :disabled="!canSubmit" @click="submit" />
</div>
</template>
</Dialog>
</template>
@@ -15,443 +15,386 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useToast } from 'primevue/usetoast'
import { useFeriados } from '@/composables/useFeriados'
import DatePicker from 'primevue/datepicker'
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useToast } from 'primevue/usetoast';
import { useFeriados } from '@/composables/useFeriados';
import DatePicker from 'primevue/datepicker';
defineOptions({ inheritAttrs: false })
defineOptions({ inheritAttrs: false });
const props = defineProps({
// Quando passados pelas páginas de agenda, dispensam o boot() interno
ownerId: { type: String, default: null },
tenantId: { type: String, default: null },
workRules: { type: Array, default: () => [] }
})
// Quando passados pelas páginas de agenda, dispensam o boot() interno
ownerId: { type: String, default: null },
tenantId: { type: String, default: null },
workRules: { type: Array, default: () => [] }
});
const emit = defineEmits(['bloqueado'])
const emit = defineEmits(['bloqueado']);
const router = useRouter()
const tenantStore = useTenantStore()
const toast = useToast()
const router = useRouter();
const tenantStore = useTenantStore();
const toast = useToast();
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados()
const { nacionais, municipais, todos, loading, load, criar, remover, isDuplicata, doMes } = useFeriados();
// ── Auth — só faz boot interno se as props não vieram ────────
const _ownerId = ref(props.ownerId)
const _tenantId = ref(props.tenantId)
const _ownerId = ref(props.ownerId);
const _tenantId = ref(props.tenantId);
watch(() => props.ownerId, v => { if (v) _ownerId.value = v })
watch(() => props.tenantId, v => { if (v) _tenantId.value = v })
watch(
() => props.ownerId,
(v) => {
if (v) _ownerId.value = v;
}
);
watch(
() => props.tenantId,
(v) => {
if (v) _tenantId.value = v;
}
);
async function boot () {
if (!_ownerId.value) {
const { data } = await supabase.auth.getUser()
_ownerId.value = data?.user?.id || null
}
if (!_tenantId.value) {
_tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
}
if (_tenantId.value) await load(_tenantId.value)
async function boot() {
if (!_ownerId.value) {
const { data } = await supabase.auth.getUser();
_ownerId.value = data?.user?.id || null;
}
if (!_tenantId.value) {
_tenantId.value = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null;
}
if (_tenantId.value) await load(_tenantId.value);
}
onMounted(boot)
onMounted(boot);
// ── Feriados do mês atual ────────────────────────────────────
const mesAtual = new Date().getMonth() + 1
const feriadosMes = computed(() => doMes(mesAtual))
const mesAtual = new Date().getMonth() + 1;
const feriadosMes = computed(() => doMes(mesAtual));
const MESES = ['Janeiro','Fevereiro','Março','Abril','Maio','Junho','Julho','Agosto','Setembro','Outubro','Novembro','Dezembro']
const nomeMes = MESES[mesAtual - 1]
const MESES = ['Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'];
const nomeMes = MESES[mesAtual - 1];
// ── Dias de trabalho (dow) ────────────────────────────────────
const workDowSet = computed(() =>
new Set((props.workRules || []).filter(r => r.ativo).map(r => Number(r.dia_semana)))
)
const workDowSet = computed(() => new Set((props.workRules || []).filter((r) => r.ativo).map((r) => Number(r.dia_semana))));
function isDiaUtil (iso) {
if (!iso) return false
const [y, m, d] = iso.split('-').map(Number)
const dow = new Date(y, m - 1, d).getDay()
// Se não tem workRules, assume que todo dia pode ser relevante
if (!props.workRules?.length) return true
return workDowSet.value.has(dow)
function isDiaUtil(iso) {
if (!iso) return false;
const [y, m, d] = iso.split('-').map(Number);
const dow = new Date(y, m - 1, d).getDay();
// Se não tem workRules, assume que todo dia pode ser relevante
if (!props.workRules?.length) return true;
return workDowSet.value.has(dow);
}
// ── Bloqueios já existentes para o mês ───────────────────────
const bloqueiosDatas = ref(new Set()) // Set de ISO strings já bloqueadas (feriado)
const loadingBloqueios = ref(false)
const bloqueiosDatas = ref(new Set()); // Set de ISO strings já bloqueadas (feriado)
const loadingBloqueios = ref(false);
async function loadBloqueiosMes () {
if (!_ownerId.value) return
const ano = new Date().getFullYear()
const start = `${ano}-${String(mesAtual).padStart(2,'0')}-01`
const end = `${ano}-${String(mesAtual).padStart(2,'0')}-31`
loadingBloqueios.value = true
try {
const { data } = await supabase
.from('agenda_bloqueios')
.select('data_inicio')
.eq('owner_id', _ownerId.value)
.in('origem', ['agenda_feriado', 'agenda_dia'])
.gte('data_inicio', start)
.lte('data_inicio', end)
bloqueiosDatas.value = new Set((data || []).map(r => r.data_inicio))
} catch { /* silencioso */ }
finally { loadingBloqueios.value = false }
async function loadBloqueiosMes() {
if (!_ownerId.value) return;
const ano = new Date().getFullYear();
const start = `${ano}-${String(mesAtual).padStart(2, '0')}-01`;
const end = `${ano}-${String(mesAtual).padStart(2, '0')}-31`;
loadingBloqueios.value = true;
try {
const { data } = await supabase.from('agenda_bloqueios').select('data_inicio').eq('owner_id', _ownerId.value).in('origem', ['agenda_feriado', 'agenda_dia']).gte('data_inicio', start).lte('data_inicio', end);
bloqueiosDatas.value = new Set((data || []).map((r) => r.data_inicio));
} catch {
/* silencioso */
} finally {
loadingBloqueios.value = false;
}
}
watch(_ownerId, v => { if (v) loadBloqueiosMes() })
onMounted(() => { if (_ownerId.value) loadBloqueiosMes() })
watch(_ownerId, (v) => {
if (v) loadBloqueiosMes();
});
onMounted(() => {
if (_ownerId.value) loadBloqueiosMes();
});
function jaFoiBloqueado (iso) {
return bloqueiosDatas.value.has(iso)
function jaFoiBloqueado(iso) {
return bloqueiosDatas.value.has(iso);
}
// ── Dupla confirmação inline ──────────────────────────────────
const confirmandoIso = ref(null) // ISO do feriado aguardando confirmação
const salvandoIso = ref(null) // ISO sendo gravado
const confirmandoIso = ref(null); // ISO do feriado aguardando confirmação
const salvandoIso = ref(null); // ISO sendo gravado
function pedirConfirmacao (iso) {
// Se já está confirmando outro, cancela e abre o novo
confirmandoIso.value = confirmandoIso.value === iso ? null : iso
function pedirConfirmacao(iso) {
// Se já está confirmando outro, cancela e abre o novo
confirmandoIso.value = confirmandoIso.value === iso ? null : iso;
}
function cancelarConfirmacao () {
confirmandoIso.value = null
function cancelarConfirmacao() {
confirmandoIso.value = null;
}
async function confirmarBloqueio (feriado) {
if (!_ownerId.value || !_tenantId.value) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 })
return
}
salvandoIso.value = feriado.data
confirmandoIso.value = null
try {
const row = {
owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio',
recorrente: false,
titulo: `Feriado: ${feriado.nome}`,
data_inicio: feriado.data,
data_fim: feriado.data,
hora_inicio: null,
hora_fim: null,
origem: 'agenda_feriado'
async function confirmarBloqueio(feriado) {
if (!_ownerId.value || !_tenantId.value) {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Configurações da agenda não carregadas.', life: 3000 });
return;
}
salvandoIso.value = feriado.data;
confirmandoIso.value = null;
try {
const row = {
owner_id: _ownerId.value,
tenant_id: _tenantId.value,
tipo: 'bloqueio',
recorrente: false,
titulo: `Feriado: ${feriado.nome}`,
data_inicio: feriado.data,
data_fim: feriado.data,
hora_inicio: null,
hora_fim: null,
origem: 'agenda_feriado'
};
const { error } = await supabase.from('agenda_bloqueios').insert([row])
if (error) throw error
const { error } = await supabase.from('agenda_bloqueios').insert([row]);
if (error) throw error;
// Marcar sessões existentes no dia como 'remarcar'
await supabase
.from('agenda_eventos')
.update({ status: 'remarcar' })
.eq('owner_id', _ownerId.value)
.eq('tipo', 'sessao')
.gte('inicio_em', `${feriado.data}T00:00:00`)
.lte('inicio_em', `${feriado.data}T23:59:59`)
// Marcar sessões existentes no dia como 'remarcar'
await supabase.from('agenda_eventos').update({ status: 'remarcar' }).eq('owner_id', _ownerId.value).eq('tipo', 'sessao').gte('inicio_em', `${feriado.data}T00:00:00`).lte('inicio_em', `${feriado.data}T23:59:59`);
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data])
toast.add({
severity: 'success',
summary: 'Dia bloqueado',
detail: `${feriado.nome} bloqueado. Sessões existentes marcadas para reagendamento.`,
life: 4000
})
emit('bloqueado', feriado)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao bloquear.', life: 4000 })
} finally {
salvandoIso.value = null
}
bloqueiosDatas.value = new Set([...bloqueiosDatas.value, feriado.data]);
toast.add({
severity: 'success',
summary: 'Dia bloqueado',
detail: `${feriado.nome} bloqueado. Sessões existentes marcadas para reagendamento.`,
life: 4000
});
emit('bloqueado', feriado);
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao bloquear.', life: 4000 });
} finally {
salvandoIso.value = null;
}
}
// ── Dialog cadastro municipal ─────────────────────────────────
const dlgOpen = ref(false)
const saving = ref(false)
const form = ref({ nome: '', data: null, observacao: '', bloqueia_sessoes: false })
const dlgOpen = ref(false);
const saving = ref(false);
const form = ref({ nome: '', data: null, observacao: '', bloqueia_sessoes: false });
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data)
const formValid = computed(() => !!form.value.nome.trim() && !!form.value.data);
function abrirDialog () {
form.value = { nome: '', data: null, observacao: '', bloqueia_sessoes: false }
dlgOpen.value = true
function abrirDialog() {
form.value = { nome: '', data: null, observacao: '', bloqueia_sessoes: false };
dlgOpen.value = true;
}
function dateToISO (d) {
if (!d) return null
const dt = d instanceof Date ? d : new Date(d)
return `${dt.getFullYear()}-${String(dt.getMonth()+1).padStart(2,'0')}-${String(dt.getDate()).padStart(2,'0')}`
function dateToISO(d) {
if (!d) return null;
const dt = d instanceof Date ? d : new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}-${String(dt.getDate()).padStart(2, '0')}`;
}
async function salvar () {
if (!formValid.value) return
const iso = dateToISO(form.value.data)
if (isDuplicata(iso, form.value.nome)) {
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 })
return
}
saving.value = true
try {
await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: iso,
observacao: form.value.observacao || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
})
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 })
dlgOpen.value = false
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 })
} finally {
saving.value = false
}
async function salvar() {
if (!formValid.value) return;
const iso = dateToISO(form.value.data);
if (isDuplicata(iso, form.value.nome)) {
toast.add({ severity: 'warn', summary: 'Duplicado', detail: 'Já existe um feriado com esse nome nessa data.', life: 3000 });
return;
}
saving.value = true;
try {
await criar({
tenant_id: _tenantId.value,
owner_id: _ownerId.value,
tipo: 'municipal',
nome: form.value.nome.trim(),
data: iso,
observacao: form.value.observacao || null,
bloqueia_sessoes: form.value.bloqueia_sessoes
});
toast.add({ severity: 'success', summary: 'Feriado cadastrado', life: 1800 });
dlgOpen.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 3500 });
} finally {
saving.value = false;
}
}
// ── Helpers ───────────────────────────────────────────────────
function fmtDate (iso) {
if (!iso) return ''
const [, m, d] = String(iso).split('-')
return `${d}/${m}`
function fmtDate(iso) {
if (!iso) return '';
const [, m, d] = String(iso).split('-');
return `${d}/${m}`;
}
</script>
<template>
<div v-bind="$attrs" class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Cabeçalho -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="flex items-center gap-2">
<i class="pi pi-star text-amber-500 text-sm" />
<span class="font-semibold text-sm">Próximos feriados</span>
</div>
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
</div>
<!-- Lista -->
<div class="px-4 py-3">
<div v-if="loading" class="flex flex-col gap-2 py-1">
<Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
</div>
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">
Nenhum feriado este mês.
</div>
<ul v-else class="flex flex-col gap-2">
<li
v-for="f in feriadosMes"
:key="f.data + f.nome"
class="flex flex-col gap-1"
>
<!-- Linha principal do feriado -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--text-color-secondary)] font-mono text-xs w-10 shrink-0">{{ fmtDate(f.data) }}</span>
<span class="flex-1 truncate" :class="{ 'line-through opacity-50': jaFoiBloqueado(f.data) }">{{ f.nome }}</span>
<Tag
:value="f.tipo === 'nacional' ? 'Nacional' : 'Municipal'"
:severity="f.tipo === 'nacional' ? 'info' : 'warn'"
class="text-xs shrink-0"
/>
<!-- Botão bloquear / bloqueado -->
<template v-if="isDiaUtil(f.data)">
<!-- bloqueado -->
<span
v-if="jaFoiBloqueado(f.data)"
v-tooltip.top="'Dia bloqueado'"
class="pfc-lock pfc-lock--done"
>
<i class="pi pi-lock text-xs" />
</span>
<!-- Salvando -->
<span v-else-if="salvandoIso === f.data" class="pfc-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Aguardando confirmação ícone ativo -->
<button
v-else-if="confirmandoIso === f.data"
v-tooltip.top="'Cancelar'"
class="pfc-lock pfc-lock--active"
@click="cancelarConfirmacao"
>
<i class="pi pi-times text-xs" />
</button>
<!-- Estado normal abre confirmação -->
<button
v-else
v-tooltip.top="'Bloquear este dia'"
class="pfc-lock pfc-lock--idle"
@click="pedirConfirmacao(f.data)"
>
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline (expande abaixo do item) -->
<Transition name="pfc-expand">
<div
v-if="confirmandoIso === f.data"
class="pfc-confirm"
>
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
</div>
<div class="flex gap-1.5 shrink-0">
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
</div>
<div v-bind="$attrs" class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden shadow-sm">
<!-- Cabeçalho -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border)]">
<div class="flex items-center gap-2">
<i class="pi pi-star text-amber-500 text-sm" />
<span class="font-semibold text-sm">Próximos feriados</span>
</div>
</Transition>
</li>
</ul>
<span class="text-xs text-[var(--text-color-secondary)]">{{ nomeMes }}</span>
</div>
<!-- Lista -->
<div class="px-4 py-3">
<div v-if="loading" class="flex flex-col gap-2 py-1">
<Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
</div>
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">Nenhum feriado este mês.</div>
<ul v-else class="flex flex-col gap-2">
<li v-for="f in feriadosMes" :key="f.data + f.nome" class="flex flex-col gap-1">
<!-- Linha principal do feriado -->
<div class="flex items-center gap-2 text-sm">
<span class="text-[var(--text-color-secondary)] font-mono text-xs w-10 shrink-0">{{ fmtDate(f.data) }}</span>
<span class="flex-1 truncate" :class="{ 'line-through opacity-50': jaFoiBloqueado(f.data) }">{{ f.nome }}</span>
<Tag :value="f.tipo === 'nacional' ? 'Nacional' : 'Municipal'" :severity="f.tipo === 'nacional' ? 'info' : 'warn'" class="text-xs shrink-0" />
<!-- Botão bloquear / bloqueado -->
<template v-if="isDiaUtil(f.data)">
<!-- bloqueado -->
<span v-if="jaFoiBloqueado(f.data)" v-tooltip.top="'Dia já bloqueado'" class="pfc-lock pfc-lock--done">
<i class="pi pi-lock text-xs" />
</span>
<!-- Salvando -->
<span v-else-if="salvandoIso === f.data" class="pfc-lock">
<i class="pi pi-spinner pi-spin text-xs" />
</span>
<!-- Aguardando confirmação ícone ativo -->
<button v-else-if="confirmandoIso === f.data" v-tooltip.top="'Cancelar'" class="pfc-lock pfc-lock--active" @click="cancelarConfirmacao">
<i class="pi pi-times text-xs" />
</button>
<!-- Estado normal abre confirmação -->
<button v-else v-tooltip.top="'Bloquear este dia'" class="pfc-lock pfc-lock--idle" @click="pedirConfirmacao(f.data)">
<i class="pi pi-lock-open text-xs" />
</button>
</template>
</div>
<!-- Confirmação inline (expande abaixo do item) -->
<Transition name="pfc-expand">
<div v-if="confirmandoIso === f.data" class="pfc-confirm">
<i class="pi pi-exclamation-triangle pfc-confirm__icon" />
<div class="flex-1 min-w-0">
<p class="text-xs font-semibold mb-0.5">Bloquear {{ f.nome }}?</p>
<p class="text-xs opacity-70 leading-snug">O dia inteiro ficará indisponível. Sessões existentes serão marcadas para reagendamento.</p>
</div>
<div class="flex gap-1.5 shrink-0">
<Button label="Não" size="small" severity="secondary" outlined class="rounded-full h-7 text-xs px-3" @click="cancelarConfirmacao" />
<Button label="Bloquear" size="small" severity="danger" icon="pi pi-lock" class="rounded-full h-7 text-xs px-3" @click="confirmarBloqueio(f)" />
</div>
</div>
</Transition>
</li>
</ul>
</div>
<!-- Ações -->
<div class="flex flex-col gap-1.5 px-4 pb-4">
<Button icon="pi pi-plus" label="Cadastrar feriado municipal" severity="secondary" outlined size="small" class="w-full rounded-full" @click="abrirDialog" />
<Button icon="pi pi-list" label="Ver todos os feriados" text size="small" class="w-full rounded-full" @click="router.push('/configuracoes/bloqueios')" />
</div>
</div>
<!-- Ações -->
<div class="flex flex-col gap-1.5 px-4 pb-4">
<Button
icon="pi pi-plus"
label="Cadastrar feriado municipal"
severity="secondary"
outlined
size="small"
class="w-full rounded-full"
@click="abrirDialog"
/>
<Button
icon="pi pi-list"
label="Ver todos os feriados"
text
size="small"
class="w-full rounded-full"
@click="router.push('/configuracoes/bloqueios')"
/>
</div>
</div>
<!-- Dialog cadastro -->
<Dialog
v-model:visible="dlgOpen"
modal
:draggable="false"
header="Cadastrar feriado municipal"
:style="{ width: '420px' }"
>
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker
v-model="form.data"
showIcon fluid iconDisplay="input"
dateFormat="dd/mm/yy"
:manualInput="false"
class="mt-1"
>
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome)"
class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:disabled="!formValid || (form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome))"
:loading="saving"
@click="salvar"
/>
</template>
</Dialog>
<!-- Dialog cadastro -->
<Dialog v-model:visible="dlgOpen" modal :draggable="false" header="Cadastrar feriado municipal" :style="{ width: '420px' }">
<div class="flex flex-col gap-4 pt-1">
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Nome do feriado *</label>
<InputText v-model="form.nome" class="w-full mt-1" placeholder="Ex.: Aniversário da cidade, Padroeiro…" />
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Data *</label>
<DatePicker v-model="form.data" showIcon fluid iconDisplay="input" dateFormat="dd/mm/yy" :manualInput="false" class="mt-1">
<template #inputicon="sp"><i class="pi pi-calendar" @click="sp.clickCallback" /></template>
</DatePicker>
</div>
<div>
<label class="text-xs text-[var(--text-color-secondary)] font-medium">Observação <span class="opacity-60">(opcional)</span></label>
<Textarea v-model="form.observacao" class="w-full mt-1" rows="2" autoResize placeholder="Nota interna…" />
</div>
<div v-if="form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome)" class="text-sm text-red-500 flex items-center gap-2">
<i class="pi pi-exclamation-triangle" />
existe um feriado com esse nome nessa data.
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined @click="dlgOpen = false" />
<Button label="Cadastrar" icon="pi pi-check" :disabled="!formValid || (form.data && form.nome && isDuplicata(dateToISO(form.data), form.nome))" :loading="saving" @click="salvar" />
</template>
</Dialog>
</template>
<style scoped>
/* ── Ícone de cadeado por feriado ────────────────────────── */
.pfc-lock {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
flex-shrink: 0;
transition: all 0.14s;
}
.pfc-lock--idle {
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
color: var(--text-color-secondary);
background: transparent;
border: 1.5px solid var(--surface-border);
cursor: pointer;
}
.pfc-lock--idle:hover {
color: var(--red-600, #dc2626);
border-color: var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
color: var(--red-600, #dc2626);
border-color: var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 10%, transparent);
}
.pfc-lock--active {
color: var(--red-600, #dc2626);
border: 1.5px solid var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
cursor: pointer;
color: var(--red-600, #dc2626);
border: 1.5px solid var(--red-400, #f87171);
background: color-mix(in srgb, var(--red-400, #f87171) 12%, transparent);
cursor: pointer;
}
.pfc-lock--done {
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
color: var(--text-color-secondary);
opacity: 0.45;
cursor: default;
}
/* ── Confirmação inline ───────────────────────────────────── */
.pfc-confirm {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
margin-left: 2.75rem; /* alinha com o nome, após a data */
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.625rem 0.75rem;
border-radius: 0.875rem;
background: color-mix(in srgb, var(--red-400, #f87171) 10%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--red-400, #f87171) 30%, transparent);
margin-left: 2.75rem; /* alinha com o nome, após a data */
}
.pfc-confirm__icon {
color: var(--red-500, #ef4444);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
color: var(--red-500, #ef4444);
flex-shrink: 0;
margin-top: 2px;
font-size: 0.8rem;
}
/* ── Transição expand ─────────────────────────────────────── */
.pfc-expand-enter-active,
.pfc-expand-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
transition:
opacity 0.15s ease,
transform 0.15s ease;
}
.pfc-expand-enter-from,
.pfc-expand-leave-to {
opacity: 0;
transform: translateY(-4px);
opacity: 0;
transform: translateY(-4px);
}
</style>
</style>
@@ -15,151 +15,104 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue';
const props = defineProps({
title: { type: String, default: 'Agenda' },
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' } // 'full_24h' | 'work_hours'
})
title: { type: String, default: 'Agenda' },
view: { type: String, default: 'day' }, // 'day' | 'week'
mode: { type: String, default: 'work_hours' } // 'full_24h' | 'work_hours'
});
const emit = defineEmits([
'today',
'prev',
'next',
'changeView',
'toggleMode',
'createSession',
'createBlock',
'search'
])
const emit = defineEmits(['today', 'prev', 'next', 'changeView', 'toggleMode', 'createSession', 'createBlock', 'search']);
// UX: busca com debounce simples
const searchLocal = ref('')
let t = null
const searchLocal = ref('');
let t = null;
watch(searchLocal, (v) => {
clearTimeout(t)
t = setTimeout(() => emit('search', v), 220)
})
clearTimeout(t);
t = setTimeout(() => emit('search', v), 220);
});
// SelectButtons (executivo, direto)
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
]
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }
];
const modeOptions = [
{ label: 'Horário de funcionamento', value: 'work_hours' },
{ label: 'Dia completo (24h)', value: 'full_24h' }
]
{ label: 'Horário de funcionamento', value: 'work_hours' },
{ label: 'Dia completo (24h)', value: 'full_24h' }
];
const modeTag = computed(() => {
return props.mode === 'full_24h'
? { severity: 'info', text: '24h' }
: { severity: 'success', text: 'Funcionamento' }
})
return props.mode === 'full_24h' ? { severity: 'info', text: '24h' } : { severity: 'success', text: 'Funcionamento' };
});
function onChangeView(val) {
emit('changeView', val)
emit('changeView', val);
}
function onToggleMode(val) {
emit('toggleMode', val)
emit('toggleMode', val);
}
</script>
<template>
<Card class="mb-3 md:mb-4">
<template #content>
<div class="flex flex-column gap-3">
<Card class="mb-3 md:mb-4">
<template #content>
<div class="flex flex-column gap-3">
<!-- Top row -->
<div class="flex align-items-start justify-content-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="flex align-items-center gap-2 flex-wrap">
<div class="text-xl md:text-2xl font-semibold leading-tight">
{{ title }}
</div>
<Tag :severity="modeTag.severity" :value="modeTag.text" />
</div>
<div class="text-sm mt-1" style="color: var(--text-color-secondary)">Navegue, filtre e crie compromissos com velocidade sem perder controle clínico.</div>
</div>
<!-- Top row -->
<div class="flex align-items-start justify-content-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="flex align-items-center gap-2 flex-wrap">
<div class="text-xl md:text-2xl font-semibold leading-tight">
{{ title }}
</div>
<Tag :severity="modeTag.severity" :value="modeTag.text" />
<div class="flex align-items-center gap-2 flex-wrap">
<Button size="small" icon="pi pi-plus" label="Sessão" @click="emit('createSession')" />
<Button size="small" icon="pi pi-lock" severity="secondary" label="Bloqueio" @click="emit('createBlock')" />
</div>
</div>
<Divider class="my-0" />
<!-- Controls row -->
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr">
<div class="grid gap-3 md:gap-4 items-center" style="grid-template-columns: 1fr">
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
<!-- Nav -->
<div class="flex align-items-center gap-2 flex-wrap">
<Button size="small" text icon="pi pi-angle-left" @click="emit('prev')" />
<Button size="small" text icon="pi pi-angle-right" @click="emit('next')" />
<Button size="small" icon="pi pi-calendar" label="Hoje" @click="emit('today')" />
</div>
<!-- View + Mode -->
<div class="flex align-items-center gap-2 flex-wrap">
<SelectButton :modelValue="view" :options="viewOptions" optionLabel="label" optionValue="value" @update:modelValue="onChangeView" />
<SelectButton :modelValue="mode" :options="modeOptions" optionLabel="label" optionValue="value" @update:modelValue="onToggleMode" />
</div>
</div>
<!-- Search -->
<div class="flex align-items-center gap-2 flex-wrap">
<div class="flex-1 min-w-[260px]">
<FloatLabel>
<InputText id="agenda-search" v-model="searchLocal" class="w-full" />
<label for="agenda-search">Buscar por título / observação</label>
</FloatLabel>
</div>
<Button size="small" text icon="pi pi-times" :disabled="!searchLocal" @click="searchLocal = ''" />
</div>
</div>
</div>
</div>
<div class="text-sm mt-1" style="color: var(--text-color-secondary);">
Navegue, filtre e crie compromissos com velocidade sem perder controle clínico.
</div>
</div>
<div class="flex align-items-center gap-2 flex-wrap">
<Button
size="small"
icon="pi pi-plus"
label="Sessão"
@click="emit('createSession')"
/>
<Button
size="small"
icon="pi pi-lock"
severity="secondary"
label="Bloqueio"
@click="emit('createBlock')"
/>
</div>
</div>
<Divider class="my-0" />
<!-- Controls row -->
<div class="grid gap-3 md:gap-4" style="grid-template-columns: 1fr;">
<div class="grid gap-3 md:gap-4 items-center" style="grid-template-columns: 1fr;">
<div class="flex align-items-center justify-content-between gap-3 flex-wrap">
<!-- Nav -->
<div class="flex align-items-center gap-2 flex-wrap">
<Button size="small" text icon="pi pi-angle-left" @click="emit('prev')" />
<Button size="small" text icon="pi pi-angle-right" @click="emit('next')" />
<Button size="small" icon="pi pi-calendar" label="Hoje" @click="emit('today')" />
</div>
<!-- View + Mode -->
<div class="flex align-items-center gap-2 flex-wrap">
<SelectButton
:modelValue="view"
:options="viewOptions"
optionLabel="label"
optionValue="value"
@update:modelValue="onChangeView"
/>
<SelectButton
:modelValue="mode"
:options="modeOptions"
optionLabel="label"
optionValue="value"
@update:modelValue="onToggleMode"
/>
</div>
</div>
<!-- Search -->
<div class="flex align-items-center gap-2 flex-wrap">
<div class="flex-1 min-w-[260px]">
<FloatLabel>
<InputText id="agenda-search" v-model="searchLocal" class="w-full" />
<label for="agenda-search">Buscar por título / observação</label>
</FloatLabel>
</div>
<Button
size="small"
text
icon="pi pi-times"
:disabled="!searchLocal"
@click="searchLocal = ''"
/>
</div>
</div>
</div>
</div>
</template>
</Card>
</template>
</template>
</Card>
</template>
@@ -15,139 +15,123 @@
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
import { computed } from 'vue';
const props = defineProps({
stats: { type: Object, default: () => ({}) }
})
stats: { type: Object, default: () => ({}) }
});
const emit = defineEmits(['quickBlock', 'quickCreate'])
const emit = defineEmits(['quickBlock', 'quickCreate']);
const dados_de_exemplo = {
totalSessions: 6,
totalMinutes: 300,
biggestFreeWindow: '2h 10m',
pending: 0,
reschedules: 1,
attentions: 1,
suggested1: '14:00',
suggested2: '16:30',
nextBreak: '12:00'
}
totalSessions: 6,
totalMinutes: 300,
biggestFreeWindow: '2h 10m',
pending: 0,
reschedules: 1,
attentions: 1,
suggested1: '14:00',
suggested2: '16:30',
nextBreak: '12:00'
};
const s = computed(() => {
const base = props.stats && Object.keys(props.stats).length ? props.stats : dados_de_exemplo
return {
totalSessions: base.totalSessions ?? 0,
totalMinutes: base.totalMinutes ?? 0,
biggestFreeWindow: base.biggestFreeWindow ?? '—',
pending: base.pending ?? 0,
reschedules: base.reschedules ?? 0,
attentions: base.attentions ?? 0,
suggested1: base.suggested1 ?? '—',
suggested2: base.suggested2 ?? '—',
nextBreak: base.nextBreak ?? '—'
}
})
const base = props.stats && Object.keys(props.stats).length ? props.stats : dados_de_exemplo;
return {
totalSessions: base.totalSessions ?? 0,
totalMinutes: base.totalMinutes ?? 0,
biggestFreeWindow: base.biggestFreeWindow ?? '—',
pending: base.pending ?? 0,
reschedules: base.reschedules ?? 0,
attentions: base.attentions ?? 0,
suggested1: base.suggested1 ?? '—',
suggested2: base.suggested2 ?? '—',
nextBreak: base.nextBreak ?? '—'
};
});
function minutesToHuman(min) {
const n = Number(min || 0)
const h = Math.floor(n / 60)
const m = n % 60
if (h <= 0) return `${m}m`
if (m <= 0) return `${h}h`
return `${h}h ${m}m`
const n = Number(min || 0);
const h = Math.floor(n / 60);
const m = n % 60;
if (h <= 0) return `${m}m`;
if (m <= 0) return `${h}h`;
return `${h}h ${m}m`;
}
const totalTimeHuman = computed(() => minutesToHuman(s.value.totalMinutes))
const totalTimeHuman = computed(() => minutesToHuman(s.value.totalMinutes));
const attentionSeverity = computed(() => {
if (s.value.attentions >= 3) return 'danger'
if (s.value.attentions >= 1) return 'warning'
return 'success'
})
if (s.value.attentions >= 3) return 'danger';
if (s.value.attentions >= 1) return 'warning';
return 'success';
});
</script>
<template>
<Card>
<template #title>
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="flex flex-column">
<span>Pulso da agenda</span>
<span class="text-xs" style="color: var(--text-color-secondary);">
Indicadores rápidos para decisão imediata.
</span>
</div>
<Card>
<template #title>
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="flex flex-column">
<span>Pulso da agenda</span>
<span class="text-xs" style="color: var(--text-color-secondary)"> Indicadores rápidos para decisão imediata. </span>
</div>
<div class="flex align-items-center gap-2">
<Tag :severity="attentionSeverity" :value="`Atenções: ${s.attentions}`" />
</div>
</div>
</template>
<template #content>
<div class="grid gap-2">
<!-- Linha 1: métricas -->
<div class="grid gap-2" style="grid-template-columns: 1fr 1fr;">
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
<div class="text-xs" style="color: var(--text-color-secondary);">Sessões (no filtro)</div>
<div class="text-xl font-semibold mt-1">{{ s.totalSessions }}</div>
</div>
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
<div class="text-xs" style="color: var(--text-color-secondary);">Tempo total</div>
<div class="text-xl font-semibold mt-1">{{ totalTimeHuman }}</div>
</div>
</div>
<!-- Linha 2: janelas e atenção -->
<div class="grid gap-2" style="grid-template-columns: 1fr 1fr;">
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
<div class="text-xs" style="color: var(--text-color-secondary);">Maior janela livre</div>
<div class="text-base font-semibold mt-1">{{ s.biggestFreeWindow }}</div>
<div class="text-xs mt-2" style="color: var(--text-color-secondary);">
Sugestões: <b>{{ s.suggested1 }}</b> e <b>{{ s.suggested2 }}</b>
<div class="flex align-items-center gap-2">
<Tag :severity="attentionSeverity" :value="`Atenções: ${s.attentions}`" />
</div>
</div>
</div>
</template>
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card);">
<div class="text-xs" style="color: var(--text-color-secondary);">Pontos de atenção</div>
<div class="flex align-items-center gap-2 mt-2 flex-wrap">
<Tag severity="warning" :value="`Pendências: ${s.pending}`" />
<Tag severity="info" :value="`Remarcar: ${s.reschedules}`" />
<template #content>
<div class="grid gap-2">
<!-- Linha 1: métricas -->
<div class="grid gap-2" style="grid-template-columns: 1fr 1fr">
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card)">
<div class="text-xs" style="color: var(--text-color-secondary)">Sessões (no filtro)</div>
<div class="text-xl font-semibold mt-1">{{ s.totalSessions }}</div>
</div>
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card)">
<div class="text-xs" style="color: var(--text-color-secondary)">Tempo total</div>
<div class="text-xl font-semibold mt-1">{{ totalTimeHuman }}</div>
</div>
</div>
<!-- Linha 2: janelas e atenção -->
<div class="grid gap-2" style="grid-template-columns: 1fr 1fr">
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card)">
<div class="text-xs" style="color: var(--text-color-secondary)">Maior janela livre</div>
<div class="text-base font-semibold mt-1">{{ s.biggestFreeWindow }}</div>
<div class="text-xs mt-2" style="color: var(--text-color-secondary)">
Sugestões: <b>{{ s.suggested1 }}</b> e <b>{{ s.suggested2 }}</b>
</div>
</div>
<div class="p-3 border-round-xl" style="border: 1px solid var(--surface-border); background: var(--surface-card)">
<div class="text-xs" style="color: var(--text-color-secondary)">Pontos de atenção</div>
<div class="flex align-items-center gap-2 mt-2 flex-wrap">
<Tag severity="warning" :value="`Pendências: ${s.pending}`" />
<Tag severity="info" :value="`Remarcar: ${s.reschedules}`" />
</div>
<div class="text-xs mt-2" style="color: var(--text-color-secondary)">
Próxima pausa: <b>{{ s.nextBreak }}</b>
</div>
</div>
</div>
<Divider class="my-2" />
<!-- Ações rápidas -->
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="text-xs" style="color: var(--text-color-secondary)">Ações rápidas (criação sempre abre modal nada nasce "direto").</div>
<div class="flex align-items-center gap-2">
<Button size="small" icon="pi pi-plus" label="Nova sessão" @click="emit('quickCreate')" />
<Button size="small" icon="pi pi-lock" severity="secondary" label="Bloquear horário" @click="emit('quickBlock')" />
</div>
</div>
</div>
<div class="text-xs mt-2" style="color: var(--text-color-secondary);">
Próxima pausa: <b>{{ s.nextBreak }}</b>
</div>
</div>
</div>
<Divider class="my-2" />
<!-- Ações rápidas -->
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="text-xs" style="color: var(--text-color-secondary);">
Ações rápidas (criação sempre abre modal nada nasce "direto").
</div>
<div class="flex align-items-center gap-2">
<Button
size="small"
icon="pi pi-plus"
label="Nova sessão"
@click="emit('quickCreate')"
/>
<Button
size="small"
icon="pi pi-lock"
severity="secondary"
label="Bloquear horário"
@click="emit('quickBlock')"
/>
</div>
</div>
</div>
</template>
</Card>
</template>
</template>
</Card>
</template>
File diff suppressed because it is too large Load Diff