ZERADO
This commit is contained in:
@@ -8,10 +8,25 @@
|
||||
@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 class="flex flex-col gap-2">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<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>
|
||||
|
||||
<!-- TOPBAR ACTION -->
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Gerar usuário"
|
||||
icon="pi pi-user-plus"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:disabled="saving"
|
||||
@click="generateUser"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,55 +36,67 @@
|
||||
{{ 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 class="flex flex-col gap-2 mt-2">
|
||||
<!-- Nome -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-user" />
|
||||
<InputText
|
||||
id="cr-nome"
|
||||
v-model.trim="form.nome_completo"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-nome">Nome completo *</label>
|
||||
</FloatLabel>
|
||||
</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>
|
||||
<!-- E-mail -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-envelope" />
|
||||
<InputText
|
||||
id="cr-email"
|
||||
v-model.trim="form.email_principal"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
inputmode="email"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-email">E-mail *</label>
|
||||
</FloatLabel>
|
||||
</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>
|
||||
<!-- Telefone -->
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class="pi pi-phone" />
|
||||
<InputMask
|
||||
id="cr-telefone"
|
||||
v-model="form.telefone"
|
||||
mask="(99) 99999-9999"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</IconField>
|
||||
<label for="cr-telefone">Telefone *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-surface-500">
|
||||
Dica: “Gerar usuário” preenche automaticamente com dados fictícios.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,12 +122,49 @@
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
import InputMask from 'primevue/inputmask'
|
||||
import Message from 'primevue/message'
|
||||
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
/**
|
||||
* Lista “curada” de pensadores influentes na psicanálise e seu entorno.
|
||||
* Usada para geração rápida de dados fictícios.
|
||||
*/
|
||||
const PSICANALISE_PENSADORES = Object.freeze([
|
||||
{ nome: 'Sigmund Freud' },
|
||||
{ nome: 'Jacques Lacan' },
|
||||
{ nome: 'Melanie Klein' },
|
||||
{ nome: 'Donald Winnicott' },
|
||||
{ nome: 'Wilfred Bion' },
|
||||
{ nome: 'Sándor Ferenczi' },
|
||||
{ nome: 'Anna Freud' },
|
||||
{ nome: 'Karl Abraham' },
|
||||
{ nome: 'Otto Rank' },
|
||||
{ nome: 'Karen Horney' },
|
||||
{ nome: 'Erich Fromm' },
|
||||
{ nome: 'Michael Balint' },
|
||||
{ nome: 'Ronald Fairbairn' },
|
||||
{ nome: 'John Bowlby' },
|
||||
{ nome: 'André Green' },
|
||||
{ nome: 'Jean Laplanche' },
|
||||
{ nome: 'Christopher Bollas' },
|
||||
{ nome: 'Thomas Ogden' },
|
||||
{ nome: 'Jessica Benjamin' },
|
||||
{ nome: 'Joyce McDougall' },
|
||||
{ nome: 'Peter Fonagy' },
|
||||
{ nome: 'Carl Gustav Jung' },
|
||||
{ nome: 'Alfred Adler' }
|
||||
])
|
||||
|
||||
// domínio seguro para dados fictícios
|
||||
const AUTO_EMAIL_DOMAIN = 'example.com'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
@@ -114,7 +178,10 @@ const props = defineProps({
|
||||
emailField: { type: String, default: 'email_principal' },
|
||||
phoneField: { type: String, default: 'telefone' },
|
||||
|
||||
// ✅ NÃO coloque status aqui por padrão (evita violar patients_status_check)
|
||||
// multi-tenant (defaults do seu schema)
|
||||
tenantField: { type: String, default: 'tenant_id' },
|
||||
responsibleMemberField: { type: String, default: 'responsible_member_id' },
|
||||
|
||||
extraPayload: { type: Object, default: () => ({}) },
|
||||
|
||||
closeOnCreated: { type: Boolean, default: true },
|
||||
@@ -184,6 +251,78 @@ async function getOwnerId () {
|
||||
return user.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Pega tenant_id + member_id do usuário logado.
|
||||
*/
|
||||
async function resolveTenantContextOrFail () {
|
||||
const { data: authData, error: authError } = await supabase.auth.getUser()
|
||||
if (authError) throw authError
|
||||
const uid = authData?.user?.id
|
||||
if (!uid) throw new Error('Sessão inválida.')
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tenant_members')
|
||||
.select('id, tenant_id')
|
||||
.eq('user_id', uid)
|
||||
.eq('status', 'active')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.tenant_id || !data?.id) throw new Error('Responsible member not found')
|
||||
|
||||
return { tenantId: data.tenant_id, memberId: data.id }
|
||||
}
|
||||
|
||||
/* ----------------------------
|
||||
* Gerador (nome/email/telefone)
|
||||
* ---------------------------- */
|
||||
function randInt (min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
function pick (arr) {
|
||||
return arr[randInt(0, arr.length - 1)]
|
||||
}
|
||||
function slugify (s) {
|
||||
return String(s || '')
|
||||
.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '.')
|
||||
.replace(/(^\.)|(\.$)/g, '')
|
||||
}
|
||||
function randomPhoneBRMasked () {
|
||||
const ddd = randInt(11, 99)
|
||||
const a = randInt(10000, 99999)
|
||||
const b = randInt(1000, 9999)
|
||||
return `(${ddd}) ${a}-${b}`
|
||||
}
|
||||
|
||||
function generateUser () {
|
||||
if (saving.value) return
|
||||
|
||||
const p = pick(PSICANALISE_PENSADORES)
|
||||
const nome = p?.nome || 'Paciente'
|
||||
|
||||
const base = slugify(nome) || 'paciente'
|
||||
const suffix = randInt(10, 999)
|
||||
const email = `${base}.${suffix}@${AUTO_EMAIL_DOMAIN}`
|
||||
|
||||
form.nome_completo = nome
|
||||
form.email_principal = email
|
||||
form.telefone = randomPhoneBRMasked()
|
||||
|
||||
touched.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Gerar usuário',
|
||||
detail: 'Dados fictícios preenchidos.',
|
||||
life: 2200
|
||||
})
|
||||
}
|
||||
|
||||
async function submit () {
|
||||
touched.value = true
|
||||
errorMsg.value = ''
|
||||
@@ -201,16 +340,21 @@ async function submit () {
|
||||
saving.value = true
|
||||
try {
|
||||
const ownerId = await getOwnerId()
|
||||
const { tenantId, memberId } = await resolveTenantContextOrFail()
|
||||
|
||||
// extraPayload antes; tenant/responsible forçados depois (não podem ser sobrescritos sem querer)
|
||||
const payload = {
|
||||
...props.extraPayload,
|
||||
|
||||
[props.ownerField]: ownerId,
|
||||
[props.tenantField]: tenantId,
|
||||
[props.responsibleMemberField]: memberId,
|
||||
|
||||
[props.nameField]: nome,
|
||||
[props.emailField]: email.toLowerCase(),
|
||||
[props.phoneField]: normalizePhoneDigits(tel),
|
||||
...props.extraPayload
|
||||
[props.phoneField]: normalizePhoneDigits(tel)
|
||||
}
|
||||
|
||||
// remove undefined
|
||||
Object.keys(payload).forEach((k) => {
|
||||
if (payload[k] === undefined) delete payload[k]
|
||||
})
|
||||
@@ -248,4 +392,4 @@ async function submit () {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
@@ -3,7 +3,6 @@
|
||||
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'
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<!-- 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'
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<!-- src/components/agenda/PausasChipsEditor.vue -->
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Button from 'primevue/button'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import FloatLabel from 'primevue/floatlabel'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Tag from 'primevue/tag'
|
||||
import Divider from 'primevue/divider'
|
||||
import DatePicker from 'primevue/datepicker'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const toast = useToast()
|
||||
@@ -32,6 +26,15 @@ function minToHHMM(min) {
|
||||
function newId() {
|
||||
return Math.random().toString(16).slice(2) + Date.now().toString(16)
|
||||
}
|
||||
function hhmmToDate(hhmm) {
|
||||
if (!isValidHHMM(hhmm)) return null
|
||||
const [h, m] = String(hhmm).split(':').map(Number)
|
||||
const d = new Date(); d.setHours(h, m, 0, 0); return d
|
||||
}
|
||||
function dateToHHMM(date) {
|
||||
if (!date || !(date instanceof Date)) return null
|
||||
return String(date.getHours()).padStart(2, '0') + ':' + String(date.getMinutes()).padStart(2, '0')
|
||||
}
|
||||
|
||||
const internal = ref([])
|
||||
|
||||
@@ -151,14 +154,22 @@ const presets = [
|
||||
]
|
||||
|
||||
const dlg = ref(false)
|
||||
const form = ref({ label: 'Pausa', inicio: '12:00', fim: '13:00' })
|
||||
const form = ref({ label: 'Pausa', inicio: null, fim: null })
|
||||
|
||||
const formInicioHHMM = computed(() => dateToHHMM(form.value.inicio))
|
||||
const formFimHHMM = computed(() => dateToHHMM(form.value.fim))
|
||||
const formValid = computed(() =>
|
||||
isValidHHMM(formInicioHHMM.value) &&
|
||||
isValidHHMM(formFimHHMM.value) &&
|
||||
formFimHHMM.value > formInicioHHMM.value
|
||||
)
|
||||
|
||||
function openCustom() {
|
||||
form.value = { label: 'Pausa', inicio: '12:00', fim: '13:00' }
|
||||
form.value = { label: 'Pausa', inicio: hhmmToDate('12:00'), fim: hhmmToDate('13:00') }
|
||||
dlg.value = true
|
||||
}
|
||||
function saveCustom() {
|
||||
addPauseSmart(form.value)
|
||||
addPauseSmart({ label: form.value.label, inicio: formInicioHHMM.value, fim: formFimHHMM.value })
|
||||
dlg.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -200,46 +211,44 @@ function saveCustom() {
|
||||
</div>
|
||||
|
||||
<!-- custom dialog -->
|
||||
<Dialog v-model:visible="dlg" modal header="Adicionar pausa" :style="{ width: '520px' }">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="form.label" class="w-full" inputId="plabel" placeholder="Ex.: Almoço" />
|
||||
<label for="plabel">Nome</label>
|
||||
</FloatLabel>
|
||||
<Dialog v-model:visible="dlg" modal :draggable="false" header="Adicionar pausa" :style="{ width: '420px' }">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-color-secondary)] mb-1 block">Nome</label>
|
||||
<InputText v-model="form.label" class="w-full" placeholder="Ex.: Almoço" />
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="form.inicio" class="w-full" inputId="pinicio" placeholder="12:00" />
|
||||
<label for="pinicio">Início (HH:MM)</label>
|
||||
</FloatLabel>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Início</label>
|
||||
<DatePicker v-model="form.inicio" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col gap-1">
|
||||
<label class="text-xs text-[var(--text-color-secondary)]">Fim</label>
|
||||
<DatePicker v-model="form.fim" showIcon fluid iconDisplay="input" timeOnly hourFormat="24">
|
||||
<template #inputicon="slotProps">
|
||||
<i class="pi pi-clock" @click="slotProps.clickCallback" />
|
||||
</template>
|
||||
</DatePicker>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 md:col-span-6">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="form.fim" class="w-full" inputId="pfim" placeholder="13:00" />
|
||||
<label for="pfim">Fim (HH:MM)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<div v-if="isValidHHMM(form.inicio) && isValidHHMM(form.fim) && form.fim <= form.inicio" class="col-span-12 text-sm text-red-500">
|
||||
<div v-if="formInicioHHMM && formFimHHMM && formFimHHMM <= formInicioHHMM" class="text-sm text-red-500">
|
||||
O fim precisa ser maior que o início.
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 text-600 text-xs">
|
||||
Se houver conflito com outra pausa, o sistema adiciona automaticamente apenas o trecho que não sobrepõe.
|
||||
<div class="text-[var(--text-color-secondary)] text-xs">
|
||||
Se houver conflito com outra pausa, o sistema adiciona apenas o trecho que não sobrepõe.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" icon="pi pi-times" severity="secondary" outlined @click="dlg = false" />
|
||||
<Button
|
||||
label="Adicionar"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isValidHHMM(form.inicio) || !isValidHHMM(form.fim) || form.fim <= form.inicio"
|
||||
@click="saveCustom"
|
||||
/>
|
||||
<Button label="Adicionar" icon="pi pi-check" :disabled="!formValid" @click="saveCustom" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user