Layout 100%, Notificações, SetupWizard

This commit is contained in:
Leonardo
2026-03-17 21:08:14 -03:00
parent 84d65e49c0
commit 66f67cd40f
77 changed files with 35823 additions and 15023 deletions
@@ -8,6 +8,7 @@
:style="{ width: '1000px', maxWidth: '96vw' }"
:breakpoints="{ '960px': '96vw', '640px': '98vw' }"
class="agenda-event-composer"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="w-full flex items-center justify-between gap-3">
@@ -24,47 +25,7 @@
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<Button
v-if="step === 2 && !isEdit && allowBack"
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
size="small"
class="rounded-full"
@click="goBack"
/>
<Button
v-if="step === 2 && isEdit && hasSerie"
label="Encerrar série"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full text-xs h-8"
@click="onEncerrarSerie"
/>
<Button
v-if="step === 2 && isEdit && !hasSerie"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full h-9 w-9"
v-tooltip.bottom="'Remover'"
@click="onDelete"
/>
<Button
v-if="step === 2"
label="Salvar"
icon="pi pi-check"
size="small"
class="rounded-full"
:disabled="!canSave"
@click="onSave"
/>
</div>
<!-- actions moved to footer -->
</div>
</template>
@@ -924,7 +885,7 @@
:style="{ width: '560px', maxWidth: '96vw' }"
:breakpoints="{ '640px': '98vw' }"
>
<div class="flex flex-col gap-4 p-1">
<div class="flex flex-col gap-4">
<!-- Data -->
<div>
<label class="block text-sm font-medium mb-2">Data</label>
@@ -1060,6 +1021,53 @@
</template>
</Dialog>
<!-- Footer -->
<template v-if="step === 2" #footer>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<Button
v-if="!isEdit && allowBack"
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
size="small"
class="rounded-full"
@click="goBack"
/>
<Button
v-if="isEdit && hasSerie"
label="Encerrar série"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full text-xs h-8"
@click="onEncerrarSerie"
/>
<Button
v-if="isEdit && !hasSerie"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full h-9 w-9"
v-tooltip.bottom="'Remover'"
@click="onDelete"
/>
</div>
<Button
label="Salvar"
icon="pi pi-check"
size="small"
class="rounded-full"
:disabled="!canSave"
@click="onSave"
/>
</div>
</template>
<!-- -->
<!-- Cadastro Rápido de Paciente -->
<!-- -->
@@ -2662,7 +2670,7 @@ function statusSeverity (v) {
.commitment-card {
width: 100%; text-align: left;
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
transition: box-shadow .12s ease, transform .12s ease, border-color .12s;
overflow: hidden;
@@ -2704,7 +2712,7 @@ function statusSeverity (v) {
/* ── paciente hero ──────────────────────────────── */
.patient-hero {
border: 1.5px solid var(--surface-border);
border-radius: 1.25rem;
border-radius: 6px;
overflow: hidden;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
@@ -2736,7 +2744,7 @@ function statusSeverity (v) {
/* Card genérico para seções (data/horário, etc.) */
.field-card {
border-radius: 1rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
@@ -2848,7 +2856,7 @@ function statusSeverity (v) {
}
.side-card {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
border-radius: 6px;
padding: .9rem 1rem;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
}
@@ -2879,7 +2887,7 @@ function statusSeverity (v) {
/* ── serie banner ───────────────────────────────── */
.serie-banner {
border-radius: 1rem;
border-radius: 6px;
padding: .75rem .9rem;
background: color-mix(in srgb, var(--blue-500, #3b82f6) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
@@ -2922,7 +2930,7 @@ function statusSeverity (v) {
.recorrencia-preview {
display: flex; align-items: center; gap: .5rem;
padding: .5rem .75rem;
border-radius: .75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 8%, transparent);
border: 1px solid color-mix(in srgb, var(--p-primary-400) 25%, transparent);
}
@@ -2994,7 +3002,7 @@ function statusSeverity (v) {
align-items: center;
gap: 12px;
padding: 10px 16px;
border-radius: 0.75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--primary-500, #6366f1) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--primary-400, #818cf8) 25%, transparent);
}
@@ -3125,7 +3133,7 @@ function statusSeverity (v) {
.rec-startdate-row {
display: flex; align-items: center; justify-content: space-between; gap: .5rem;
padding: .45rem .65rem;
border-radius: .75rem;
border-radius: 6px;
background: color-mix(in srgb, var(--surface-ground), transparent 30%);
border: 1px solid var(--surface-border);
}
@@ -3177,7 +3185,7 @@ function statusSeverity (v) {
/* ── personalizar box ───────────────────────────── */
.personalizar-box {
border: 1px solid var(--surface-border);
border-radius: .85rem;
border-radius: 6px;
padding: .75rem;
background: color-mix(in srgb, var(--surface-ground), transparent 40%);
display: flex;
@@ -3190,7 +3198,7 @@ function statusSeverity (v) {
.patient-item {
width: 100%; display: flex; align-items: center; justify-content: space-between;
gap: 1rem; text-align: left; padding: .85rem .95rem;
border: 1px solid var(--surface-border); border-radius: 1.25rem;
border: 1px solid var(--surface-border); border-radius: 6px;
background: color-mix(in srgb, var(--surface-card), transparent 10%);
transition: box-shadow .12s ease, transform .12s ease;
}
@@ -3199,7 +3207,7 @@ function statusSeverity (v) {
/* ── serie panel (Recorrências Aplicadas) ─────────── */
.serie-panel {
border: 1px solid var(--surface-border);
border-radius: 1.1rem;
border-radius: 6px;
overflow: hidden;
}
.serie-panel__header {
@@ -3273,7 +3281,7 @@ function statusSeverity (v) {
flex-direction: column;
gap: .35rem;
border: 1px solid var(--surface-border);
border-radius: .5rem;
border-radius: 6px;
padding: .5rem;
}
.commitment-item-row {
@@ -6,14 +6,15 @@
:closable="!saving"
:dismissableMask="!saving"
class="dc-dialog w-[96vw] max-w-2xl"
:pt="{ content: { class: 'p-0' }, header: { class: 'pb-0' } }"
: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="dc-header-dot shrink-0"
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">
@@ -37,33 +38,17 @@
v-tooltip.top="'Excluir'"
@click="emitDelete"
/>
<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>
</div>
</template>
<!-- Banner de preview -->
<div
class="dc-banner"
class="h-[72px] flex items-center justify-center transition-colors duration-[250ms] rounded-[6px]"
:style="{ backgroundColor: previewBgColor }"
>
<span
class="dc-banner__pill"
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' }}
@@ -71,7 +56,7 @@
</div>
<!-- Corpo -->
<div class="flex flex-col gap-4 p-4">
<div class="flex flex-col gap-4 mt-4">
<!-- Nome + Ativo -->
<div class="flex items-center gap-3">
@@ -91,68 +76,15 @@
<label for="cr-nome">Nome *</label>
</FloatLabel>
</div>
<div class="flex items-center gap-2 shrink-0 pt-1">
<!-- Toggle Ativo -->
<div class="shrink-0 flex items-center gap-2">
<span class="text-sm font-medium">Ativo</span>
<InputSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
<ToggleSwitch v-model="form.active" :disabled="saving || isActiveLocked" />
</div>
</div>
<!-- Seção Cor -->
<div class="dc-section">
<div class="dc-section__label">Cor</div>
<!-- Paleta predefinida -->
<div class="dc-palette">
<button
v-for="p in presetColors"
:key="p.bg"
class="dc-swatch"
:class="{ 'dc-swatch--active': form.bg_color === p.bg }"
: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 dc-swatch__check" />
</button>
<!-- Custom ColorPicker -->
<div class="dc-swatch dc-swatch--custom" title="Cor personalizada">
<ColorPicker
v-model="form.bg_color"
format="hex"
:disabled="saving || isEditLocked"
/>
</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="dc-text-opt"
:class="{ 'dc-text-opt--active': form.text_color === '#ffffff' }"
:disabled="saving || isEditLocked"
@click="form.text_color = '#ffffff'"
>
<span class="dc-text-opt__dot" style="background:#ffffff; border: 1px solid #ccc;" />
Branco
</button>
<button
class="dc-text-opt"
:class="{ 'dc-text-opt--active': form.text_color === '#000000' }"
:disabled="saving || isEditLocked"
@click="form.text_color = '#000000'"
>
<span class="dc-text-opt__dot" style="background:#000000;" />
Preto
</button>
</div>
</div>
</div>
<!-- Descrição -->
<!-- Descrição -->
<FloatLabel variant="on">
<Textarea
id="cr-descricao"
@@ -166,10 +98,83 @@
<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="dc-section">
<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="dc-section__label mb-0">Campos adicionais</div>
<div class="text-[1rem] font-bold uppercase tracking-[0.06em] opacity-45">Campos adicionais</div>
<Button
label="Adicionar campo"
icon="pi pi-plus"
@@ -190,7 +195,7 @@
<div
v-for="(f, idx) in form.fields"
:key="f.key"
class="grid grid-cols-1 gap-2 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-0)] p-3 md:grid-cols-12"
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">
@@ -233,14 +238,32 @@
@click="removeField(idx)"
/>
</div>
<div class="md:col-span-12 text-xs opacity-40 font-mono">
key: {{ f.key }}
</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>
@@ -249,8 +272,9 @@ import { computed, reactive, watch } from 'vue'
import Textarea from 'primevue/textarea'
import Dropdown from 'primevue/dropdown'
import InputSwitch from 'primevue/inputswitch'
import ColorPicker from 'primevue/colorpicker'
import ToggleSwitch from 'primevue/toggleswitch'
const props = defineProps({
modelValue: { type: Boolean, default: false },
@@ -281,6 +305,16 @@ const presetColors = [
{ bg: '292524', text: '#ffffff', name: 'Escuro' },
]
// bg_colors dos presets (sem #) para comparação
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)
})
function applyPreset (p) {
if (props.saving) return
form.bg_color = p.bg
@@ -350,9 +384,9 @@ function hydrate () {
}
}
const isActiveLocked = computed(() => !!form.locked) // nativo+locked → sempre ativo, nunca pode desativar
const isEditLocked = computed(() => false) // edição sempre permitida
const isFieldsLocked = computed(() => false) // campos sempre editáveis
const isActiveLocked = computed(() => !!form.locked)
const isEditLocked = computed(() => false)
const isFieldsLocked = computed(() => false)
const canDelete = computed(() => !form.native)
const canSubmit = computed(() => {
@@ -408,13 +442,11 @@ function removeField (idx) {
}
function syncKey (field) {
// se o user renomear, a key acompanha (sem quebrar: simples por enquanto)
const next = makeKey(field.label)
field.key = next
}
function makeKey (label) {
const k = String(label || '')
.trim()
.toLowerCase()
@@ -424,100 +456,4 @@ function makeKey (label) {
.replace(/(^_|_$)/g, '') || `field_${Math.random().toString(16).slice(2, 8)}`
return k
}
</script>
<style scoped>
/* ── Header ─────────────────────────────── */
.dc-header-dot {
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid rgba(255,255,255,0.3);
box-shadow: 0 0 0 3px rgba(0,0,0,0.08);
transition: background-color 0.2s ease;
}
/* ── Banner de preview ───────────────────── */
.dc-banner {
height: 72px;
display: flex; align-items: center; justify-content: center;
transition: background-color 0.25s ease;
}
.dc-banner__pill {
font-size: 1rem; font-weight: 700; letter-spacing: -0.02em;
padding: 0.35rem 1.1rem;
background: rgba(0,0,0,0.15);
border-radius: 999px;
backdrop-filter: blur(4px);
transition: color 0.2s ease;
}
/* ── Section ─────────────────────────────── */
.dc-section {
border: 1px solid var(--surface-border);
border-radius: 1.25rem;
background: var(--surface-card);
padding: 1rem;
}
.dc-section__label {
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
opacity: 0.45; margin-bottom: 0.75rem;
}
/* ── Paleta ──────────────────────────────── */
.dc-palette {
display: flex; flex-wrap: wrap; gap: 0.45rem;
}
.dc-swatch {
width: 28px; height: 28px;
border-radius: 50%;
border: 2px solid transparent;
display: grid; place-items: center;
cursor: pointer;
transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
position: relative;
}
.dc-swatch:hover:not(:disabled) {
transform: scale(1.18);
box-shadow: 0 3px 10px rgba(0,0,0,0.2);
}
.dc-swatch--active {
border-color: var(--surface-0, #fff);
box-shadow: 0 0 0 2px var(--text-color);
}
.dc-swatch__check {
font-size: 0.6rem; color: #fff; font-weight: 900;
}
.dc-swatch--custom {
background: conic-gradient(red, yellow, lime, cyan, blue, magenta, red);
overflow: hidden;
}
.dc-swatch--custom :deep(.p-colorpicker-preview) {
width: 100%; height: 100%;
border: none; border-radius: 50%;
opacity: 0;
}
/* ── Texto toggle ────────────────────────── */
.dc-text-opt {
display: inline-flex; align-items: center; gap: 0.4rem;
padding: 0.25rem 0.75rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
font-size: 0.8rem; font-weight: 500;
cursor: pointer;
color: var(--text-color);
background: transparent;
transition: background 0.12s, border-color 0.12s;
}
.dc-text-opt:hover:not(:disabled) { background: var(--surface-hover); }
.dc-text-opt--active {
background: var(--surface-section, var(--surface-100));
border-color: var(--primary-color);
color: var(--primary-color);
font-weight: 700;
}
.dc-text-opt__dot {
width: 10px; height: 10px; border-radius: 50%; display: inline-block;
}
</style>
</script>
+154 -182
View File
@@ -3,141 +3,102 @@
<Toast />
<ConfirmDialog />
<!-- Sentinel para detecção de sticky -->
<!-- Sentinel -->
<div ref="headerSentinelRef" class="ag-sentinel" />
<!-- Hero Header sticky -->
<div ref="headerEl" class="ag-hero mx-3 md:mx-5 mb-3" :class="{ 'ag-hero--stuck': headerStuck }">
<!-- Blobs decorativos -->
<div class="ag-hero__blobs" aria-hidden="true">
<div class="ag-hero__blob ag-hero__blob--1" />
<div class="ag-hero__blob ag-hero__blob--2" />
<div class="ag-hero__blob ag-hero__blob--3" />
<!-- Topbar compacta sticky -->
<div ref="headerEl" class="ag-topbar mx-3 md:mx-4 mb-3" :class="{ 'ag-topbar--stuck': headerStuck }">
<div class="ag-topbar__blobs" aria-hidden="true">
<div class="ag-topbar__blob ag-topbar__blob--1" />
<div class="ag-topbar__blob ag-topbar__blob--2" />
</div>
<div class="ag-topbar__inner">
<!-- Linha 1: brand + controles -->
<div class="ag-hero__row1">
<!-- Brand -->
<div class="ag-hero__brand">
<div class="ag-hero__icon">
<i class="pi pi-calendar text-lg" />
</div>
<div class="min-w-0">
<div class="ag-hero__title">Agenda</div>
<div class="ag-hero__sub">{{ subtitleText }}</div>
<div class="ag-topbar__brand">
<div class="ag-topbar__icon"><i class="pi pi-calendar text-base" /></div>
<div class="min-w-0 hidden xl:block">
<div class="ag-topbar__title">Agenda · Clínica</div>
<div class="ag-topbar__sub">{{ subtitleText }}</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="ag-hero__desktop-controls">
<!-- Navegação (sempre visível) -->
<div class="flex items-center gap-1">
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full" @click="goToday" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
<Button :label="visibleTitle" icon="pi pi-calendar" severity="secondary" outlined size="small" class="rounded-full" @click="toggleMonthPicker" />
</div>
<!-- Navegação -->
<div class="ag-topbar__nav">
<Button label="Hoje" severity="secondary" outlined size="small" class="rounded-full hidden lg:flex" @click="goToday" />
<Button icon="pi pi-chevron-left" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goPrev" />
<span class="ag-topbar__date-pill" @click="toggleMonthPicker">
<i class="pi pi-calendar text-xs opacity-60" />
{{ subtitleText }}
</span>
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
</div>
<!-- Busca (oculta quando colado) -->
<div v-if="!headerStuck" class="w-[260px]">
<!-- Filtros (desktop) -->
<div class="ag-topbar__filters hidden xl:flex items-center gap-1.5">
<SelectButton v-model="calendarView" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
<SelectButton v-model="timeMode" :options="timeModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
<SelectButton v-model="mosaicMode" :options="mosaicModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" size="small" />
</div>
<!-- Ações -->
<div class="ag-topbar__actions">
<!-- Busca desktop -->
<div class="hidden xl:block w-44">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText id="agendaSearch" v-model="search" class="w-full" autocomplete="off" @keyup.enter="openSearchModal" />
</IconField>
<label for="agendaSearch">Buscar paciente...</label>
<label for="agendaSearch">Buscar...</label>
</FloatLabel>
</div>
<!-- Ações rápidas -->
<div class="flex items-center gap-1">
<!-- Badge: feriados próximos sem bloqueio -->
<div v-if="feriadosTodosProximos.length" class="relative">
<Button
icon="pi pi-bell"
:severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'"
outlined
class="h-9 w-9 rounded-full"
v-tooltip.bottom="feriadosSemBloqueio.length
? `${feriadosSemBloqueio.length} feriado(s) sem bloqueio nos próximos 30 dias`
: `${feriadosTodosProximos.length} feriado(s) gerenciados`"
@click="feriadosAlertaOpen = true"
/>
<span v-if="feriadosSemBloqueio.length" class="ag-badge">{{ feriadosSemBloqueio.length }}</span>
</div>
<Button v-if="!headerStuck" label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="goSettings" />
</div>
</div>
<!-- Menu mobile (<1200px) -->
<div class="ag-hero__mobile-controls">
<!-- Sino: feriados próximos -->
<!-- Sino feriados -->
<div v-if="feriadosTodosProximos.length" class="relative">
<Button
icon="pi pi-bell"
:severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'"
outlined
class="h-9 w-9 rounded-full"
@click="feriadosAlertaOpen = true"
/>
<Button icon="pi pi-bell" :severity="feriadosSemBloqueio.length ? 'danger' : 'secondary'" outlined class="h-9 w-9 rounded-full" @click="feriadosAlertaOpen = true" />
<span v-if="feriadosSemBloqueio.length" class="ag-badge">{{ feriadosSemBloqueio.length }}</span>
</div>
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => headerMenuRef.toggle(e)" />
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
</div>
</div>
<!-- Divisor -->
<Divider class="ag-hero__divider my-2" />
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Novo compromisso" @click="onCreateFromButton" />
<!-- Linha 2: filtros -->
<div class="ag-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<div class="flex items-center gap-2">
<span class="text-sm opacity-60">Exibir:</span>
<SelectButton v-model="onlySessions" :options="onlySessionsOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchModalOpen = true" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => headerMenuRef.toggle(e)" />
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
</div>
<SelectButton v-model="calendarView" :options="viewOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
<SelectButton v-model="timeMode" :options="timeModeOptions" optionLabel="label" optionValue="value" :allowEmpty="false" />
</div>
<div v-if="searchTrim" class="flex items-center gap-2">
<Tag :value="`Busca: ${searchTrim}`" severity="secondary" />
<Button label="Limpar" icon="pi pi-times" text severity="secondary" size="small" @click="clearSearch" />
<Button v-if="searchResults.length" :label="`Ver resultados (${searchResults.length})`" icon="pi pi-list" severity="secondary" outlined size="small" class="rounded-full" @click="openSearchModal" />
</div>
</div>
</div>
<!-- Aviso: eventos fora da jornada de trabalho -->
<div
v-if="hasEventsOutsideWorkHours"
class="mx-3 md:mx-5 mb-3 rounded-2xl p-3"
style="background: color-mix(in srgb, var(--yellow-400, #facc15) 10%, var(--surface-card)); border: 1px solid color-mix(in srgb, var(--yellow-400, #facc15) 35%, transparent);"
>
<div class="flex items-start gap-3">
<i class="pi pi-exclamation-triangle shrink-0 mt-0.5" style="color: var(--yellow-600, #ca8a04);" />
<div class="min-w-0 flex-1">
<div class="font-semibold text-sm">Existem compromissos fora da jornada de trabalho</div>
<div class="text-xs opacity-70 mt-0.5">A exibição foi ajustada para <b>24h</b> automaticamente. Caso queira visualizar com horário reduzido (e aceite não ver alguns compromissos), escolha abaixo:</div>
<div class="flex gap-2 mt-2 flex-wrap">
<Button label="Meu Horário" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = 'my'" />
<Button label="12h" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = '12'" />
<!-- Desktop: extras -->
<div class="hidden xl:flex items-center gap-1">
<Button label="Bloquear" icon="pi pi-lock" size="small" class="rounded-full" severity="danger" outlined @click="(e) => blockMenuRef.toggle(e)" />
<Menu ref="blockMenuRef" :model="blockMenuItems" :popup="true" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="refetch" />
<Button icon="pi pi-sync" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recorrências" @click="goRecorrencias" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="goSettings" />
</div>
</div>
</div>
</div>
<!-- Layout: 2 colunas -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 pb-5">
<!-- Coluna maior -->
<div class="w-full lg:flex-1 lg:order-2 min-w-0">
<div class="overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] shadow-sm">
<!-- Aviso: fora da jornada -->
<div v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
<div class="flex gap-1 shrink-0">
<Button label="Meu Horário" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = 'my'" />
<Button label="12h" size="small" severity="secondary" outlined class="rounded-full" @click="timeMode = '12'" />
</div>
</div>
</div>
<!-- Layout 2 colunas: calendário + sidebar -->
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
<!-- Col centro: calendário mosaic -->
<div class="w-full xl:flex-1 min-w-0">
<div class="ag-cal-wrap">
<div class="p-2">
<AgendaClinicMosaic
ref="calendarRef"
@@ -169,12 +130,9 @@
</div>
<!-- Sidebar -->
<div class="w-full lg:basis-[24%] lg:max-w-[24%] lg:order-1">
<!-- Resultados (DESKTOP) -->
<div
v-if="searchTrim"
class="hidden sm:block mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm"
>
<div class="hidden xl:flex flex-col gap-3 w-full xl:w-[280px] shrink-0">
<!-- Resultados -->
<div v-if="searchTrim" class="ag-card">
<div class="mb-2 flex items-center justify-between gap-2">
<div class="min-w-0">
<div class="font-semibold truncate">Resultados</div>
@@ -220,40 +178,38 @@
</div>
<!-- Mini calendário -->
<div class="mb-3 rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="mb-2 flex items-center justify-between">
<span class="font-semibold">Calendário</span>
<div class="flex items-center gap-1">
<Button label="Hoje" severity="secondary" text class="h-9 rounded-full" @click="miniGoToday" />
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-9 w-9 rounded-full" @click="miniPrevMonth" />
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-9 w-9 rounded-full" @click="miniNextMonth" />
<div class="ag-card">
<div class="ag-card__head mb-1">
<span class="ag-card__title"><i class="pi pi-calendar" />{{ visibleTitle }}</span>
<div class="flex items-center gap-0.5">
<Button icon="pi pi-home" severity="secondary" text class="h-7 w-7 rounded-full" v-tooltip.top="'Hoje'" @click="miniGoToday" />
<Button icon="pi pi-chevron-left" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniPrevMonth" />
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />
</div>
</div>
<Calendar
v-model="miniDate"
inline
showWeek
class="w-full"
class="ag-mini-cal"
@update:modelValue="onMiniPick"
:pt="{ day: ({ context }) => ({ class: miniDayClass(context.date) }) }"
>
<template #date="{ date }">
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
<template #date="{ date }">
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
</div>
<ProximosFeriadosCard
class="mb-3"
:ownerId="clinicOwnerId"
:tenantId="tenantId || ''"
:workRules="workRules"
@bloqueado="refetch"
/>
:ownerId="clinicOwnerId"
:tenantId="tenantId || ''"
:workRules="workRules"
@bloqueado="refetch"
/>
<div class="rounded-3xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-3 shadow-sm">
<div class="ag-card">
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
</div>
</div>
@@ -2452,76 +2408,92 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
:deep(.evt-private) { opacity: 0.9; filter: saturate(0.25); }
:deep(.evt-private.fc-event) { border-style: dashed; }
/* ── Hero Header ─────────────────────────────────── */
/* ── Topbar ─────────────────────────────────────────── */
.ag-sentinel { height: 1px; }
.ag-hero {
.ag-topbar {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.ag-hero--stuck {
margin-left: 0;
margin-right: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
padding: 8px 12px;
}
.ag-topbar--stuck { border-top-left-radius: 0; border-top-right-radius: 0; }
.ag-topbar__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ag-topbar__blob { position: absolute; border-radius: 50%; filter: blur(60px); }
.ag-topbar__blob--1 { width: 16rem; height: 16rem; top: -4rem; right: -2rem; background: rgba(99,102,241,0.10); }
.ag-topbar__blob--2 { width: 18rem; height: 18rem; top: 0; left: -4rem; background: rgba(52,211,153,0.07); }
/* Blobs */
.ag-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.ag-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.ag-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.12); }
.ag-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,0.09); }
.ag-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(217,70,239,0.08); }
/* Linha 1 */
.ag-hero__row1 {
.ag-topbar__inner {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;
}
.ag-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex-shrink: 0; min-width: 0;
}
.ag-hero__icon {
.ag-topbar__brand { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
.ag-topbar__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
width: 2.25rem; height: 2.25rem; border-radius: 6px; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.ag-hero__title {
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
color: var(--text-color); white-space: nowrap;
.ag-topbar__title { font-size: 1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.ag-topbar__sub { font-size: 0.75rem; color: var(--text-color-secondary); }
.ag-topbar__nav { display: flex; align-items: center; gap: 0.25rem; }
.ag-topbar__date-pill {
display: inline-flex; align-items: center; gap: 0.35rem;
padding: 0.25rem 0.75rem; border-radius: 999px;
border: 1px solid var(--surface-border); background: var(--surface-ground);
font-size: 0.8rem; font-weight: 600; color: var(--text-color);
cursor: pointer; white-space: nowrap; transition: border-color 0.15s;
}
.ag-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
.ag-topbar__date-pill:hover { border-color: var(--p-primary-400); }
.ag-topbar__filters { flex-shrink: 0; }
.ag-topbar__actions { display: flex; align-items: center; gap: 0.5rem; margin-left: auto; }
/* ── Badge ────────────────────────────────────────────── */
.ag-badge {
position: absolute; top: -4px; right: -4px;
min-width: 16px; height: 16px; border-radius: 999px; padding: 0 4px;
background: var(--red-500, #ef4444); color: #fff;
font-size: 0.65rem; font-weight: 700;
display: flex; align-items: center; justify-content: center; pointer-events: none;
}
.ag-hero__desktop-controls {
flex: 1; display: flex; align-items: center;
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
}
.ag-hero__mobile-controls { display: none; }
/* Linha 2 */
.ag-hero__row2 {
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
/* ── Calendar wrap ──────────────────────────────────── */
.ag-cal-wrap {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card); overflow: hidden;
}
/* Mobile < 1200px */
@media (max-width: 1199px) {
.ag-hero__desktop-controls { display: none; }
.ag-hero__mobile-controls { display: flex; margin-left: auto; }
.ag-hero__divider,
.ag-hero__row2 { display: none; }
/* ── Sidebar cards ──────────────────────────────────── */
.ag-card {
border: 1px solid var(--surface-border);
border-radius: 6px; background: var(--surface-card); padding: 0.75rem;
}
.ag-card__head { display: flex; align-items: center; justify-content: space-between; }
.ag-card__title {
display: flex; align-items: center; gap: 0.35rem;
font-size: 0.7rem; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: var(--text-color-secondary); opacity: 0.65;
}
/* ── Mini calendário ────────────────────────────────── */
:deep(.ag-mini-cal .p-datepicker) { width: 100%; border: none; padding: 0; background: transparent; box-shadow: none; }
:deep(.ag-mini-cal .p-datepicker-header) { padding: 0 0 0.5rem; border: none; background: transparent; }
:deep(.ag-mini-cal .p-datepicker-calendar) { width: 100%; font-size: 0.78rem; }
:deep(.ag-mini-cal .p-datepicker-calendar td) { padding: 1px; }
:deep(.ag-mini-cal .p-datepicker-calendar td > span) {
width: 100%; min-width: unset; border-radius: 6px;
position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 1;
}
.mini-day-num { display: block; text-align: center; line-height: 1; }
.mini-day-dot {
position: absolute; bottom: 2px; right: 2px;
width: 4px; height: 4px; border-radius: 50%;
background: var(--primary-color, #6366f1);
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -2,137 +2,149 @@
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="extlink-sentinel" />
<div ref="headerSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div ref="headerEl" class="extlink-hero mx-3 md:mx-5 mb-4" :class="{ 'extlink-hero--stuck': headerStuck }">
<div class="extlink-hero__blobs" aria-hidden="true">
<div class="extlink-hero__blob extlink-hero__blob--1" />
<div class="extlink-hero__blob extlink-hero__blob--2" />
<!--
HERO sticky
-->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
</div>
<!-- Row 1 -->
<div class="extlink-hero__row1">
<div class="extlink-hero__brand">
<div class="extlink-hero__icon"><i class="pi pi-link text-lg" /></div>
<div class="min-w-0">
<div class="extlink-hero__title">Link de Cadastro</div>
<div class="extlink-hero__sub">Compartilhe com o paciente para preencher o pré-cadastro com calma e segurança</div>
<div class="relative z-[1] flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 flex-shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-link text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Link de Cadastro</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Compartilhe com o paciente para preencher o pré-cadastro</div>
</div>
</div>
<!-- Desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<!-- Status + link rápido desktop -->
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-3">
<!-- Badge de status -->
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border transition-colors"
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0 transition-colors"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span
class="h-2 w-2 rounded-full"
:class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'"
/>
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
{{ inviteToken ? 'Link ativo' : 'Gerando…' }}
</span>
<Button
label="Gerar novo link"
icon="pi pi-refresh"
severity="secondary"
outlined
class="rounded-full"
:loading="rotating"
@click="rotateLink"
/>
<!-- Link inline -->
<div v-if="!inviteToken" class="flex items-center gap-2 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner" /> Gerando link
</div>
<InputGroup v-else class="max-w-xl">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-[0.75rem]" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
</InputGroup>
</div>
<!-- Mobile (<1200px) -->
<div class="flex xl:hidden items-center shrink-0">
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined class="rounded-full" :loading="rotating" @click="rotateLink" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
</div>
</div>
</section>
<!-- Divider -->
<Divider class="extlink-hero__divider my-2" />
<!--
CONTEÚDO
-->
<div class="flex flex-col lg:flex-row gap-3 px-3 md:px-4 pb-5">
<!-- Row 2: link rápido (oculto no mobile) -->
<div class="extlink-hero__row2">
<div v-if="!inviteToken" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xs" /> Gerando link
</div>
<InputGroup v-else class="max-w-2xl">
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar link" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir no navegador" @click="openLink" />
</InputGroup>
</div>
</div>
<!-- Conteúdo -->
<div class="flex flex-col lg:flex-row gap-4 px-3 md:px-5 mb-5">
<!-- Esquerda: ações do link -->
<div class="flex-1 min-w-0 flex flex-col gap-4">
<!-- ESQUERDA: link + mensagem -->
<div class="flex-1 min-w-0 flex flex-col gap-3">
<!-- Card principal: link -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)] flex items-center justify-between gap-3 flex-wrap">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Header da seção -->
<div class="flex items-center justify-between gap-3 flex-wrap px-4 pt-3.5 pb-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div>
<div class="font-semibold text-[var(--text-color)]">Seu link público</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
</div>
<span
class="inline-flex items-center gap-2 text-xs px-3 py-1.5 rounded-full border"
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0"
:class="inviteToken
? 'border-emerald-200 text-emerald-700 bg-emerald-50'
: 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
>
<span class="h-2 w-2 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
{{ inviteToken ? 'Ativo' : 'Gerando…' }}
</span>
</div>
<div class="p-5 space-y-4">
<div class="p-4 flex flex-col gap-4">
<!-- Skeleton -->
<div v-if="!inviteToken" class="space-y-3">
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
<div class="h-10 rounded-xl bg-[var(--surface-ground)] animate-pulse" />
<div v-if="!inviteToken" class="flex flex-col gap-3">
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
<div class="h-10 rounded-md bg-[var(--surface-ground,#f8fafc)] animate-pulse" />
</div>
<div v-else class="space-y-4">
<!-- Link com ações -->
<div v-else class="flex flex-col gap-4">
<!-- InputGroup do link -->
<InputGroup>
<InputGroupAddon><i class="pi pi-link" /></InputGroupAddon>
<InputText readonly :value="publicUrl" class="font-mono text-xs" />
<InputText readonly :value="publicUrl" class="font-mono text-[0.75rem]" />
<Button icon="pi pi-copy" severity="secondary" title="Copiar" @click="copyLink" />
<Button icon="pi pi-external-link" severity="secondary" title="Abrir" @click="openLink" />
</InputGroup>
<div class="text-xs text-[var(--text-color-secondary)]">
Token: <span class="font-mono select-all">{{ inviteToken }}</span>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">
Token: <span class="font-mono select-all opacity-60">{{ inviteToken }}</span>
</div>
<!-- CTAs rápidas -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<button class="extlink-cta-btn" @click="copyLink">
<div class="extlink-cta-btn__icon bg-[color-mix(in_srgb,var(--p-primary-500,#6366f1)_12%,transparent)] text-[var(--p-primary-500,#6366f1)]">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<!-- Copiar link -->
<button
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
@click="copyLink"
>
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-copy" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar link</div>
<div class="text-xs text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
<div class="min-w-0">
<div class="font-semibold text-[1rem] text-[var(--text-color)]">Copiar link</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Cole no WhatsApp ou e-mail</div>
</div>
</button>
<button class="extlink-cta-btn" @click="copyInviteMessage">
<div class="extlink-cta-btn__icon bg-emerald-500/10 text-emerald-600">
<!-- Copiar mensagem pronta -->
<button
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
@click="copyInviteMessage"
>
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-comment" />
</div>
<div class="text-left min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Copiar mensagem pronta</div>
<div class="text-xs text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
<div class="min-w-0">
<div class="font-semibold text-[1rem] text-[var(--text-color)]">Copiar mensagem pronta</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Texto formatado com o link incluso</div>
</div>
</button>
</div>
@@ -146,84 +158,66 @@
</div>
<!-- Mensagem pronta -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-1">
<i class="pi pi-comment text-sm text-[var(--text-color-secondary)]" />
<i class="pi pi-comment text-[1rem] text-[var(--text-color-secondary)]" />
Mensagem pronta para envio
</div>
<div class="text-sm text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
<div class="rounded-xl bg-[var(--surface-ground)] border border-[var(--surface-border)] p-4 text-sm text-[var(--text-color)] leading-relaxed">
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-3">Copie e cole ao enviar o link ao paciente:</div>
<div class="rounded-md bg-[var(--surface-ground,#f8fafc)] border border-[var(--surface-border,#e2e8f0)] p-4 text-[1rem] text-[var(--text-color)] leading-relaxed">
Olá! Segue o link para seu pré-cadastro. Preencha com calma campos opcionais podem ficar em branco:
<span class="block mt-2 font-mono text-xs break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
<span class="block mt-2 font-mono text-[0.72rem] break-all text-[var(--text-color-secondary)]">{{ publicUrl || '…aguardando link…' }}</span>
</div>
<div class="mt-3">
<Button
icon="pi pi-copy"
label="Copiar mensagem"
severity="secondary"
outlined
class="rounded-full"
:disabled="!publicUrl"
@click="copyInviteMessage"
/>
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
</div>
</div>
</div>
<!-- Direita: instruções -->
<div class="lg:w-80 shrink-0 flex flex-col gap-4">
<!-- DIREITA: instruções -->
<div class="w-full lg:w-[272px] lg:flex-shrink-0 flex flex-col gap-3">
<!-- Como funciona -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<div class="p-5 border-b border-[var(--surface-border)]">
<div class="font-semibold text-[var(--text-color)]">Como funciona</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Simples e sem fricção para o paciente</div>
</div>
<div class="p-5">
<ol class="space-y-4">
<li class="flex gap-3">
<div class="extlink-step shrink-0">1</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Você envia o link</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Por WhatsApp, e-mail ou mensagem direta.</div>
</div>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">2</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">O paciente preenche</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">Campos opcionais podem ficar em branco. Menos fricção, mais adesão.</div>
</div>
</li>
<li class="flex gap-3">
<div class="extlink-step shrink-0">3</div>
<div class="min-w-0">
<div class="font-semibold text-sm text-[var(--text-color)]">Você recebe e converte</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-0.5">O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.</div>
</div>
</li>
</ol>
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-list-check text-[0.9rem]" />
</div>
<div class="min-w-0">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Como funciona</span>
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Simples e sem fricção para o paciente</span>
</div>
</div>
<ol class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
<li v-for="step in howItWorks" :key="step.n" class="flex items-start gap-3 px-3.5 py-3">
<div class="grid place-items-center w-7 h-7 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500 text-[0.75rem] font-bold mt-px">
{{ step.n }}
</div>
<div class="min-w-0">
<div class="font-semibold text-[1rem] text-[var(--text-color)]">{{ step.title }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5 leading-relaxed">{{ step.desc }}</div>
</div>
</li>
</ol>
</div>
<!-- Boas práticas -->
<div class="rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="font-semibold text-[var(--text-color)] flex items-center gap-2 mb-3">
<i class="pi pi-shield text-sm text-[var(--text-color-secondary)]" />
Boas práticas
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
<i class="pi pi-shield text-[0.9rem]" />
</div>
<div class="min-w-0">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Boas práticas</span>
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Segurança e privacidade</span>
</div>
</div>
<ul class="space-y-2.5">
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Gere um novo link se suspeitar que ele foi repassado indevidamente.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Informe o paciente que campos opcionais podem ficar em branco.</span>
</li>
<li class="flex gap-2 text-sm text-[var(--text-color-secondary)]">
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0" />
<span>Evite divulgar em público; é um link para compartilhamento individual.</span>
<ul class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
<li v-for="tip in goodPractices" :key="tip" class="flex items-start gap-2.5 px-3.5 py-2.5">
<i class="pi pi-check text-emerald-500 mt-0.5 flex-shrink-0 text-[1rem]" />
<span class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">{{ tip }}</span>
</li>
</ul>
</div>
@@ -241,26 +235,39 @@ import { supabase } from '@/lib/supabase/client'
const toast = useToast()
const inviteToken = ref('')
const rotating = ref(false)
const rotating = ref(false)
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
const headerStuck = ref(false)
let _observer = null
// ── Mobile menu ───────────────────────────────────────────
// ── Mobile menu ───────────────────────────────────────────
const mobileMenuRef = ref(null)
const mobileMenuItems = computed(() => [
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
{ label: 'Copiar link', icon: 'pi pi-copy', command: () => copyLink(), disabled: !inviteToken.value },
{ label: 'Copiar mensagem', icon: 'pi pi-comment', command: () => copyInviteMessage(), disabled: !inviteToken.value },
{ label: 'Abrir no navegador', icon: 'pi pi-external-link', command: () => openLink(), disabled: !inviteToken.value },
{ separator: true },
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
{ label: 'Gerar novo link', icon: 'pi pi-refresh', command: () => rotateLink() }
])
// ── URL base ────────────────────────────────────────────────
// ── Conteúdo estático ─────────────────────────────────────
const howItWorks = [
{ n: 1, title: 'Você envia o link', desc: 'Por WhatsApp, e-mail ou mensagem direta.' },
{ n: 2, title: 'O paciente preenche', desc: 'Campos opcionais podem ficar em branco. Menos fricção, mais adesão.' },
{ n: 3, title: 'Você recebe e converte', desc: 'O cadastro aparece em "Cadastros recebidos". Revise e converta em paciente quando quiser.' },
]
const goodPractices = [
'Gere um novo link se suspeitar que ele foi repassado indevidamente.',
'Informe o paciente que campos opcionais podem ficar em branco.',
'Evite divulgar em público; é um link para compartilhamento individual.',
]
// ── URL base ──────────────────────────────────────────────
const PUBLIC_BASE_URL = ''
const origin = computed(() => {
@@ -273,13 +280,13 @@ const publicUrl = computed(() => {
return `${origin.value}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
// ── Token helpers ───────────────────────────────────────────
function newToken() {
// ── Token helpers ─────────────────────────────────────────
function newToken () {
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID()
return 'tok_' + Math.random().toString(36).slice(2) + Date.now().toString(36)
}
async function requireUserId() {
async function requireUserId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
@@ -287,7 +294,7 @@ async function requireUserId() {
return uid
}
async function loadOrCreateInvite() {
async function loadOrCreateInvite () {
const uid = await requireUserId()
const { data, error } = await supabase
@@ -301,10 +308,7 @@ async function loadOrCreateInvite() {
if (error) throw error
const token = data?.[0]?.token
if (token) {
inviteToken.value = token
return
}
if (token) { inviteToken.value = token; return }
const t = newToken()
const { error: insErr } = await supabase
@@ -315,19 +319,18 @@ async function loadOrCreateInvite() {
inviteToken.value = t
}
async function rotateLink() {
async function rotateLink () {
rotating.value = true
try {
const uid = await requireUserId()
const t = newToken()
const t = newToken()
const rpc = await supabase.rpc('rotate_patient_invite_token', { p_new_token: t })
if (rpc.error) {
const { error: e1 } = await supabase
.from('patient_invites')
.update({ active: false, updated_at: new Date().toISOString() })
.eq('owner_id', uid)
.eq('active', true)
.eq('owner_id', uid).eq('active', true)
if (e1) throw e1
const { error: e2 } = await supabase
@@ -345,7 +348,7 @@ async function rotateLink() {
}
}
async function copyLink() {
async function copyLink () {
try {
if (!publicUrl.value) return
await navigator.clipboard.writeText(publicUrl.value)
@@ -355,12 +358,12 @@ async function copyLink() {
}
}
function openLink() {
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
async function copyInviteMessage() {
async function copyInviteMessage () {
try {
if (!publicUrl.value) return
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
@@ -387,96 +390,4 @@ onMounted(async () => {
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<style scoped>
/* ── Sentinel ─────────────────────────────────────── */
.extlink-sentinel { height: 1px; }
/* ── Hero ─────────────────────────────────────────── */
.extlink-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.extlink-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
/* Blobs decorativos */
.extlink-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.extlink-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.extlink-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.10); }
.extlink-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(16,185,129,0.08); }
/* Linha 1 */
.extlink-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.extlink-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.extlink-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.extlink-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.extlink-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
/* Linha 2 */
.extlink-hero__row2 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 0.75rem;
}
@media (max-width: 767px) {
.extlink-hero__divider,
.extlink-hero__row2 { display: none; }
}
/* ── CTA button ───────────────────────────────────── */
.extlink-cta-btn {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1rem;
border-radius: 1rem;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
cursor: pointer;
transition: background 0.15s ease, box-shadow 0.15s ease, transform 0.1s ease;
text-align: left;
}
.extlink-cta-btn:hover {
background: var(--surface-hover);
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transform: translateY(-1px);
}
.extlink-cta-btn:active { transform: translateY(0); }
.extlink-cta-btn__icon {
display: grid; place-items: center;
width: 2.25rem; height: 2.25rem;
border-radius: 0.75rem; flex-shrink: 0;
font-size: 1rem;
}
/* ── Step numbers ─────────────────────────────────── */
.extlink-step {
display: grid; place-items: center;
width: 2rem; height: 2rem;
border-radius: 0.625rem;
font-size: 0.8rem; font-weight: 700;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
</style>
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff