first commit

This commit is contained in:
Leonardo
2026-02-18 22:36:45 -03:00
parent ec6b6ef53a
commit 676042268b
122 changed files with 26354 additions and 1615 deletions

View File

@@ -0,0 +1,251 @@
<template>
<Dialog
v-model:visible="isOpen"
modal
:closable="!saving"
:dismissableMask="!saving"
:style="{ width: '34rem', maxWidth: '92vw' }"
@hide="onHide"
>
<template #header>
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500">
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios).
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<Message v-if="errorMsg" severity="error" :closable="false">
{{ errorMsg }}
</Message>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-nome">Nome *</label>
<InputText
id="cr-nome"
v-model.trim="form.nome_completo"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent="submit"
/>
<small v-if="touched && !form.nome_completo" class="text-red-500">
Informe o nome.
</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-email">E-mail *</label>
<InputText
id="cr-email"
v-model.trim="form.email_principal"
:disabled="saving"
inputmode="email"
autocomplete="off"
@keydown.enter.prevent="submit"
/>
<small v-if="touched && !form.email_principal" class="text-red-500">
Informe o e-mail.
</small>
<small v-if="touched && form.email_principal && !isValidEmail(form.email_principal)" class="text-red-500">
E-mail inválido.
</small>
</div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium" for="cr-telefone">Telefone *</label>
<InputMask
id="cr-telefone"
v-model="form.telefone"
:disabled="saving"
mask="(99) 99999-9999"
placeholder="(16) 99999-9999"
@keydown.enter.prevent="submit"
/>
<small v-if="touched && !form.telefone" class="text-red-500">
Informe o telefone.
</small>
<small v-else-if="touched && form.telefone && !isValidPhone(form.telefone)" class="text-red-500">
Telefone inválido.
</small>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
text
:disabled="saving"
@click="close"
/>
<Button
label="Salvar"
:loading="saving"
:disabled="saving"
@click="submit"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import InputMask from 'primevue/inputmask'
import Message from 'primevue/message'
import { supabase } from '@/lib/supabase/client'
const props = defineProps({
modelValue: { type: Boolean, default: false },
title: { type: String, default: 'Cadastro rápido' },
tableName: { type: String, default: 'patients' },
ownerField: { type: String, default: 'owner_id' },
// defaults alinhados com seu schema
nameField: { type: String, default: 'nome_completo' },
emailField: { type: String, default: 'email_principal' },
phoneField: { type: String, default: 'telefone' },
// ✅ NÃO coloque status aqui por padrão (evita violar patients_status_check)
extraPayload: { type: Object, default: () => ({}) },
closeOnCreated: { type: Boolean, default: true },
resetOnOpen: { type: Boolean, default: true }
})
const emit = defineEmits(['update:modelValue', 'created'])
const toast = useToast()
const saving = ref(false)
const touched = ref(false)
const errorMsg = ref('')
const form = reactive({
nome_completo: '',
email_principal: '',
telefone: ''
})
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
watch(
() => props.modelValue,
(v) => {
if (v && props.resetOnOpen) reset()
if (v) {
touched.value = false
errorMsg.value = ''
}
}
)
function reset () {
form.nome_completo = ''
form.email_principal = ''
form.telefone = ''
}
function close () {
isOpen.value = false
}
function onHide () {}
function isValidEmail (v) {
return /.+@.+\..+/.test(String(v || '').trim())
}
function isValidPhone (v) {
const digits = String(v || '').replace(/\D/g, '')
return digits.length === 10 || digits.length === 11
}
function normalizePhoneDigits (v) {
const digits = String(v || '').replace(/\D/g, '')
return digits || null
}
async function getOwnerId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const user = data?.user
if (!user?.id) throw new Error('Usuário não encontrado. Faça login novamente.')
return user.id
}
async function submit () {
touched.value = true
errorMsg.value = ''
const nome = String(form.nome_completo || '').trim()
const email = String(form.email_principal || '').trim()
const tel = String(form.telefone || '')
if (!nome) return
if (!email) return
if (!isValidEmail(email)) return
if (!tel) return
if (!isValidPhone(tel)) return
saving.value = true
try {
const ownerId = await getOwnerId()
const payload = {
[props.ownerField]: ownerId,
[props.nameField]: nome,
[props.emailField]: email.toLowerCase(),
[props.phoneField]: normalizePhoneDigits(tel),
...props.extraPayload
}
// remove undefined
Object.keys(payload).forEach((k) => {
if (payload[k] === undefined) delete payload[k]
})
const { data, error } = await supabase
.from(props.tableName)
.insert(payload)
.select()
.single()
if (error) throw error
toast.add({
severity: 'success',
summary: 'Paciente criado',
detail: nome,
life: 2500
})
emit('created', data)
if (props.closeOnCreated) close()
} catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'
errorMsg.value = msg
toast.add({
severity: 'error',
summary: 'Erro ao salvar',
detail: msg,
life: 4500
})
console.error('[ComponentCadastroRapido] insert error:', err)
} finally {
saving.value = false
}
}
</script>

View 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 a disponibilidade do online.
</div>
</div>
</div>
</TabPanel>
</TabView>
</div>
</template>
</Card>
</template>

View 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>

View File

@@ -1,5 +1,5 @@
<script setup>
import { ProductService } from '@/service/ProductService';
import { ProductService } from '@/services/ProductService';
import { onMounted, ref } from 'vue';
const products = ref(null);

View File

@@ -0,0 +1,31 @@
<script setup>
import { computed } from 'vue'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const props = defineProps({
feature: {
type: String,
required: true
},
fallback: {
type: Boolean,
default: false
}
})
const ent = useEntitlementsStore()
const allowed = computed(() => {
return ent.can(props.feature)
})
</script>
<template>
<template v-if="allowed">
<slot />
</template>
<template v-else-if="fallback">
<slot name="fallback" />
</template>
</template>