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:
@@ -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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"')
|
||||
const esc = (s) =>
|
||||
String(s ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
|
||||
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 / já bloqueado -->
|
||||
<template v-if="isDiaUtil(f.data)">
|
||||
<!-- Já 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 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 / já bloqueado -->
|
||||
<template v-if="isDiaUtil(f.data)">
|
||||
<!-- Já 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" />
|
||||
Já 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" />
|
||||
Já 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
Reference in New Issue
Block a user