first commit
This commit is contained in:
220
src/components/agenda/AgendaOnlineGradeCard.vue
Normal file
220
src/components/agenda/AgendaOnlineGradeCard.vue
Normal file
@@ -0,0 +1,220 @@
|
||||
<!-- src/components/agenda/AgendaOnlineGradeCard.vue -->
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import TabView from 'primevue/tabview'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Tag from 'primevue/tag'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { fetchSlotsRegras } from '@/services/agendaConfigService'
|
||||
import { fetchSlotsBloqueados, setSlotBloqueado } from '@/services/agendaSlotsBloqueadosService'
|
||||
import { gerarSlotsDoDia } from '@/utils/slotsGenerator'
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
ownerId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const diasSemana = [
|
||||
{ label: 'Seg', value: 1 },
|
||||
{ label: 'Ter', value: 2 },
|
||||
{ label: 'Qua', value: 3 },
|
||||
{ label: 'Qui', value: 4 },
|
||||
{ label: 'Sex', value: 5 },
|
||||
{ label: 'Sáb', value: 6 },
|
||||
{ label: 'Dom', value: 0 }
|
||||
]
|
||||
|
||||
const loading = ref(false)
|
||||
const savingSlot = ref(false)
|
||||
|
||||
const slotsRegras = ref([]) // agenda_slots_regras
|
||||
const regrasSemanais = ref([]) // agenda_regras_semanais
|
||||
const bloqueadosByDia = ref({}) // {dia: Set('09:00'...)}
|
||||
|
||||
async function loadRegrasSemanais() {
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_regras_semanais')
|
||||
.select('*')
|
||||
.eq('owner_id', props.ownerId)
|
||||
.order('dia_semana', { ascending: true })
|
||||
.order('hora_inicio', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
regrasSemanais.value = data || []
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
await Promise.all([
|
||||
loadRegrasSemanais(),
|
||||
(async () => { slotsRegras.value = await fetchSlotsRegras(props.ownerId) })()
|
||||
])
|
||||
|
||||
// carregue bloqueados de todos os dias
|
||||
const map = {}
|
||||
for (const d of diasSemana.map(x => x.value)) {
|
||||
const rows = await fetchSlotsBloqueados(props.ownerId, d)
|
||||
map[d] = new Set(rows.map(r => String(r.hora_inicio).slice(0, 5)))
|
||||
}
|
||||
bloqueadosByDia.value = map
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar a grade.', life: 3200 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function regraDoDia(dia) {
|
||||
return slotsRegras.value.find(r => r.dia_semana === dia) || null
|
||||
}
|
||||
function janelasDoDia(dia) {
|
||||
return (regrasSemanais.value || []).filter(r => r.dia_semana === dia && r.ativo !== false)
|
||||
}
|
||||
|
||||
function slotsDoDia(dia) {
|
||||
const regra = regraDoDia(dia)
|
||||
if (!regra || regra.ativo === false) return []
|
||||
return gerarSlotsDoDia(janelasDoDia(dia), regra)
|
||||
}
|
||||
|
||||
function isBloqueado(dia, hhmm) {
|
||||
return !!bloqueadosByDia.value?.[dia]?.has(hhmm)
|
||||
}
|
||||
|
||||
async function toggleSlot(dia, hhmm) {
|
||||
savingSlot.value = true
|
||||
try {
|
||||
const blocked = isBloqueado(dia, hhmm)
|
||||
await setSlotBloqueado(props.ownerId, dia, hhmm, !blocked)
|
||||
|
||||
if (!bloqueadosByDia.value[dia]) bloqueadosByDia.value[dia] = new Set()
|
||||
if (blocked) bloqueadosByDia.value[dia].delete(hhmm)
|
||||
else bloqueadosByDia.value[dia].add(hhmm)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível atualizar o horário.', life: 3200 })
|
||||
} finally {
|
||||
savingSlot.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resumo = computed(() => {
|
||||
// só um resumo simples — depois refinamos
|
||||
const diasAtivos = diasSemana.filter(d => (regraDoDia(d.value)?.ativo !== false)).length
|
||||
return { diasAtivos }
|
||||
})
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-globe" />
|
||||
<span>Grade do online (estilo Altegio)</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loading" class="flex items-center gap-3 text-600">
|
||||
<ProgressSpinner style="width:22px;height:22px" />
|
||||
Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Resumo tipo “cards” -->
|
||||
<div class="grid grid-cols-12 gap-3 mb-4">
|
||||
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
|
||||
<div class="text-600 text-sm">Tipo de slots</div>
|
||||
<div class="text-900 font-semibold mt-1">Fixo</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
|
||||
<div class="text-600 text-sm">Dias ativos</div>
|
||||
<div class="text-900 font-semibold mt-1">{{ resumo.diasAtivos }}</div>
|
||||
</div>
|
||||
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
|
||||
<div class="text-600 text-sm">Dica</div>
|
||||
<div class="text-900 font-semibold mt-1">Clique em um horário para ocultar/exibir</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabView>
|
||||
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<!-- Jornada -->
|
||||
<div class="col-span-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-900 font-medium">Jornada do dia</div>
|
||||
<div class="text-600 text-sm">
|
||||
(Isso vem das suas “janelas semanais”)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<template v-if="janelasDoDia(d.value).length">
|
||||
<Tag
|
||||
v-for="j in janelasDoDia(d.value)"
|
||||
:key="j.id"
|
||||
:value="`${String(j.hora_inicio).slice(0,5)}–${String(j.hora_fim).slice(0,5)}`"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-600 text-sm">Sem jornada ativa neste dia.</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chips -->
|
||||
<div class="col-span-12">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-900 font-medium">Horários publicados</div>
|
||||
<div class="text-600 text-sm" v-if="savingSlot">Salvando…</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<template v-if="slotsDoDia(d.value).length">
|
||||
<button
|
||||
v-for="hh in slotsDoDia(d.value)"
|
||||
:key="hh"
|
||||
class="px-3 py-2 rounded-lg border text-sm transition"
|
||||
:class="isBloqueado(d.value, hh)
|
||||
? 'border-[var(--surface-border)] text-600 bg-[var(--surface-ground)] line-through opacity-70'
|
||||
: 'border-[var(--surface-border)] text-900 bg-[var(--surface-card)] hover:bg-[var(--surface-ground)]'"
|
||||
@click="toggleSlot(d.value, hh)"
|
||||
>
|
||||
{{ hh }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<span class="text-600 text-sm">
|
||||
Nada para publicar (verifique: jornada do dia + regra de slots ativa).
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="text-600 text-sm mt-3 leading-relaxed">
|
||||
Se algum horário não deve aparecer para o paciente, clique para <b>desativar</b>.
|
||||
Isso não altera sua agenda interna — só a disponibilidade do online.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
183
src/components/agenda/AgendaSlotsPorDiaCard.vue
Normal file
183
src/components/agenda/AgendaSlotsPorDiaCard.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<!-- src/components/agenda/AgendaSlotsPorDiaCard.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch, onMounted } from 'vue'
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
import TabView from 'primevue/tabview'
|
||||
import TabPanel from 'primevue/tabpanel'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import InputSwitch from 'primevue/inputswitch'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import { fetchSlotsRegras, upsertSlotRegra } from '@/services/agendaConfigService'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
ownerId: { type: String, required: true }
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const diasSemana = [
|
||||
{ label: 'Dom', value: 0 },
|
||||
{ label: 'Seg', value: 1 },
|
||||
{ label: 'Ter', value: 2 },
|
||||
{ label: 'Qua', value: 3 },
|
||||
{ label: 'Qui', value: 4 },
|
||||
{ label: 'Sex', value: 5 },
|
||||
{ label: 'Sáb', value: 6 }
|
||||
]
|
||||
|
||||
const passos = [15, 20, 30, 45, 60, 75, 90, 120].map(v => ({ label: `${v} min`, value: v }))
|
||||
const offsets = [0, 15, 30, 45].map(v => ({ label: v === 0 ? ':00' : `:${String(v).padStart(2, '0')}`, value: v }))
|
||||
|
||||
const model = ref({
|
||||
0: { dia_semana: 0, ativo: false, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
|
||||
1: { dia_semana: 1, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
|
||||
2: { dia_semana: 2, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
|
||||
3: { dia_semana: 3, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
|
||||
4: { dia_semana: 4, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
|
||||
5: { dia_semana: 5, ativo: true, passo_minutos: 60, offset_minutos: 0, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 },
|
||||
6: { dia_semana: 6, ativo: true, passo_minutos: 60, offset_minutos: 30, buffer_antes_min: 0, buffer_depois_min: 0, min_antecedencia_horas: 0 }
|
||||
})
|
||||
|
||||
function applyRows(rows) {
|
||||
for (const r of rows || []) {
|
||||
model.value[r.dia_semana] = {
|
||||
dia_semana: r.dia_semana,
|
||||
ativo: !!r.ativo,
|
||||
passo_minutos: r.passo_minutos,
|
||||
offset_minutos: r.offset_minutos,
|
||||
buffer_antes_min: r.buffer_antes_min,
|
||||
buffer_depois_min: r.buffer_depois_min,
|
||||
min_antecedencia_horas: r.min_antecedencia_horas
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const rows = await fetchSlotsRegras(props.ownerId)
|
||||
applyRows(rows)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível carregar slots por dia.', life: 3200 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function salvarDia(dia) {
|
||||
saving.value = true
|
||||
try {
|
||||
const p = model.value[dia]
|
||||
await upsertSlotRegra(props.ownerId, p)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: `Slots do ${diasSemana.find(x => x.value === dia)?.label} atualizados.`, life: 1600 })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar.', life: 3200 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function salvarTudo() {
|
||||
saving.value = true
|
||||
try {
|
||||
for (const d of diasSemana.map(x => x.value)) {
|
||||
await upsertSlotRegra(props.ownerId, model.value[d])
|
||||
}
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Slots por dia atualizados.', life: 1800 })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não foi possível salvar tudo.', life: 3200 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="overflow-hidden">
|
||||
<template #title>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-clock" />
|
||||
<span>Organização de slots (por dia)</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-refresh" text rounded :disabled="loading" @click="load" />
|
||||
<Button label="Salvar tudo" icon="pi pi-check" size="small" :loading="saving" @click="salvarTudo" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="text-600 text-sm mb-3 leading-relaxed">
|
||||
Aqui você define <b>de quanto em quanto</b> os horários aparecem e <b>em qual minuto</b> eles alinham (ex.: :00 ou :30).
|
||||
<span class="ml-1">Ex.: sábado com passo 60 e offset 30 gera 08:30, 09:30, 10:30…</span>
|
||||
</div>
|
||||
|
||||
<TabView>
|
||||
<TabPanel v-for="d in diasSemana" :key="d.value" :header="d.label">
|
||||
<div class="grid grid-cols-12 gap-4">
|
||||
<div class="col-span-12 flex items-center gap-3">
|
||||
<InputSwitch v-model="model[d.value].ativo" />
|
||||
<div>
|
||||
<div class="text-900 font-medium">Ativo</div>
|
||||
<div class="text-600 text-sm">Se desligado, o online não oferece horários nesse dia.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<FloatLabel>
|
||||
<Dropdown v-model="model[d.value].passo_minutos" :options="passos" optionLabel="label" optionValue="value" class="w-full" inputId="passo" />
|
||||
<label for="passo">Passo (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<FloatLabel>
|
||||
<Dropdown v-model="model[d.value].offset_minutos" :options="offsets" optionLabel="label" optionValue="value" class="w-full" inputId="offset" />
|
||||
<label for="offset">Alinhamento</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<FloatLabel>
|
||||
<InputNumber v-model="model[d.value].min_antecedencia_horas" class="w-full" :min="0" :max="720" inputId="ante" />
|
||||
<label for="ante">Antecedência (h)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<FloatLabel>
|
||||
<InputNumber v-model="model[d.value].buffer_antes_min" class="w-full" :min="0" :max="240" inputId="ba" />
|
||||
<label for="ba">Buffer antes (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4">
|
||||
<FloatLabel>
|
||||
<InputNumber v-model="model[d.value].buffer_depois_min" class="w-full" :min="0" :max="240" inputId="bd" />
|
||||
<label for="bd">Buffer depois (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-4 flex items-end">
|
||||
<Button class="w-full" label="Salvar este dia" icon="pi pi-check" :loading="saving" @click="salvarDia(d.value)" />
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabView>
|
||||
</template>
|
||||
</Card>
|
||||
</template>
|
||||
Reference in New Issue
Block a user