Copyright, Financeiro, Lançamentos, aprimoramentos de ui

This commit is contained in:
Leonardo
2026-03-21 08:05:40 -03:00
parent 29ed349cf2
commit a89d1f5560
268 changed files with 58870 additions and 1752 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
# /public
Esta pasta é servida **estaticamente pelo Vite** na raiz do servidor (`/`).
Arquivos aqui **não passam pelo bundler** — são copiados diretamente para o build final e acessíveis via URL direta.
---
## ⚠️ Não confundir com
| Pasta | O que é |
|---|---|
| `public/` (esta pasta) | Arquivos estáticos brutos. Acessíveis via `fetch('/arquivo.json')` ou `<img src="/imagem.png">` |
| `src/views/pages/public/` | Componentes Vue de páginas públicas (sem autenticação). Fazem parte do bundle normalmente. |
---
## O que colocar aqui
- Arquivos JSON de configuração estática (ex: `loading-phrases.json`)
- Favicons e ícones (`favicon.ico`, `apple-touch-icon.png`)
- Imagens que não precisam ser processadas pelo Vite
- Arquivos de verificação de domínio (Google, Meta, etc.)
## O que **não** colocar aqui
- Componentes `.vue`
- Arquivos `.js` ou `.ts` que importam outros módulos
- Qualquer coisa que precise de `@/` ou de `import`
---
## Exemplo de uso
```js
// Acessando loading-phrases.json de qualquer componente
const res = await fetch('/loading-phrases.json')
const json = await res.json()
```
O arquivo em disco é `public/loading-phrases.json`, mas o fetch usa `/loading-phrases.json` — sem o prefixo `public/`.
+54
View File
@@ -0,0 +1,54 @@
{
"motivations": [
"Você é luz na vida de alguém 💙",
"Seu cuidado transforma histórias 🌱",
"Presença é o maior presente 🤝",
"Cada sessão é um passo de coragem 🌟",
"Você faz diferença todos os dias ✨",
"Cuidar de si é cuidar do outro 🌿",
"Escutar é um ato de amor 🫶",
"Sua dedicação não passa despercebida 🙏",
"Cada palavra sua pode mudar uma vida 💬",
"Você planta esperança todo dia 🌻",
"A saúde mental começa com você 🧠",
"Seu trabalho é semear cura 🕊️",
"Obrigado por cuidar de quem precisa 💚",
"Você é o porto seguro de alguém 🌊",
"Sua empatia é um superpoder 🦋",
"Cada pequeno avanço importa muito 📈",
"Você constrói pontes onde havia muros 🌉",
"Sua presença já é terapêutica ☀️",
"O cuidado que você oferece volta para você 🔄",
"Você ajuda pessoas a se encontrarem 🧭",
"Cada consulta é uma semente plantada 🌾",
"Sua escuta ativa salva vidas silenciosamente 🤫",
"Você transforma dor em possibilidade 🦅",
"Gratidão por existir nessa profissão 🌈",
"Você é a diferença que alguém esperava 🎯",
"Seu compromisso com o outro é admirável 🏅",
"Cada história que você ouve é sagrada 📖",
"Você cultiva coragem nos outros ⚡",
"Pequenos gestos seus geram grandes mudanças 🪄",
"Você é testemunha das maiores transformações 🦜",
"Sua paciência é um presente raro 🎁",
"O mundo é melhor com profissionais como você 🌍",
"Você inspira mesmo quando não percebe 💫",
"Cada paciente carrega um pedaço da sua dedicação 🧩",
"Você acolhe o que o mundo muitas vezes rejeita 🤲",
"Sua vocação é visível em cada detalhe 🔍",
"Você cria espaços seguros onde havia medo 🏡",
"Sua energia positiva é contagiante 🌞",
"Você lembra às pessoas que elas não estão sozinhas 🫂",
"Cada sessão bem conduzida é uma vitória 🏆",
"Você acredita nas pessoas antes delas acreditarem em si 🌠",
"Sua competência é tão importante quanto sua compaixão 💎",
"Você guia sem tirar a autonomia de ninguém 🧗",
"Sua história também inspira 📌",
"Você faz o invisível se tornar visível 🔦",
"Cuidar é um ato revolucionário 🔥",
"Você nunca sabe o quanto uma sessão mudou tudo 🌪️",
"Sua leveza ajuda o outro a respirar 💨",
"Você escolheu a profissão mais humana do mundo ❤️",
"Obrigado por não desistir de ninguém 🛡️"
]
}
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/App.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { onMounted, watch } from 'vue' import { onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
+16
View File
@@ -1,3 +1,19 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/app/bootstrapUserSettings.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout' import { useLayout } from '@/layout/composables/layout'
import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes' import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
+16
View File
@@ -1,3 +1,19 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/app/session.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue' import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_core.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
html { html {
height: 100%; height: 100%;
font-size: 14px; font-size: 14px;
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_footer.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
.layout-footer { .layout-footer {
display: flex; display: flex;
align-items: center; align-items: center;
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_main.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
.layout-main-container { .layout-main-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_menu.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@use 'mixins' as *; @use 'mixins' as *;
/* ── Sidebar container ─────────────────────────────────────── */ /* ── Sidebar container ─────────────────────────────────────── */
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_mixins.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@mixin focused() { @mixin focused() {
outline-width: var(--focus-ring-width); outline-width: var(--focus-ring-width);
outline-style: var(--focus-ring-style); outline-style: var(--focus-ring-style);
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_preloading.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
.preloader { .preloader {
position: fixed; position: fixed;
z-index: 999999; z-index: 999999;
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_responsive.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@media screen and (min-width: 1960px) { @media screen and (min-width: 1960px) {
.layout-main, .layout-main,
.landing-wrapper { .landing-wrapper {
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_topbar.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@use 'mixins' as *; @use 'mixins' as *;
.layout-topbar { .layout-topbar {
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_typography.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
h1, h1,
h2, h2,
h3, h3,
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/_utils.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/* Utils */ /* Utils */
.clearfix:after { .clearfix:after {
content: ' '; content: ' ';
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/layout.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@use './variables/_common'; @use './variables/_common';
@use './variables/_light'; @use './variables/_light';
@use './variables/_dark'; @use './variables/_dark';
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/variables/_common.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
:root { :root {
--primary-color: var(--p-primary-color); --primary-color: var(--p-primary-color);
--primary-contrast-color: var(--p-primary-contrast-color); --primary-contrast-color: var(--p-primary-contrast-color);
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/variables/_dark.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
:root[class*='app-dark'] { :root[class*='app-dark'] {
--surface-ground: var(--p-surface-950); --surface-ground: var(--p-surface-950);
--code-background: var(--p-surface-800); --code-background: var(--p-surface-800);
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/layout/variables/_light.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
:root { :root {
--surface-ground: var(--p-surface-100); --surface-ground: var(--p-surface-100);
--code-background: var(--p-surface-900); --code-background: var(--p-surface-900);
+204
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/styles.scss
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/* ── Imports ─────────────────────────── */ /* ── Imports ─────────────────────────── */
@use 'primeicons/primeicons.css'; @use 'primeicons/primeicons.css';
@use '@/assets/layout/layout.scss'; @use '@/assets/layout/layout.scss';
@@ -29,3 +46,190 @@
animation: highlight-pulse 1s ease-out 3; animation: highlight-pulse 1s ease-out 3;
border-color: rgba(99,102,241,0.6) !important; border-color: rgba(99,102,241,0.6) !important;
} }
.agenda-altura {
height: calc(90vh - 150px);
overflow-y: auto;
}
.fade-up-enter-active {
transition: opacity 0.4s ease, transform 0.4s ease;
}
.fade-up-enter-from {
opacity: 0;
transform: translateY(16px);
}
.fade-up-enter-active .anim-child {
transition: opacity 0.45s ease, transform 0.45s ease;
transition-delay: var(--delay, 0ms);
}
.fade-up-enter-from .anim-child {
opacity: 0;
transform: translateY(16px);
}
.menu-enter-from {
opacity: 0;
transform: translateY(4px) scale(1.5);
}
.menu-enter-active {
transition:
opacity 140ms ease,
transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.menu-enter-to {
opacity: 1;
transform: translateY(0) scale(1);
}
/* ── LoadedPhraseBlock ───────────────── */
.loaded-phrase-block {
border: 2px dashed var(--surface-border);
border-radius: 12px;
padding: 0.875rem 1.125rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
background: color-mix(in srgb, var(--primary-color, #6366f1) 3%, var(--surface-card));
}
.loaded-phrase-block__header {
display: flex;
align-items: center;
gap: 0.45rem;
}
.loaded-phrase-block__icon {
font-size: 0.9rem;
color: var(--green-500, #22c55e);
}
.loaded-phrase-block__title {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-color-secondary);
}
.loaded-phrase-block__text {
font-size: 0.88rem;
color: var(--text-color);
font-style: italic;
line-height: 1.5;
margin: 0;
}
.loaded-phrase-in-enter-active {
transition: opacity 0.5s ease, transform 0.5s ease;
}
.loaded-phrase-in-enter-from {
opacity: 0;
transform: translateY(10px);
}
/* ── Subheader de seção ──────────────────────────────── */
.cfg-subheader {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.875rem 1rem;
border-radius: 6px;
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
background: linear-gradient(
135deg,
color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%,
color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%,
var(--surface-card) 100%
);
overflow: hidden;
position: relative;
}
/* Brilho sutil no canto */
.cfg-subheader::before {
content: '';
position: absolute;
top: -20px; right: -20px;
width: 80px; height: 80px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
filter: blur(20px);
pointer-events: none;
}
.cfg-subheader__icon {
display: grid; place-items: center;
flex-shrink: 0;
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
color: var(--primary-color, #6366f1);
}
.cfg-subheader__title {
font-size: 0.95rem;
font-weight: 700;
color: var(--primary-color, #6366f1);
letter-spacing: -0.01em;
}
.cfg-subheader__sub {
font-size: 0.75rem;
color: var(--text-color-secondary);
opacity: 0.85;
}
.cfg-card__icon-wrap {
border: 1px solid var(--surface-border);
background: var(--surface-ground);
display: grid;
place-items: center;
flex-shrink: 0;
}
/* ── Dash cards ───────────────────────────────────── */
.dash-card {
display: flex; flex-direction: column;
background: var(--surface-card);
border: 1px solid var(--surface-border);
overflow: hidden;
transition: box-shadow 0.2s;
}
.dash-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,0.07); }
.dash-card__head {
display: flex; align-items: center;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.dash-card__icon {
width: 2.25rem; height: 2.25rem;
border-radius: 8px;
display: grid; place-items: center;
flex-shrink: 0; font-size: 1rem;
}
.dash-card__sub { font-size: 0.875rem; color: var(--text-color-secondary); margin-top: 1px; }
.dash-card__badge {
margin-left: auto; flex-shrink: 0;
padding: 2px 10px; border-radius: 999px;
font-size: 0.875rem; font-weight: 700;
}
.dash-card__body {
flex: 1; padding: 0.75rem 1rem;
display: flex; flex-direction: column; gap: 0.625rem;
min-height: 88px;
}
.dash-card__foot {
padding: 0.625rem 1rem;
border-top: 1px solid var(--surface-border);
font-size: 0.875rem; font-weight: 700;
color: var(--primary-color, #6366f1);
cursor: pointer; transition: background 0.1s;
}
.dash-card__foot:hover { background: var(--surface-ground); }
/* ── Dash items (dentro dos cards) ───────────────── */
.dash-item {
display: flex; align-items: center;
}
.dash-item__avatar {
width: 2rem; height: 2rem; border-radius: 50%;
flex-shrink: 0; display: grid; place-items: center;
font-size: 0.75rem; font-weight: 700; color: #fff;
}
.dash-item__name { font-size: 0.9375rem; font-weight: 600; color: var(--text-color); }
.dash-item__sub { font-size: 0.8125rem; color: var(--text-color-secondary); margin-top: 1px; }
.dash-item__actions { display: flex; gap: 0.375rem; margin-left: auto; flex-shrink: 0; }
/* ── Empty state dos cards ────────────────────────── */
.dash-empty {
display: flex; align-items: center; gap: 0.5rem;
font-size: 0.875rem; color: var(--text-color-secondary);
padding: 0.5rem 0;
}
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/assets/tailwind.css
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@import "tailwindcss"; @import "tailwindcss";
@plugin "tailwindcss-primeui"; @plugin "tailwindcss-primeui";
+16 -2
View File
@@ -1,5 +1,19 @@
<!-- src/components/AjudaDrawer.vue --> <!--
<!-- Painel de ajuda lateral home com sessão/docs/faq + navegação interna + votação --> |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/AjudaDrawer.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
+16 -2
View File
@@ -1,5 +1,19 @@
<!-- src/components/AppOfflineOverlay.vue --> <!--
<!-- Detecta offline via eventos nativos do browser + polling de fetch --> |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/AppOfflineOverlay.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue' import { ref, onMounted, onBeforeUnmount } from 'vue'
+55 -6
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ComponentCadastroRapido.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<Dialog <Dialog
v-model:visible="isOpen" v-model:visible="isOpen"
@@ -15,7 +31,7 @@
<div class="min-w-0"> <div class="min-w-0">
<div class="text-xl font-semibold">{{ title }}</div> <div class="text-xl font-semibold">{{ title }}</div>
<div class="text-sm text-surface-500"> <div class="text-sm text-surface-500">
Crie um paciente rapidamente (nome, e-mail e telefone obrigatórios). Crie um paciente rapidamente.
</div> </div>
</div> </div>
@@ -51,7 +67,7 @@
:disabled="saving" :disabled="saving"
autocomplete="off" autocomplete="off"
autofocus autofocus
@keydown.enter.prevent="submit" @keydown.enter.prevent="submit('only')"
/> />
</IconField> </IconField>
<label for="cr-nome">Nome completo *</label> <label for="cr-nome">Nome completo *</label>
@@ -71,7 +87,7 @@
:disabled="saving" :disabled="saving"
inputmode="email" inputmode="email"
autocomplete="off" autocomplete="off"
@keydown.enter.prevent="submit" @keydown.enter.prevent="submit('only')"
/> />
</IconField> </IconField>
<label for="cr-email">E-mail *</label> <label for="cr-email">E-mail *</label>
@@ -90,7 +106,7 @@
class="w-full" class="w-full"
variant="filled" variant="filled"
:disabled="saving" :disabled="saving"
@keydown.enter.prevent="submit" @keydown.enter.prevent="submit('only')"
/> />
</IconField> </IconField>
<label for="cr-telefone">Telefone *</label> <label for="cr-telefone">Telefone *</label>
@@ -111,12 +127,31 @@
:disabled="saving" :disabled="saving"
@click="close" @click="close"
/> />
<!-- Na rota de pacientes: "Salvar" -->
<Button <Button
v-if="isOnPatientsPage"
label="Salvar" label="Salvar"
:loading="saving" :loading="saving"
:disabled="saving" :disabled="saving"
@click="submit" @click="submit('only')"
/> />
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button
label="Salvar e fechar"
severity="secondary"
outlined
:loading="saving"
:disabled="saving"
@click="submit('only')"
/>
<Button
label="Salvar e ver pacientes"
:loading="saving"
:disabled="saving"
@click="submit('view')"
/>
</template>
</div> </div>
</template> </template>
</Dialog> </Dialog>
@@ -124,6 +159,7 @@
<script setup> <script setup>
import { computed, reactive, ref, watch } from 'vue' import { computed, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useRoleGuard } from '@/composables/useRoleGuard' import { useRoleGuard } from '@/composables/useRoleGuard'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
@@ -134,6 +170,13 @@ import Message from 'primevue/message'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
const { canSee } = useRoleGuard() const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
const isOnPatientsPage = computed(() => {
const p = String(route.path || '')
return p.includes('/patients') || p.includes('/pacientes')
})
/** /**
* Lista "curada" de pensadores influentes na psicanálise e seu entorno. * Lista "curada" de pensadores influentes na psicanálise e seu entorno.
@@ -325,7 +368,12 @@ function generateUser () {
}) })
} }
async function submit () { function patientsListRoute () {
const p = String(route.path || '')
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes'
}
async function submit (mode = 'only') {
touched.value = true touched.value = true
errorMsg.value = '' errorMsg.value = ''
@@ -378,6 +426,7 @@ async function submit () {
emit('created', data) emit('created', data)
if (props.closeOnCreated) close() if (props.closeOnCreated) close()
if (mode === 'view') await router.push(patientsListRoute())
} catch (err) { } catch (err) {
const msg = err?.message || err?.details || 'Não foi possível criar o paciente.' const msg = err?.message || err?.details || 'Não foi possível criar o paciente.'
errorMsg.value = msg errorMsg.value = msg
@@ -0,0 +1,416 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaEventoFinanceiroPanel.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<!--
AgendaEventoFinanceiroPanel
Painel compacto de status financeiro exibido dentro do modal de sessão.
Mostra o financial_record vinculado ao evento e permite registrar pagamento
ou gerar cobrança sem sair do contexto da agenda.
Props:
evento linha de agenda_eventos (deve ter: id, tipo, billed,
billing_contract_id, price, patient_id, inicio_em)
Emits:
cobranca-atualizada após qualquer mutação, para o pai recarregar
-->
<script setup>
import { ref, computed, watch } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'
// ── props / emits ─────────────────────────────────────────────────────────────
const props = defineProps({
evento: {
type: Object,
required: true,
},
})
const emit = defineEmits(['cobranca-atualizada'])
// ── external ──────────────────────────────────────────────────────────────────
const toast = useToast()
const confirm = useConfirm()
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro()
// ── estado local ──────────────────────────────────────────────────────────────
const record = ref(null) // financial_record vinculado
const fetching = ref(false)
const generating = ref(false)
// ── opções de método de pagamento ─────────────────────────────────────────────
const PAYMENT_METHODS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' },
]
function paymentLabel (method) {
return PAYMENT_METHODS.find(o => o.value === method)?.label ?? method ?? '—'
}
// ── formatação ─────────────────────────────────────────────────────────────────
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' })
function fmtBRL (v) { return _brl.format(v ?? 0) }
function fmtDate (iso) {
if (!iso) return '—'
const d = iso.includes('T') ? new Date(iso) : new Date(iso + 'T00:00:00')
return new Intl.DateTimeFormat('pt-BR').format(d)
}
// ── config visual de status ────────────────────────────────────────────────────
const STATUS_CFG = {
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
cancelled: { label: 'Cancelado', severity: 'secondary' },
}
// ── computed: cenário a renderizar ────────────────────────────────────────────
const scenario = computed(() => {
if (props.evento.tipo !== 'sessao') return 'noop' // bloqueio
if (props.evento.billing_contract_id) return 'contrato' // pacote
if (fetching.value) return 'carregando'
if (record.value) return 'com-cobranca'
return 'sem-cobranca'
})
const canAct = computed(() =>
record.value && (record.value.status === 'pending' || record.value.status === 'overdue')
)
// ── buscar financial_record pelo evento ───────────────────────────────────────
async function fetchRecord () {
if (!props.evento.id) return
fetching.value = true
try {
const { data, error } = await supabase
.from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (error) throw error
record.value = data ?? null
} catch (e) {
console.warn('[AgendaEventoFinanceiroPanel] fetchRecord:', e?.message)
record.value = null
} finally {
fetching.value = false
}
}
watch(() => props.evento?.id, () => {
record.value = null
fetchRecord()
}, { immediate: true })
// ── gerar cobrança ─────────────────────────────────────────────────────────────
async function onGerarCobranca () {
generating.value = true
try {
const result = await gerarCobrancaManual(props.evento)
if (!result.ok) throw new Error(result.error)
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'success', summary: 'Cobrança gerada', detail: `${fmtBRL(props.evento.price ?? 0)} agendado para recebimento.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível gerar a cobrança.', life: 4000 })
} finally {
generating.value = false
}
}
// ── dialog: registrar pagamento ───────────────────────────────────────────────
const payDlgVisible = ref(false)
const payDlgMethod = ref(null)
const payDlgLoading = ref(false)
function openPayDialog () {
payDlgMethod.value = null
payDlgVisible.value = true
}
async function confirmPayment () {
if (!payDlgMethod.value || !record.value) return
payDlgLoading.value = true
try {
const { data, error } = await supabase.rpc('mark_as_paid', {
p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value,
})
if (error) throw error
payDlgVisible.value = false
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'success', summary: 'Pago!', detail: `Recebimento via ${paymentLabel(payDlgMethod.value)} registrado.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível registrar pagamento.', life: 4000 })
} finally {
payDlgLoading.value = false
}
}
// ── cancelar cobrança ─────────────────────────────────────────────────────────
function requestCancel () {
confirm.require({
message: `Cancelar a cobrança de ${fmtBRL(record.value?.final_amount)} desta sessão?`,
header: 'Cancelar cobrança',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Não',
acceptLabel: 'Sim, cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
const { error } = await supabase
.from('financial_records')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', record.value.id)
if (error) throw error
await fetchRecord()
emit('cobranca-atualizada')
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Cobrança cancelada.', life: 3000 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 })
}
},
})
}
</script>
<template>
<div>
<!-- Painel principal noop (bloqueios) não renderiza nada -->
<div v-if="scenario !== 'noop'" class="fin-panel">
<!-- Cabeçalho do painel -->
<div class="fin-panel__header">
<i class="pi pi-wallet" />
<span>Cobrança</span>
<Button
v-if="props.evento.billed && !fetching"
icon="pi pi-refresh"
text
size="small"
severity="secondary"
class="ml-auto h-6 w-6"
v-tooltip.top="'Recarregar'"
@click="fetchRecord"
/>
</div>
<!-- Sessão de pacote / contrato -->
<div v-if="scenario === 'contrato'" class="fin-panel__body">
<span class="fin-badge fin-badge--contract">
<i class="pi pi-box text-xs" />
Sessão de pacote
</span>
</div>
<!-- Sem cobrança gerada -->
<div v-else-if="scenario === 'sem-cobranca'" class="fin-panel__body fin-panel__body--empty">
<div class="flex items-center gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-minus-circle text-sm opacity-50" />
<span class="text-sm">Sem cobrança gerada</span>
</div>
<Button
label="Gerar cobrança"
icon="pi pi-plus"
size="small"
class="rounded-full mt-2"
:loading="generating || finLoading"
@click="onGerarCobranca"
/>
<div v-if="props.evento.price" class="text-xs text-[var(--text-color-secondary)] mt-1">
Valor da sessão: {{ fmtBRL(props.evento.price) }}
</div>
</div>
<!-- Carregando o financial_record -->
<div v-else-if="scenario === 'carregando'" class="fin-panel__body">
<div class="flex flex-col gap-1.5">
<Skeleton height="1rem" class="w-24" />
<Skeleton height="1.5rem" class="w-32" />
<Skeleton height="1rem" class="w-20" />
</div>
</div>
<!-- Com cobrança -->
<div v-else-if="scenario === 'com-cobranca'" class="fin-panel__body">
<!-- Linha de status + valor -->
<div class="flex items-center justify-between gap-2">
<Tag
:value="STATUS_CFG[record.status]?.label ?? record.status"
:severity="STATUS_CFG[record.status]?.severity"
class="text-xs"
/>
<span class="font-bold text-sm text-[var(--text-color)]">{{ fmtBRL(record.final_amount) }}</span>
</div>
<!-- Vencimento / data de pagamento -->
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] mt-1.5">
<template v-if="record.status === 'paid'">
<i class="pi pi-check-circle text-emerald-500" />
<span class="text-emerald-600">{{ paymentLabel(record.payment_method) }} · {{ fmtDate(record.paid_at) }}</span>
</template>
<template v-else>
<i class="pi pi-calendar" />
<span :class="record.status === 'overdue' ? 'text-red-500 font-semibold' : ''">
Vence {{ fmtDate(record.due_date) }}
</span>
</template>
</div>
<!-- Ações: pendente / vencido -->
<div v-if="canAct" class="flex gap-1.5 mt-3">
<Button
label="Receber"
icon="pi pi-check"
size="small"
class="rounded-full flex-1"
@click="openPayDialog"
/>
<Button
icon="pi pi-times"
size="small"
severity="danger"
outlined
class="rounded-full h-7 w-7"
v-tooltip.top="'Cancelar cobrança'"
@click="requestCancel"
/>
</div>
</div>
</div>
<!-- Dialog: Registrar Pagamento -->
<Dialog
v-model:visible="payDlgVisible"
modal
:draggable="false"
pt:mask:class="backdrop-blur-xs"
header="Registrar pagamento"
class="w-[92vw] max-w-sm"
>
<div class="flex flex-col gap-4 pt-1">
<!-- Valor -->
<div class="flex items-center justify-between px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<span class="text-sm text-[var(--text-color-secondary)]">Valor a receber</span>
<span class="font-bold text-[var(--text-color)]">{{ fmtBRL(record?.final_amount) }}</span>
</div>
<!-- Método (grid de botões) -->
<div>
<div class="text-sm font-semibold text-[var(--text-color)] mb-2">Método de pagamento</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in PAYMENT_METHODS"
:key="opt.value"
type="button"
class="flex flex-col items-center gap-1 px-2 py-2 rounded-md border text-xs font-medium transition-all duration-150 cursor-pointer select-none"
:class="payDlgMethod === opt.value
? 'border-[var(--primary-color,#6366f1)] bg-[var(--primary-color,#6366f1)]/10 text-[var(--primary-color,#6366f1)]'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-[var(--primary-color,#6366f1)]/40'"
@click="payDlgMethod = opt.value"
>
<i
class="text-base"
:class="{
'pi pi-bolt': opt.value === 'pix',
'pi pi-building': opt.value === 'deposito',
'pi pi-money-bill': opt.value === 'dinheiro',
'pi pi-credit-card': opt.value === 'cartao',
'pi pi-id-card': opt.value === 'convenio',
}"
/>
{{ opt.label }}
</button>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="payDlgLoading" @click="payDlgVisible = false" />
<Button label="Confirmar" icon="pi pi-check" class="rounded-full" :loading="payDlgLoading" :disabled="!payDlgMethod" @click="confirmPayment" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.fin-panel {
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 0.5rem;
overflow: hidden;
background: var(--surface-card, #fff);
}
.fin-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground, #f8fafc);
border-bottom: 1px solid var(--surface-border, #e2e8f0);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.fin-panel__body {
padding: 0.75rem;
}
.fin-panel__body--empty {
display: flex;
flex-direction: column;
}
.fin-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
}
.fin-badge--contract {
background: color-mix(in srgb, var(--p-indigo-500, #6366f1) 10%, transparent);
color: var(--p-indigo-600, #4f46e5);
border: 1px solid color-mix(in srgb, var(--p-indigo-500, #6366f1) 20%, transparent);
}
</style>
@@ -1,4 +1,19 @@
<!-- src/components/agenda/AgendaOnlineGradeCard.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaOnlineGradeCard.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import TabView from 'primevue/tabview' import TabView from 'primevue/tabview'
@@ -1,4 +1,19 @@
<!-- src/components/agenda/AgendaSlotsPorDiaCard.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaSlotsPorDiaCard.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref, watch, onMounted } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
import TabView from 'primevue/tabview' import TabView from 'primevue/tabview'
+16 -1
View File
@@ -1,4 +1,19 @@
<!-- src/components/agenda/PausasChipsEditor.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/PausasChipsEditor.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import DatePicker from 'primevue/datepicker' import DatePicker from 'primevue/datepicker'
@@ -0,0 +1,284 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agendador/AgendadorPreview.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed } from 'vue'
const props = defineProps({
cfg: { type: Object, required: true }
})
const cor = computed(() => props.cfg.cor_primaria || '#4b6bff')
const corMix = computed(() => `color-mix(in srgb, ${cor.value} 15%, transparent)`)
const tipos = [
{ key: 'primeira', label: 'Primeira Entrevista', sub: 'Novo paciente', icon: 'pi-star', bg: '#0284c7', shadow: 'rgba(2,132,199,.25)' },
{ key: 'retorno', label: 'Retorno', sub: 'Já sou paciente', icon: 'pi-refresh', bg: '#059669', shadow: 'rgba(5,150,105,.25)' },
{ key: 'reagendar',label: 'Reagendar', sub: 'Mudar data ou horário', icon: 'pi-calendar-plus', bg: '#7c3aed', shadow: 'rgba(124,58,237,.25)' },
]
const tiposAtivos = computed(() =>
tipos.filter(t => props.cfg.tipos_habilitados?.includes(t.key))
)
const modalidadeLabel = computed(() => ({
presencial: 'Presencial',
online: 'Online (vídeo)',
ambos: 'Presencial · Online',
}[props.cfg.modalidade] || 'Presencial'))
</script>
<template>
<!-- Frame de celular -->
<div class="phone-frame">
<div class="phone-notch" />
<!-- Root do agendador -->
<div class="agdp-root">
<!-- Card principal -->
<div class="agdp-card" :style="{ '--cp': cor }">
<!-- Hero -->
<div class="agdp-hero">
<!-- Blobs -->
<div class="agdp-blobs" aria-hidden="true">
<div class="agdp-blob agdp-blob--1" :style="{ background: `color-mix(in srgb, ${cor} 22%, transparent)` }" />
<div class="agdp-blob agdp-blob--2" :style="{ background: `color-mix(in srgb, ${cor} 12%, transparent)` }" />
</div>
<!-- Logo -->
<div class="agdp-avatar">
<img v-if="cfg.logomarca_url" :src="cfg.logomarca_url" alt="logo" class="w-full h-full object-cover" />
<i v-else class="pi pi-heart text-xl" :style="{ color: cor }" />
</div>
<!-- Nome -->
<div class="agdp-name">
{{ cfg.nome_exibicao || 'Seu nome aqui' }}
</div>
<!-- Endereço -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-endereco">
<i class="pi pi-map-marker text-[0.6rem]" />
<span>{{ cfg.endereco }}</span>
</div>
<!-- Modalidade badge -->
<div class="agdp-badge" :style="{ background: corMix, color: cor }">
<i class="pi pi-video text-[0.55rem]" />
{{ modalidadeLabel }}
</div>
</div>
<!-- Home section -->
<div class="agdp-section">
<!-- Mensagem de boas-vindas -->
<p class="agdp-welcome">
{{ cfg.mensagem_boas_vindas || 'Bem-vindo! Escolha o tipo de consulta para começar.' }}
</p>
<!-- Como chegar -->
<div v-if="cfg.botao_como_chegar_ativo && cfg.endereco" class="agdp-como-chegar">
<i class="pi pi-directions text-[0.6rem]" />
<span :style="{ color: cor }">Como chegar</span>
</div>
<div class="agdp-divider" />
<!-- Botões de tipo -->
<div class="agdp-tipos">
<div
v-for="t in tiposAtivos"
:key="t.key"
class="agdp-tipo-btn"
:style="{ '--btn-bg': t.bg, '--btn-shadow': t.shadow }"
>
<div class="agdp-tipo-icon">
<i :class="`pi ${t.icon} text-[0.65rem]`" />
</div>
<div class="agdp-tipo-label">
<span class="agdp-tipo-main">{{ t.label }}</span>
<span class="agdp-tipo-sub">{{ t.sub }}</span>
</div>
<i class="pi pi-arrow-right text-[0.55rem] opacity-40 ml-auto" />
</div>
<!-- Placeholder se nenhum tipo ativo -->
<div v-if="!tiposAtivos.length" class="agdp-no-tipos">
Nenhum tipo de consulta habilitado
</div>
</div>
<!-- Powered -->
<p class="agdp-powered">Powered by <strong>Agência Psi</strong></p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* ── Frame de celular ──────────────────── */
.phone-frame {
position: relative;
width: 260px;
min-height: 500px;
margin: 0 auto;
border-radius: 2.5rem;
border: 8px solid #1e293b;
background: #1e293b;
box-shadow:
0 0 0 2px #334155,
0 32px 64px rgba(0,0,0,.35),
0 8px 24px rgba(0,0,0,.2);
overflow: hidden;
}
.phone-notch {
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 72px;
height: 10px;
background: #1e293b;
border-radius: 0 0 10px 10px;
z-index: 10;
}
/* ── Root ──────────────────────────────── */
.agdp-root {
background: #f0f3fb;
min-height: 100%;
padding: 12px 10px 16px;
overflow-y: auto;
max-height: 560px;
}
/* ── Card ──────────────────────────────── */
.agdp-card {
background: #fff;
border-radius: 1.25rem;
border: 1px solid rgba(0,0,0,.06);
box-shadow: 0 8px 24px rgba(0,0,0,.08), 0 2px 8px rgba(0,0,0,.04);
overflow: hidden;
}
/* ── Hero ──────────────────────────────── */
.agdp-hero {
position: relative;
overflow: hidden;
padding: 1.25rem 1rem 0.875rem;
background: #f7f8fd;
border-bottom: 1px solid rgba(0,0,0,.06);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.agdp-blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.agdp-blob { position: absolute; border-radius: 50%; filter: blur(40px); }
.agdp-blob--1 { width: 8rem; height: 8rem; top: -2rem; right: -2rem; }
.agdp-blob--2 { width: 7rem; height: 7rem; bottom: -2rem; left: -2rem; }
.agdp-avatar {
position: relative; z-index: 1;
width: 52px; height: 52px;
border-radius: 50%;
border: 2.5px solid #fff;
box-shadow: 0 4px 12px rgba(0,0,0,.12);
background: #f1f5f9;
overflow: hidden;
display: grid; place-items: center;
flex-shrink: 0;
}
.agdp-name {
position: relative; z-index: 1;
font-size: 0.82rem; font-weight: 800;
letter-spacing: -0.02em;
color: #111827;
text-align: center;
}
.agdp-endereco {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 3px;
font-size: 0.62rem; color: #6b7280;
text-align: center;
max-width: 90%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.agdp-badge {
position: relative; z-index: 1;
font-size: 0.58rem; font-weight: 700;
padding: 2px 8px;
border-radius: 999px;
display: flex; align-items: center; gap: 3px;
}
/* ── Section ───────────────────────────── */
.agdp-section { padding: 0.875rem 0.875rem 0.75rem; }
.agdp-welcome {
font-size: 0.68rem; color: #6b7280;
text-align: center; line-height: 1.5;
margin-bottom: 0.6rem;
}
.agdp-como-chegar {
display: flex; align-items: center; justify-content: center; gap: 3px;
font-size: 0.62rem; font-weight: 700;
margin-bottom: 0.6rem;
}
.agdp-divider {
height: 1px; background: #e5e7eb; margin-bottom: 0.75rem;
}
/* ── Tipos ─────────────────────────────── */
.agdp-tipos { display: flex; flex-direction: column; gap: 0.5rem; }
.agdp-tipo-btn {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px;
border-radius: 0.75rem;
border: 1.5px solid #e5e7eb;
background: #fff;
cursor: default;
transition: border-color .15s, box-shadow .15s;
}
.agdp-tipo-icon {
width: 28px; height: 28px;
border-radius: 0.5rem;
display: grid; place-items: center;
background: color-mix(in srgb, var(--btn-bg, #6366f1) 15%, transparent);
color: var(--btn-bg, #6366f1);
flex-shrink: 0;
}
.agdp-tipo-label { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; }
.agdp-tipo-main { font-size: 0.72rem; font-weight: 700; color: #111827; }
.agdp-tipo-sub { font-size: 0.58rem; color: #9ca3af; }
.agdp-no-tipos {
font-size: 0.68rem; color: #9ca3af;
text-align: center; padding: 1rem 0;
}
.agdp-powered {
text-align: center; font-size: 0.58rem; color: #9ca3af;
margin-top: 1rem;
}
.agdp-powered strong { color: #6b7280; font-weight: 700; }
</style>
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/BestSellingWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/NotificationsWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/RecentSalesWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ProductService } from '@/services/ProductService'; import { ProductService } from '@/services/ProductService';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/RevenueStreamWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { useLayout } from '@/layout/composables/layout'; import { useLayout } from '@/layout/composables/layout';
import { onMounted, ref, watch } from 'vue'; import { onMounted, ref, watch } from 'vue';
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/dashboard/StatsWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<div class="col-span-12 lg:col-span-6 xl:col-span-3"> <div class="col-span-12 lg:col-span-6 xl:col-span-3">
<div class="card mb-0"> <div class="card mb-0">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/FeaturesWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<div id="features" class="py-6 px-6 lg:px-20 mt-8 mx-0 lg:mx-20"> <div id="features" class="py-6 px-6 lg:px-20 mt-8 mx-0 lg:mx-20">
<div class="grid grid-cols-12 gap-4 justify-center"> <div class="grid grid-cols-12 gap-4 justify-center">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/FooterWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<div class="py-6 px-6 mx-0 mt-20 lg:mx-20"> <div class="py-6 px-6 mx-0 mt-20 lg:mx-20">
<div class="grid grid-cols-12 gap-4"> <div class="grid grid-cols-12 gap-4">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/HeroWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<div <div
id="hero" id="hero"
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/HighlightsWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<div id="highlights" class="py-6 px-6 lg:px-20 mx-0 my-12 lg:mx-20"> <div id="highlights" class="py-6 px-6 lg:px-20 mx-0 my-12 lg:mx-20">
<div class="text-center"> <div class="text-center">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/PricingWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<div id="pricing" class="py-6 px-6 lg:px-20 my-2 md:my-6"> <div id="pricing" class="py-6 px-6 lg:px-20 my-2 md:my-6">
<div class="text-center mb-6"> <div class="text-center mb-6">
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/landing/TopbarWidget.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
function smoothScroll(id) { function smoothScroll(id) {
document.body.click(); document.body.click();
@@ -1,4 +1,19 @@
<!-- src/components/notifications/NotificationDrawer.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/notifications/NotificationDrawer.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -1,4 +1,19 @@
<!-- src/components/notifications/NotificationItem.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/notifications/NotificationItem.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
+16 -1
View File
@@ -1,4 +1,19 @@
<!-- src/components/patients/PatientActionMenu.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/patients/PatientActionMenu.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
+16
View File
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/security/FeatureGate.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
+87
View File
@@ -0,0 +1,87 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/AppLoadingPhrases.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Transition name="fade-up" appear>
<div class="flex flex-col items-center justify-center gap-6" :class="containerClass">
<!-- Motivação -->
<div class="flex flex-col items-center gap-2 text-center px-6">
<span class="text-base font-semibold text-[var(--text-color,#1e293b)] leading-snug max-w-[320px]">
{{ motivation || '...' }}
</span>
<span class="text-sm text-[var(--text-color-secondary,#64748b)]">
{{ action }}
</span>
</div>
<!-- Progress bar -->
<div class="w-[220px] h-[3px] rounded-full bg-[var(--surface-border,#e2e8f0)] overflow-hidden">
<div class="progress-bar h-full rounded-full bg-[var(--primary-color,#6366f1)]" />
</div>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted } from 'vue'
defineProps({
action: { type: String, default: 'Carregando...' },
containerClass: { type: String, default: 'py-24' },
})
const motivation = ref(null)
onMounted(async () => {
try {
const res = await fetch('/loading-phrases.json')
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const json = await res.json()
const list = json.motivations || []
motivation.value = list.length
? list[Math.floor(Math.random() * list.length)]
: 'Carregando...'
} catch (e) {
console.warn('[AppLoadingPhrases] fetch falhou:', e)
motivation.value = 'Carregando...'
}
})
</script>
<style scoped>
/* Entrada do componente inteiro */
.fade-up-enter-active {
transition: opacity 0.45s ease, transform 0.45s ease;
}
.fade-up-enter-from {
opacity: 0;
transform: translateY(16px);
}
/* Progress bar — vai de 0% a 85% em ~2.5s, para não "completar" antes do loading acabar */
.progress-bar {
animation: progress-indeterminate 1.6s ease-in-out infinite;
transform-origin: left;
}
@keyframes progress-indeterminate {
0% { margin-left: 0%; width: 0%; }
30% { margin-left: 0%; width: 60%; }
70% { margin-left: 40%; width: 60%; }
100% { margin-left: 100%; width: 0%; }
}
</style>
+44
View File
@@ -0,0 +1,44 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/LoadedPhraseBlock.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Transition name="loaded-phrase-in" appear>
<div v-if="phrase" class="loaded-phrase-block">
<div class="loaded-phrase-block__header">
<i class="pi pi-check-circle loaded-phrase-block__icon" />
<span class="loaded-phrase-block__title">Ambiente carregado!</span>
</div>
<p class="loaded-phrase-block__text">{{ phrase }}</p>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const phrase = ref(null)
onMounted(async () => {
try {
const res = await fetch('/loading-phrases.json')
const json = await res.json()
const list = json.motivations || []
phrase.value = list.length ? list[Math.floor(Math.random() * list.length)] : null
} catch {
phrase.value = null
}
})
</script>
+197
View File
@@ -0,0 +1,197 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/PatientCadastroDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
|
| Dialog de cadastro/edição de paciente.
| Abre PatientsCadastroPage em modo dialog (sem navegação de rota).
|
| Props:
| modelValue (Boolean) controla visibilidade
| patientId (String) null = novo, id = edição
|
| Emits:
| update:modelValue fecha
| created paciente criado ou atualizado com sucesso
|--------------------------------------------------------------------------
-->
<template>
<Dialog
v-model:visible="isOpen"
modal
:draggable="false"
:closable="false"
:dismissableMask="false"
:maximizable="false"
:style="{ width: '90vw', maxWidth: '1100px', height: maximized ? '100vh' : '90vh' }"
:contentStyle="{ padding: 0, overflow: 'auto', height: '100%' }"
pt:mask:class="backdrop-blur-xs"
>
<!-- Header -->
<template #header>
<div class="flex items-center justify-between w-full gap-3">
<!-- Título -->
<span class="text-base font-semibold text-[var(--text-color)] leading-tight">
{{ patientId ? 'Editar Paciente' : 'Cadastro de Paciente' }}
</span>
<!-- Botões à direita -->
<div class="flex items-center gap-1 ml-auto">
<!-- Preencher tudo ( testMODE) -->
<Button
v-if="pageRef?.canSee?.('testMODE')"
label="Preencher tudo"
icon="pi pi-bolt"
severity="secondary"
outlined
size="small"
class="rounded-full"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
@click="pageRef?.fillRandomPatient?.()"
/>
<!-- Excluir ( em edição) -->
<Button
v-if="patientId"
icon="pi pi-trash"
severity="danger"
outlined
size="small"
class="rounded-full"
:loading="pageRef?.deleting?.value"
:disabled="pageRef?.saving?.value || pageRef?.deleting?.value"
title="Excluir paciente"
@click="pageRef?.confirmDelete?.()"
/>
<!-- Maximizar -->
<button
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
:title="maximized ? 'Restaurar' : 'Maximizar'"
@click="maximized = !maximized"
>
<i :class="maximized ? 'pi pi-window-minimize' : 'pi pi-window-maximize'" />
</button>
<!-- Fechar -->
<button
class="w-8 h-8 rounded-lg border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer grid place-items-center text-sm transition-colors hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)]"
title="Fechar"
@click="isOpen = false"
>
<i class="pi pi-times" />
</button>
</div>
</div>
</template>
<!-- Conteúdo -->
<PatientsCadastroPage
ref="pageRef"
:dialog-mode="true"
:patient-id="patientId"
@cancel="isOpen = false"
@created="onCreated"
/>
<!-- Footer -->
<template #footer>
<div class="flex justify-end gap-2">
<Button
label="Cancelar"
severity="secondary"
text
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="isOpen = false"
/>
<!-- Na rota de pacientes: "Salvar" -->
<Button
v-if="isOnPatientsPage"
label="Salvar"
:loading="!!pageRef?.saving?.value"
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="submitWith('only')"
/>
<!-- Fora da rota de pacientes: "Salvar e fechar" + "Salvar e ver pacientes" -->
<template v-else>
<Button
label="Salvar e fechar"
severity="secondary"
outlined
:loading="pendingMode === 'only' && !!pageRef?.saving?.value"
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="submitWith('only')"
/>
<Button
label="Salvar e ver pacientes"
:loading="pendingMode === 'view' && !!pageRef?.saving?.value"
:disabled="!!pageRef?.saving?.value || !!pageRef?.deleting?.value"
@click="submitWith('view')"
/>
</template>
</div>
</template>
</Dialog>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import PatientsCadastroPage from '@/features/patients/cadastro/PatientsCadastroPage.vue'
const props = defineProps({
modelValue: { type: Boolean, default: false },
patientId: { type: String, default: null }
})
const emit = defineEmits(['update:modelValue', 'created'])
const isOpen = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
// Reset maximized when dialog opens
watch(() => props.modelValue, (v) => { if (!v) maximized.value = false })
const maximized = ref(false)
const pageRef = ref(null)
const pendingMode = ref('only')
const route = useRoute()
const router = useRouter()
const isOnPatientsPage = computed(() => {
const p = String(route.path || '')
return p.includes('/patients') || p.includes('/pacientes')
})
function patientsListRoute () {
const p = String(route.path || '')
return p.startsWith('/therapist') ? '/therapist/patients' : '/admin/pacientes'
}
function submitWith (mode) {
pendingMode.value = mode
pageRef.value?.onSubmit()
}
async function onCreated (data) {
isOpen.value = false
emit('created', data)
if (pendingMode.value === 'view') {
await router.push(patientsListRoute())
}
}
</script>
+208
View File
@@ -0,0 +1,208 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/ui/PatientCreatePopover.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
|
| Popover de cadastro de paciente usado na página de pacientes e no
| menu lateral. Encapsula as 3 ações: Cadastro Rápido, Cadastro Completo
| e Link de Cadastro (com URL + copiar).
|
| Emits:
| quick-create usuário escolheu Cadastro Rápido
| go-complete usuário escolheu Cadastro Completo
|
| Expose:
| toggle(event) abre/fecha o Popover
|--------------------------------------------------------------------------
-->
<template>
<PatientCadastroDialog v-model="showCadastroDialog" />
<Popover ref="popRef">
<div class="flex flex-col min-w-[230px]">
<!-- Cadastro rápido -->
<button
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="onQuickCreate"
>
<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-bolt text-sm" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Rápido</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Nome, e-mail e telefone</div>
</div>
</button>
<!-- Cadastro completo -->
<button
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]"
@click="onGoComplete"
>
<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-user-plus text-sm" />
</div>
<div>
<div class="text-sm font-semibold text-[var(--text-color)]">Cadastro Completo</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">Formulário detalhado</div>
</div>
</button>
<!-- Divisor -->
<div class="mx-3 my-1.5 border-t border-[var(--surface-border,#e2e8f0)]" />
<!-- Link de cadastro -->
<div class="px-3 pb-3">
<div class="flex items-center gap-1.5 text-[0.68rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 mb-2">
<i class="pi pi-link text-[0.6rem]" />
Link de cadastro
</div>
<!-- Carregando token -->
<div v-if="loadingToken" class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] py-1">
<i class="pi pi-spin pi-spinner text-[0.7rem]" /> Carregando link
</div>
<!-- Sem token ainda -->
<div v-else-if="!inviteToken" class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-60 py-1">
Nenhum link ativo.
<button class="underline cursor-pointer border-0 bg-transparent text-[var(--primary-color,#6366f1)]" @click="loadToken">Tentar novamente</button>
</div>
<!-- URL + ações -->
<template v-else>
<InputGroup class="w-full">
<InputText
:value="publicUrl"
readonly
class="text-[0.68rem] font-mono"
style="min-width: 0"
/>
<InputGroupAddon
class="cursor-pointer hover:bg-[var(--surface-hover)] transition-colors"
title="Copiar link"
@click="copyLink"
>
<i class="pi pi-copy text-sm" />
</InputGroupAddon>
</InputGroup>
<div class="flex gap-1 mt-2">
<Button
label="Copiar mensagem"
icon="pi pi-comment"
text
size="small"
class="flex-1 text-xs rounded-full"
@click="copyMessage"
/>
<Button
icon="pi pi-external-link"
text
size="small"
class="rounded-full"
v-tooltip.top="'Abrir no navegador'"
@click="openLink"
/>
</div>
</template>
</div>
</div>
</Popover>
</template>
<script setup>
import { ref, computed } from 'vue'
import Popover from 'primevue/popover'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import PatientCadastroDialog from './PatientCadastroDialog.vue'
const emit = defineEmits(['quick-create'])
const showCadastroDialog = ref(false)
const toast = useToast()
const popRef = ref(null)
const inviteToken = ref('')
const loadingToken = ref(false)
let tokenLoaded = false
const publicUrl = computed(() => {
if (!inviteToken.value) return ''
return `${window.location.origin}/cadastro/paciente?t=${encodeURIComponent(inviteToken.value)}`
})
async function loadToken () {
if (tokenLoaded || loadingToken.value) return
loadingToken.value = true
try {
const { data: authData } = await supabase.auth.getUser()
const uid = authData?.user?.id
if (!uid) return
const { data } = await supabase
.from('patient_invites')
.select('token')
.eq('owner_id', uid)
.eq('active', true)
.order('created_at', { ascending: false })
.limit(1)
if (data?.[0]?.token) {
inviteToken.value = data[0].token
tokenLoaded = true
}
} catch { /* silencioso */ } finally {
loadingToken.value = false
}
}
function toggle (event) {
popRef.value?.toggle(event)
loadToken()
}
function close () {
try { popRef.value?.hide() } catch {}
}
function onQuickCreate () { close(); emit('quick-create') }
function onGoComplete () { close(); showCadastroDialog.value = true }
async function copyLink () {
if (!publicUrl.value) return
try {
await navigator.clipboard.writeText(publicUrl.value)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Link copiado para a área de transferência.', life: 1500 })
} catch {
window.prompt('Copie o link:', publicUrl.value)
}
}
async function copyMessage () {
if (!publicUrl.value) return
try {
const msg = `Olá! Segue o link para seu pré-cadastro. Preencha com calma — campos opcionais podem ficar em branco:\n${publicUrl.value}`
await navigator.clipboard.writeText(msg)
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Mensagem copiada para a área de transferência.', life: 1500 })
} catch {}
}
function openLink () {
if (!publicUrl.value) return
window.open(publicUrl.value, '_blank', 'noopener')
}
defineExpose({ toggle, close })
</script>
+16 -2
View File
@@ -1,5 +1,19 @@
// src/composables/useDocsHealth.js /*
// Singleton que computa métricas de saúde dos documentos. |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/Usedocshealth.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Critério de "atenção": mais de 30% dos votos são negativos // Critério de "atenção": mais de 30% dos votos são negativos
// (mínimo 3 votos totais para evitar falso-positivo com 1 voto negativo). // (mínimo 3 votos totais para evitar falso-positivo com 1 voto negativo).
// //
+271
View File
@@ -0,0 +1,271 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAgendaFinanceiro.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/**
* useAgendaFinanceiro
*
* Camada de orquestração entre agenda e financeiro.
* Não modifica useAgendaEvents diretamente recebe a função de update
* como parâmetro para manter o desacoplamento.
*
* Uso:
* const { handleStatusChange, gerarCobrancaManual } = useAgendaFinanceiro()
*
* // No handler de save do componente pai:
* await handleStatusChange(eventoOriginal, novoStatus, agendaEvents.update)
*/
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
// ─── Cache de exceções financeiras (vive enquanto o módulo estiver carregado) ─
// Chave: `${tenantId}:${exceptionType}` → FinancialException | null
const _exceptionsCache = new Map()
// ─── helper ──────────────────────────────────────────────────────────────────
async function getUid () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
return uid
}
// ─── mapeamento: status anterior → tipo de exceção a consultar ───────────────
const STATUS_TO_EXCEPTION = {
faltou: 'patient_no_show',
cancelado: 'patient_cancellation',
}
// ─── calcular valor cobrado por charge_mode ───────────────────────────────────
function calcChargeAmount (originalAmount, rule) {
if (!rule || rule.charge_mode === 'none') return 0
if (rule.charge_mode === 'full') return originalAmount
if (rule.charge_mode === 'fixed') return rule.charge_value ?? 0
if (rule.charge_mode === 'percentage') {
const pct = rule.charge_pct ?? 0
return parseFloat(((originalAmount * pct) / 100).toFixed(2))
}
return originalAmount
}
// ─── composable ──────────────────────────────────────────────────────────────
export function useAgendaFinanceiro () {
const loading = ref(false)
const error = ref(null)
// ── getFinancialExceptionRule ─────────────────────────────────────────────
/**
* Busca a regra de exceção financeira para um tipo, com cache em memória.
* Prioridade: regra própria do owner > regra global do tenant (owner_id IS NULL)
*
* @param {string} tenantId
* @param {'patient_no_show'|'patient_cancellation'|'professional_cancellation'} exceptionType
* @returns {Promise<Object|null>}
*/
async function getFinancialExceptionRule (tenantId, exceptionType) {
const cacheKey = `${tenantId}:${exceptionType}`
if (_exceptionsCache.has(cacheKey)) return _exceptionsCache.get(cacheKey)
const uid = await getUid()
const { data, error: err } = await supabase
.from('financial_exceptions')
.select('*')
.eq('tenant_id', tenantId)
.eq('exception_type', exceptionType)
.or(`owner_id.eq.${uid},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true }) // owner próprio tem prioridade
.limit(1)
.maybeSingle()
if (err) {
console.warn('[useAgendaFinanceiro] getFinancialExceptionRule:', err.message)
return null
}
_exceptionsCache.set(cacheKey, data ?? null)
return data ?? null
}
// ── gerarCobrancaManual ───────────────────────────────────────────────────
/**
* Gera cobrança para uma sessão existente com `billed = false`.
* Chama a RPC `create_financial_record_for_session`.
*
* @param {Object} evento - linha de agenda_eventos (com campo price)
* @returns {Promise<{ok: boolean, data?: Object, error?: string}>}
*/
async function gerarCobrancaManual (evento) {
if (evento.billing_contract_id) {
// sessão de pacote — não gera cobrança individual
return { ok: false, error: 'Sessão faz parte de um pacote. Cobrança gerenciada pelo contrato.' }
}
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
if (!tenantId) return { ok: false, error: 'Tenant não identificado.' }
const ownerId = await getUid()
loading.value = true
error.value = null
try {
const amount = evento.price ?? 0
const dueDate = evento.inicio_em
? new Date(evento.inicio_em).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10)
const { data, error: err } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: ownerId,
p_patient_id: evento.patient_id ?? evento.paciente_id ?? null,
p_agenda_evento_id: evento.id,
p_amount: amount,
p_due_date: dueDate,
})
if (err) throw err
return { ok: true, data }
} catch (e) {
error.value = e?.message || 'Erro ao gerar cobrança.'
return { ok: false, error: error.value }
} finally {
loading.value = false
}
}
// ── handleStatusChange ────────────────────────────────────────────────────
/**
* Orquestra a mudança de status de uma sessão + consequências financeiras.
*
* @param {Object} evento - linha atual de agenda_eventos (ANTES da mudança)
* @param {string} novoStatus - novo status a aplicar
* @param {Function} agendaUpdateFn - função que aplica o update na agenda (ex: agendaEvents.update)
* signature: (id, patch) => Promise<void>
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async function handleStatusChange (evento, novoStatus, agendaUpdateFn) {
// bloqueios e sessões de pacote não têm cobrança individual
const ignorar = evento.tipo !== 'sessao' || !!evento.billing_contract_id
const statusAnterior = evento.status
loading.value = true
error.value = null
try {
// 1. Aplica o update na agenda sempre (fonte da verdade é a agenda)
await agendaUpdateFn(evento.id, { status: novoStatus })
if (ignorar) return { ok: true }
if (statusAnterior === novoStatus) return { ok: true }
// 2. Lógica financeira por transição
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
// ── faltou / cancelado → consultar exceção financeira ──────────────
const exceptionType = STATUS_TO_EXCEPTION[novoStatus]
if (exceptionType) {
const rule = await getFinancialExceptionRule(tenantId, exceptionType)
if (!rule || rule.charge_mode === 'none') {
// Cancelar cobrança existente, se houver
if (evento.billed) {
const { data: existingRec } = await supabase
.from('financial_records')
.select('id, status')
.eq('agenda_evento_id', evento.id)
.in('status', ['pending', 'overdue'])
.maybeSingle()
if (existingRec) {
await supabase
.from('financial_records')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', existingRec.id)
}
}
return { ok: true }
}
// charge_mode != 'none' → ajustar valor da cobrança existente ou criar nova
const chargeAmount = calcChargeAmount(evento.price ?? 0, rule)
if (evento.billed) {
// Atualiza o valor da cobrança existente
const { data: existingRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', evento.id)
.in('status', ['pending', 'overdue'])
.maybeSingle()
if (existingRec) {
await supabase
.from('financial_records')
.update({
amount: chargeAmount,
final_amount: chargeAmount,
updated_at: new Date().toISOString(),
})
.eq('id', existingRec.id)
}
} else if (chargeAmount > 0) {
// Sessão sem cobrança: gera uma nova com o valor ajustado
await gerarCobrancaManual({ ...evento, price: chargeAmount })
}
return { ok: true }
}
// ── remarcar → atualizar due_date da cobrança existente ────────────
if (novoStatus === 'remarcar' && evento.billed) {
// due_date mantém a data da sessão original por enquanto
// (a nova data virá quando a sessão for reagendada)
return { ok: true }
}
// ── agendado → realizado: nenhuma ação financeira automática ────────
return { ok: true }
} catch (e) {
error.value = e?.message || 'Erro ao processar mudança de status.'
return { ok: false, error: error.value }
} finally {
loading.value = false
}
}
// ── invalidar cache (use quando o usuário altera exceções financeiras) ───
function invalidateExceptionsCache () {
_exceptionsCache.clear()
}
return {
loading,
error,
handleStatusChange,
gerarCobrancaManual,
getFinancialExceptionRule,
invalidateExceptionsCache,
}
}
+16 -2
View File
@@ -1,5 +1,19 @@
// src/composables/useAjuda.js /*
// Composable singleton para o drawer de ajuda. |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAjuda.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// - Home: docs da sessão atual + outros docs paginados + FAQ // - Home: docs da sessão atual + outros docs paginados + FAQ
// - Navegação interna com stack (voltar) // - Navegação interna com stack (voltar)
// - Votação por usuário (útil / não útil) // - Votação por usuário (útil / não útil)
+17
View File
@@ -1,3 +1,20 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useAuth.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/** /**
* --------------------------------------------------------- * ---------------------------------------------------------
* useAuth() * useAuth()
+16 -2
View File
@@ -1,5 +1,19 @@
// src/composables/useDocsAdmin.js /*
// Estado compartilhado para abrir o dialog de edição de um doc |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useDocsAdmin.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// a partir de outra página (ex: SaasFaqPage → SaasDocsPage). // a partir de outra página (ex: SaasFaqPage → SaasDocsPage).
import { ref } from 'vue' import { ref } from 'vue'
+16 -2
View File
@@ -1,5 +1,19 @@
// src/composables/useFeriados.js /*
// Fonte única de verdade para feriados: nacionais (algoritmo) + municipais (Supabase). |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useFeriados.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
+328
View File
@@ -0,0 +1,328 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useFinancialRecords.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
// ─── helpers internos ────────────────────────────────────────────────────────
function assertTenantId (tenantId) {
if (!tenantId || tenantId === 'null' || tenantId === 'undefined') {
throw new Error('Tenant ativo inválido. Selecione a clínica antes de operar no financeiro.')
}
}
async function getUid () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
const uid = data?.user?.id
if (!uid) throw new Error('Usuário não autenticado.')
return uid
}
// ─── select base com joins ───────────────────────────────────────────────────
const BASE_SELECT = `
id, tenant_id, owner_id, patient_id, agenda_evento_id,
amount, discount_amount, final_amount,
status, due_date, paid_at, payment_method,
notes, created_at, updated_at,
patients!patient_id (
id, nome_completo, identification_color
),
agenda_eventos!agenda_evento_id (
id, inicio_em, status, tipo
)
`.trim()
// ─── composable ──────────────────────────────────────────────────────────────
export function useFinancialRecords () {
const records = ref([])
const loading = ref(false)
const error = ref(null)
// ── computed: resumo financeiro ─────────────────────────────────────────
const summary = computed(() => {
const now = new Date()
const thisYear = now.getFullYear()
const thisMonth = now.getMonth() // 0-indexed
const countByStatus = { pending: 0, paid: 0, overdue: 0, cancelled: 0 }
let totalPending = 0
let totalOverdue = 0
let totalPaidThisMonth = 0
for (const r of records.value) {
countByStatus[r.status] = (countByStatus[r.status] ?? 0) + 1
if (r.status === 'pending') {
totalPending += r.final_amount ?? r.amount ?? 0
}
if (r.status === 'overdue') {
totalOverdue += r.final_amount ?? r.amount ?? 0
}
if (r.status === 'paid' && r.paid_at) {
const d = new Date(r.paid_at)
if (d.getFullYear() === thisYear && d.getMonth() === thisMonth) {
totalPaidThisMonth += r.final_amount ?? r.amount ?? 0
}
}
}
return { totalPending, totalOverdue, totalPaidThisMonth, countByStatus }
})
// ── fetchRecords ─────────────────────────────────────────────────────────
/**
* @param {Object} [filters]
* @param {string} [filters.status] - 'pending'|'paid'|'overdue'|'partial'|'cancelled'|'refunded'
* @param {string} [filters.type] - 'receita'|'despesa'
* @param {string} [filters.patient_id] - UUID
* @param {string} [filters.due_date_from] - ISO date string
* @param {string} [filters.due_date_to] - ISO date string
* @param {number} [filters.limit] - rows per page (default 50)
* @param {number} [filters.offset] - row offset for pagination (default 0)
* @returns {Promise<{total: number}>}
*/
async function fetchRecords (filters = {}) {
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
loading.value = true
error.value = null
const limit = filters.limit ?? 50
const offset = filters.offset ?? 0
try {
let query = supabase
.from('financial_records')
.select(BASE_SELECT, { count: 'exact' })
.eq('tenant_id', tenantId)
.is('deleted_at', null)
.order('due_date', { ascending: false })
.range(offset, offset + limit - 1)
if (filters.status) query = query.eq('status', filters.status)
if (filters.type) query = query.eq('type', filters.type)
if (filters.patient_id) query = query.eq('patient_id', filters.patient_id)
if (filters.due_date_from) query = query.gte('due_date', filters.due_date_from)
if (filters.due_date_to) query = query.lte('due_date', filters.due_date_to)
const { data, error: err, count } = await query
if (err) throw err
records.value = data ?? []
return { total: count ?? 0 }
} catch (e) {
error.value = e?.message || 'Erro ao carregar registros financeiros.'
records.value = []
return { total: 0 }
} finally {
loading.value = false
}
}
// ── createRecord (via RPC — para sessões) ────────────────────────────────
/**
* @param {Object} payload
* @param {string} payload.patient_id
* @param {string} payload.agenda_evento_id
* @param {number} payload.amount
* @param {string} payload.due_date - ISO date string
*/
async function createRecord (payload) {
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const ownerId = await getUid()
loading.value = true
error.value = null
try {
const { data, error: err } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: ownerId,
p_patient_id: payload.patient_id,
p_agenda_evento_id: payload.agenda_evento_id,
p_amount: payload.amount,
p_due_date: payload.due_date,
})
if (err) throw err
// Re-fetch para garantir joins completos
await fetchRecords()
return { ok: true, data }
} catch (e) {
error.value = e?.message || 'Erro ao criar cobrança.'
return { ok: false, error: e?.message }
} finally {
loading.value = false
}
}
// ── createManualRecord (INSERT direto — sem sessão) ──────────────────────
/**
* @param {Object} payload
* @param {string} payload.patient_id
* @param {number} payload.amount
* @param {number} [payload.discount_amount]
* @param {string} payload.due_date
* @param {string} [payload.status] - default 'pending'
* @param {string} [payload.payment_method]
* @param {string} [payload.notes]
*/
async function createManualRecord (payload) {
const tenantStore = useTenantStore()
const tenantId = tenantStore.activeTenantId
assertTenantId(tenantId)
const ownerId = await getUid()
loading.value = true
error.value = null
try {
const discount = payload.discount_amount ?? 0
const amount = payload.amount ?? 0
const { data, error: err } = await supabase
.from('financial_records')
.insert([{
tenant_id: tenantId,
owner_id: ownerId,
patient_id: payload.patient_id ?? null,
agenda_evento_id: null,
amount,
discount_amount: discount,
final_amount: amount - discount,
status: payload.status ?? 'pending',
due_date: payload.due_date,
payment_method: payload.payment_method ?? null,
notes: payload.notes ?? null,
}])
.select(BASE_SELECT)
.single()
if (err) throw err
records.value = [data, ...records.value]
return { ok: true, data }
} catch (e) {
error.value = e?.message || 'Erro ao criar lançamento manual.'
return { ok: false, error: e?.message }
} finally {
loading.value = false
}
}
// ── markAsPaid ───────────────────────────────────────────────────────────
/**
* @param {string} recordId
* @param {string} paymentMethod - 'pix' | 'deposito' | 'dinheiro' | 'cartao' | 'convenio'
*/
async function markAsPaid (recordId, paymentMethod) {
error.value = null
try {
const { data, error: err } = await supabase.rpc('mark_as_paid', {
p_financial_record_id: recordId,
p_payment_method: paymentMethod,
})
if (err) throw err
// RPC retorna SETOF (array) — patch local direto, sem depender do retorno
const idx = records.value.findIndex(r => r.id === recordId)
if (idx !== -1) {
records.value[idx] = {
...records.value[idx],
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: paymentMethod,
}
}
return { ok: true }
} catch (e) {
error.value = e?.message || 'Erro ao marcar como pago.'
return { ok: false, error: e?.message }
}
}
// ── cancelRecord ─────────────────────────────────────────────────────────
/**
* @param {string} recordId
*/
async function cancelRecord (recordId) {
error.value = null
try {
const { error: err } = await supabase
.from('financial_records')
.update({ status: 'cancelled', updated_at: new Date().toISOString() })
.eq('id', recordId)
if (err) throw err
// Atualiza localmente sem re-fetch
const idx = records.value.findIndex(r => r.id === recordId)
if (idx !== -1) {
records.value[idx] = { ...records.value[idx], status: 'cancelled' }
}
return { ok: true }
} catch (e) {
error.value = e?.message || 'Erro ao cancelar registro.'
return { ok: false, error: e?.message }
}
}
// ─────────────────────────────────────────────────────────────────────────
return {
// estado
records,
loading,
error,
// computed
summary,
// ações
fetchRecords,
createRecord,
createManualRecord,
markAsPaid,
cancelRecord,
}
}
+16 -2
View File
@@ -1,5 +1,19 @@
// src/composables/useMenuBadges.js /*
// Singleton — contadores para badges do menu (agenda hoje, cadastros e agendamentos recebidos) |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useMenuBadges.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue' import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
+16 -1
View File
@@ -1,4 +1,19 @@
// src/composables/useNotifications.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useNotifications.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { onMounted, onUnmounted } from 'vue' import { onMounted, onUnmounted } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useNotificationStore } from '@/stores/notificationStore' import { useNotificationStore } from '@/stores/notificationStore'
+16 -1
View File
@@ -1,4 +1,19 @@
// src/composables/usePatientLifecycle.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/usePatientLifecycle.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
export function usePatientLifecycle () { export function usePatientLifecycle () {
+16 -3
View File
@@ -1,6 +1,19 @@
// src/composables/usePlatformPermissions.js /*
// |--------------------------------------------------------------------------
// Permissões de PLATAFORMA (globais, não vinculadas a tenant). | Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/usePlatformPermissions.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Distinto do RBAC de tenant (useRoleGuard). // Distinto do RBAC de tenant (useRoleGuard).
// //
// O campo `platform_roles text[]` na tabela `profiles` do Supabase // O campo `platform_roles text[]` na tabela `profiles` do Supabase
+16
View File
@@ -1,3 +1,19 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useRoleGuard.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { computed } from 'vue' import { computed } from 'vue'
import { useTenantStore } from '@/stores/tenantStore' import { useTenantStore } from '@/stores/tenantStore'
+16 -1
View File
@@ -1,4 +1,19 @@
// src/composables/useUserSettingsPersistence.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useUserSettingsPersistence.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue' import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useLayout } from '@/layout/composables/layout' import { useLayout } from '@/layout/composables/layout'
+16 -1
View File
@@ -1,4 +1,19 @@
// src/constants/roles.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/constants/roles.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/** /**
* Roles canônicas do sistema (tenant-level) * Roles canônicas do sistema (tenant-level)
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/AgendaCalendar.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaCalendar.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref, watch, onMounted } from 'vue' import { computed, ref, watch, onMounted } from 'vue'
@@ -151,9 +166,12 @@ onMounted(() => {
<template> <template>
<div class="agenda-calendar-wrap"> <div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading"> <div v-if="loading" class="agenda-calendar-loading">
<ProgressSpinner strokeWidth="3" /> <div class="flex flex-col gap-2 w-full px-2 py-2">
<div class="text-sm mt-2" style="color: var(--text-color-secondary);"> <Skeleton height="2rem" class="mb-1" />
Carregando agenda <div class="grid grid-cols-7 gap-1">
<Skeleton v-for="n in 7" :key="n" height="1.25rem" />
</div>
<Skeleton v-for="n in 8" :key="'row' + n" height="3rem" />
</div> </div>
</div> </div>
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/AgendaClinicCalendar.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaClinicCalendar.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -1,10 +1,26 @@
<!-- src/features/agenda/components/AgendaClinicMosaic.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaClinicMosaic.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref, watch, nextTick } from 'vue' import { computed, ref, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br' import ptBrLocale from '@fullcalendar/core/locales/pt-br'
@@ -111,7 +127,7 @@ function gotoDate (date) {
} }
function setView (v) { function setView (v) {
const target = v === 'week' ? 'timeGridWeek' : (v === 'month' ? 'dayGridMonth' : 'timeGridDay') const target = v === 'week' ? 'timeGridWeek' : v === 'month' ? 'dayGridMonth' : v === 'list' ? 'listWeek' : 'timeGridDay'
forEachApi(api => api.changeView(target)) forEachApi(api => api.changeView(target))
} }
function setMode () {} function setMode () {}
@@ -241,7 +257,7 @@ function emitDebug (col) {
function buildFcOptions (ownerId) { function buildFcOptions (ownerId) {
const base = { const base = {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin], plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale, locale: ptBrLocale,
timeZone: props.timezone, timeZone: props.timezone,
@@ -269,6 +285,12 @@ function buildFcOptions (ownerId) {
businessHours: props.businessHours, businessHours: props.businessHours,
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' },
},
},
height: 'auto', height: 'auto',
expandRows: true, expandRows: true,
allDaySlot: false, allDaySlot: false,
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/AgendaEventDialog.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaEventDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<Dialog <Dialog
v-model:visible="visible" v-model:visible="visible"
@@ -457,7 +472,7 @@
:style="{ background: `#${selectedCommitment.bg_color}20`, color: `#${selectedCommitment.bg_color}`, borderColor: `#${selectedCommitment.bg_color}40` }" :style="{ background: `#${selectedCommitment.bg_color}20`, color: `#${selectedCommitment.bg_color}`, borderColor: `#${selectedCommitment.bg_color}40` }"
>{{ selectedCommitmentName }}</span> >{{ selectedCommitmentName }}</span>
<Tag v-else :value="selectedCommitmentName" severity="info" /> <Tag v-else :value="selectedCommitmentName" severity="info" />
<Tag v-if="isSessionEvent" :value="labelStatusSessao(form.status)" :severity="statusSeverity(form.status)" /> <Tag v-if="isSessionEvent" :value="labelStatusSessao(form.status)" :severity="statusSeverity(form.status)" :class="statusExtraClass(form.status)" />
</div> </div>
<div class="summary-row"> <div class="summary-row">
@@ -713,6 +728,13 @@
</FloatLabel> </FloatLabel>
</div> </div>
<!-- COBRANÇA DA SESSÃO -->
<AgendaEventoFinanceiroPanel
v-if="isSessionEvent && isEdit && eventRow?.id"
:evento="eventRow"
class="mb-3"
/>
<!-- Opção de recorrência para sessão SEM série (criação ou avulsa) --> <!-- Opção de recorrência para sessão SEM série (criação ou avulsa) -->
<template v-if="!hasSerie"> <template v-if="!hasSerie">
<div class="side-card__title mb-2">Frequência</div> <div class="side-card__title mb-2">Frequência</div>
@@ -1147,8 +1169,10 @@ import InputNumber from 'primevue/inputnumber'
import RadioButton from 'primevue/radiobutton' import RadioButton from 'primevue/radiobutton'
import Message from 'primevue/message' import Message from 'primevue/message'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue' import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue'
import { useServices } from '@/features/agenda/composables/useServices' import { useServices } from '@/features/agenda/composables/useServices'
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices' import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts' import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
@@ -1193,8 +1217,9 @@ const props = defineProps({
newPatientRoute: { type: String, default: '' }, newPatientRoute: { type: String, default: '' },
}) })
const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence']) const emit = defineEmits(['update:modelValue', 'save', 'delete', 'updateSeriesEvent', 'editSeriesOccurrence', 'updated'])
const confirm = useConfirm() const confirm = useConfirm()
const toast = useToast()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -1205,6 +1230,7 @@ const visible = computed({
const step = ref(1) const step = ref(1)
const isEdit = computed(() => !!props.eventRow?.id || !!props.eventRow?.is_occurrence) const isEdit = computed(() => !!props.eventRow?.id || !!props.eventRow?.is_occurrence)
const allowBack = computed(() => !props.lockCommitment && !props.presetCommitmentId) const allowBack = computed(() => !props.lockCommitment && !props.presetCommitmentId)
// série // série
@@ -1405,6 +1431,48 @@ function isNativeSession (c) {
const form = ref(resetForm()) const form = ref(resetForm())
// ConfirmDialog para status sensíveis (cancelado / remarcar)
const _prevStatus = ref(null)
const _skipStatusWatch = ref(false)
watch(() => form.value?.status, async (newVal, oldVal) => {
if (_skipStatusWatch.value) return
if (!isEdit.value || !form.value?.id) return
if (newVal !== 'cancelado' && newVal !== 'remarcar') return
_prevStatus.value = oldVal
const isCancelar = newVal === 'cancelado'
confirm.require({
header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão',
message: isCancelar
? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.'
: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
icon: isCancelar ? 'pi pi-times-circle' : 'pi pi-refresh',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Não',
acceptSeverity: isCancelar ? 'danger' : 'warn',
accept: async () => {
try {
const { data, error } = await supabase
.from('agenda_eventos')
.update({ status: newVal })
.eq('id', form.value.id)
.select()
.single()
if (error) throw error
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 })
emit('updated', data)
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 })
form.value.status = _prevStatus.value
}
},
reject: () => {
form.value.status = _prevStatus.value
},
})
})
// Precificação / Serviços // Precificação / Serviços
const { services, getDefaultPrice, load: loadServices } = useServices() const { services, getDefaultPrice, load: loadServices } = useServices()
const { loadItems: _csLoadItems, saveItems: saveCommitmentItems, loadItemsOrTemplate: _csLoadItemsOrTemplate } = useCommitmentServices() const { loadItems: _csLoadItems, saveItems: saveCommitmentItems, loadItemsOrTemplate: _csLoadItemsOrTemplate } = useCommitmentServices()
@@ -1818,7 +1886,10 @@ watch(
console.log('[AgendaEventDialog] abriu — eventRow:', JSON.parse(JSON.stringify(props.eventRow || {}))) console.log('[AgendaEventDialog] abriu — eventRow:', JSON.parse(JSON.stringify(props.eventRow || {})))
console.log('[AgendaEventDialog] isEdit:', isEdit.value, 'hasSerie:', hasSerie.value) console.log('[AgendaEventDialog] isEdit:', isEdit.value, 'hasSerie:', hasSerie.value)
_skipStatusWatch.value = true
form.value = resetForm() form.value = resetForm()
await nextTick()
_skipStatusWatch.value = false
samePatientConflict.value = null samePatientConflict.value = null
recorrenciaType.value = 'avulsa' recorrenciaType.value = 'avulsa'
diasSelecionados.value = [] diasSelecionados.value = []
@@ -2760,21 +2831,28 @@ const googleCalendarUrl = computed(() => {
}) })
function labelStatusSessao (v) { function labelStatusSessao (v) {
const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado' } const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcar: 'Remarcar' }
return map[v] || '—' return map[v] || '—'
} }
function statusSeverity (v) { function statusSeverity (v) {
if (v === 'agendado') return 'success' if (v === 'agendado') return 'info'
if (v === 'realizado') return 'secondary' if (v === 'realizado') return 'success'
if (v === 'faltou') return 'danger' if (v === 'faltou') return 'warn'
if (v === 'cancelado') return 'danger' if (v === 'cancelado') return 'danger'
if (v === 'remarcar') return 'secondary' // cor real via classe CSS
return 'secondary' return 'secondary'
} }
function statusExtraClass (v) {
return v === 'remarcar' ? 'tag-remarcar' : ''
}
</script> </script>
<style scoped> <style scoped>
.agenda-event-composer :deep(.p-dialog-content) { padding: .75rem; } .agenda-event-composer :deep(.p-dialog-content) { padding: .75rem; }
/* ── tag: remarcar (roxo — sem severity nativo no PrimeVue) ─ */
:deep(.tag-remarcar) { background: #a855f7 !important; color: #fff !important; }
/* ── header dot ─────────────────────────────────── */ /* ── header dot ─────────────────────────────────── */
.header-dot { .header-dot {
width: 10px; height: 10px; border-radius: 50%; width: 10px; height: 10px; border-radius: 50%;
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/AgendaRightPanel.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaRightPanel.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
const props = defineProps({ const props = defineProps({
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaToolbar.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/BloqueioDialog.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/BloqueioDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
@@ -0,0 +1,16 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/ConflictBanner.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/DeterminedCommitmentDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<Dialog <Dialog
v-model:visible="visible" v-model:visible="visible"
@@ -0,0 +1,16 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/PreviewTimeline.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/ProximosFeriadosCard.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/ProximosFeriadosCard.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -223,8 +238,8 @@ function fmtDate (iso) {
<!-- Lista --> <!-- Lista -->
<div class="px-4 py-3"> <div class="px-4 py-3">
<div v-if="loading" class="flex justify-center py-3"> <div v-if="loading" class="flex flex-col gap-2 py-1">
<i class="pi pi-spinner pi-spin opacity-40" /> <Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
</div> </div>
<div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1"> <div v-else-if="!feriadosMes.length" class="text-sm text-[var(--text-color-secondary)] py-1">
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/AgendaToolbar.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/cards/AgendaNextSessionsCardList.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
@@ -1,4 +1,19 @@
<!-- src/features/agenda/components/cards/AgendaPulseCardGrid.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/cards/AgendaPulseCardGrid.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
@@ -1,5 +1,20 @@
<!-- src/features/agenda/components/dev/AgendaDevDocs.vue <!--
Documentação técnica da Agenda exibida apenas em modo suporte/dev. |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/dev/AgendaDevDocs.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<!-- Documentação técnica da Agenda exibida apenas em modo suporte/dev.
Acessível via SupportDebugBanner botão "Docs". --> Acessível via SupportDebugBanner botão "Docs". -->
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
@@ -1,4 +1,19 @@
// src/features/agenda/composables/useAgendaClinicEvents.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaClinicEvents.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue' import { ref } from 'vue'
import { import {
listClinicEvents, listClinicEvents,
@@ -1,4 +1,19 @@
// src/features/agenda/composables/useAgendaClinicStaff.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaClinicStaff.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue' import { ref } from 'vue'
import { listTenantStaff } from '../services/agendaRepository' import { listTenantStaff } from '../services/agendaRepository'
@@ -1,3 +1,19 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaEvents.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/** /**
* useAgendaEvents.js * useAgendaEvents.js
* src/features/agenda/composables/useAgendaEvents.js * src/features/agenda/composables/useAgendaEvents.js
@@ -0,0 +1,16 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaLimits.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@@ -0,0 +1,16 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaPermissions.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@@ -0,0 +1,16 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaQuery.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@@ -1,4 +1,19 @@
// src/features/agenda/composables/useAgendaSettings.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useAgendaSettings.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref } from 'vue' import { ref } from 'vue'
import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository' import { getMyAgendaSettings, getMyWorkSchedule } from '../services/agendaRepository'
@@ -1,5 +1,19 @@
// src/features/agenda/composables/useCommitmentServices.js /*
// |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useCommitmentServices.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// CRUD de commitment_services — itens de serviço vinculados a um evento. // CRUD de commitment_services — itens de serviço vinculados a um evento.
// CRUD de recurrence_rule_services — template de serviços de uma regra de recorrência. // CRUD de recurrence_rule_services — template de serviços de uma regra de recorrência.
// //
@@ -1,3 +1,19 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useDeterminedCommitments.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
@@ -1,5 +1,19 @@
// src/features/agenda/composables/useFinancialExceptions.js /*
// |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useFinancialExceptions.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// CRUD sobre a tabela public.financial_exceptions. // CRUD sobre a tabela public.financial_exceptions.
// //
// Interface pública: // Interface pública:
@@ -1,5 +1,19 @@
// src/features/agenda/composables/useInsurancePlans.js /*
// |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useInsurancePlans.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Interface pública: // Interface pública:
// plans ref([]) todos os planos do owner (ativos e inativos) // plans ref([]) todos os planos do owner (ativos e inativos)
// loading ref(false) // loading ref(false)
@@ -1,5 +1,19 @@
// src/features/agenda/composables/usePatientDiscounts.js /*
// |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/usePatientDiscounts.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// CRUD completo sobre a tabela public.patient_discounts. // CRUD completo sobre a tabela public.patient_discounts.
// //
// Interface pública: // Interface pública:
@@ -1,5 +1,19 @@
// src/features/agenda/composables/useProfessionalPricing.js /*
// |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useProfessionalPricing.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Carrega a tabela professional_pricing do owner logado e expõe // Carrega a tabela professional_pricing do owner logado e expõe
// getPriceFor(commitmentId) → number | null // getPriceFor(commitmentId) → number | null
// //
@@ -1,3 +1,19 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useRecurrence.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
/** /**
* useRecurrence.js * useRecurrence.js
* src/features/agenda/composables/useRecurrence.js * src/features/agenda/composables/useRecurrence.js
+16 -2
View File
@@ -1,5 +1,19 @@
// src/features/agenda/composables/useServices.js /*
// |--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/composables/useServices.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Interface pública: // Interface pública:
// services ref([]) todos os serviços do owner (ativos e inativos) // services ref([]) todos os serviços do owner (ativos e inativos)
// loading ref(false) // loading ref(false)
+72 -12
View File
@@ -1,6 +1,20 @@
<!-- src/views/pages/agenda/AgendaClinicaPage.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/pages/AgendaClinicaPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<Toast />
<ConfirmDialog /> <ConfirmDialog />
<!-- Sentinel --> <!-- Sentinel -->
@@ -107,7 +121,7 @@
:slotMinTime="slotMinTime" :slotMinTime="slotMinTime"
:slotMaxTime="slotMaxTime" :slotMaxTime="slotMaxTime"
:slotDuration="slotDuration" :slotDuration="slotDuration"
:slotMinHeight="14"
:expandRows="false" :expandRows="false"
:businessHours="businessHours" :businessHours="businessHours"
:staff="staffCols" :staff="staffCols"
@@ -192,7 +206,7 @@
</div> </div>
</div> </div>
<Calendar <DatePicker
v-model="miniDate" v-model="miniDate"
inline inline
class="ag-mini-cal" class="ag-mini-cal"
@@ -203,7 +217,7 @@
<span class="mini-day-num">{{ date.day }}</span> <span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" /> <span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template> </template>
</Calendar> </DatePicker>
</div> </div>
<ProximosFeriadosCard <ProximosFeriadosCard
@@ -346,7 +360,7 @@
<!-- Month Picker --> <!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }"> <Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<div class="p-2"> <div class="p-2">
<Calendar <DatePicker
v-model="monthPickerDate" v-model="monthPickerDate"
view="month" view="month"
dateFormat="mm/yy" dateFormat="mm/yy"
@@ -499,7 +513,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import Calendar from 'primevue/calendar' import DatePicker from 'primevue/datepicker'
import AgendaClinicMosaic from '@/features/agenda/components/AgendaClinicMosaic.vue' import AgendaClinicMosaic from '@/features/agenda/components/AgendaClinicMosaic.vue'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue' import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
@@ -621,9 +635,10 @@ const onlySessionsOptions = [
{ label: 'Tudo', value: false } { label: 'Tudo', value: false }
] ]
const viewOptions = [ const viewOptions = [
{ label: 'Dia', value: 'day' }, { label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }, { label: 'Semana', value: 'week' },
{ label: 'Mês', value: 'month' } { label: 'Mês', value: 'month' },
{ label: 'Lista', value: 'list' }
] ]
const timeModeOptions = [ const timeModeOptions = [
{ label: '24h', value: '24' }, { label: '24h', value: '24' },
@@ -2172,12 +2187,41 @@ const workDowSet = computed(() =>
new Set(workRules.value.filter(r => r.ativo).map(r => Number(r.dia_semana))) new Set(workRules.value.filter(r => r.ativo).map(r => Number(r.dia_semana)))
) )
// Mini calendário: set de dias da semana atual
const currentWeekIsoSet = computed(() => {
const now = new Date()
const monday = new Date(now)
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7))
monday.setHours(0, 0, 0, 0)
const set = new Set()
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
set.add(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
}
return set
})
const todayISO = computed(() => {
const n = new Date()
return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,'0')}-${String(n.getDate()).padStart(2,'0')}`
})
// Mini calendário: classes por dia // Mini calendário: classes por dia
function miniDayClass (date) { function miniDayClass (date) {
const iso = `${date.year}-${String(date.month + 1).padStart(2,'0')}-${String(date.day).padStart(2,'0')}` const iso = `${date.year}-${String(date.month + 1).padStart(2,'0')}-${String(date.day).padStart(2,'0')}`
if (miniBlockedDaySet.value.has(iso)) return 'mini-day-blocked'
const dow = new Date(date.year, date.month, date.day).getDay() const dow = new Date(date.year, date.month, date.day).getDay()
return workDowSet.value.has(dow) ? 'mini-day-work' : 'mini-day-off' const classes = []
if (currentWeekIsoSet.value.has(iso)) {
classes.push('mini-week-hl')
if (dow === 1) classes.push('mini-week-hl--start')
else if (dow === 0) classes.push('mini-week-hl--end')
else classes.push('mini-week-hl--mid')
}
if (iso === todayISO.value) classes.push('mini-day-today')
if (miniBlockedDaySet.value.has(iso)) classes.push('mini-day-blocked')
else classes.push(workDowSet.value.has(dow) ? 'mini-day-work' : 'mini-day-off')
return classes
} }
// Mini calendário: bolinhas + bloqueios de dia inteiro // Mini calendário: bolinhas + bloqueios de dia inteiro
@@ -2519,12 +2563,28 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
width: 100%; min-width: unset; border-radius: 6px; width: 100%; min-width: unset; border-radius: 6px;
position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 1; position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 1;
} }
:deep(.p-disabled.mini-day-work) { background: color-mix(in srgb, #9ca3af 18%, transparent) !important; opacity: 0.6; }
.mini-day-num { display: block; text-align: center; line-height: 1; } .mini-day-num { display: block; text-align: center; line-height: 1; }
.mini-day-dot { .mini-day-dot {
position: absolute; bottom: 2px; right: 2px; position: absolute; bottom: 2px; right: 2px;
width: 4px; height: 4px; border-radius: 50%; width: 4px; height: 4px; border-radius: 50%;
background: var(--primary-color, #6366f1); background: var(--primary-color, #6366f1);
} }
/* Semana atual — faixa de fundo contínua seg→dom */
:deep(.mini-week-hl) { background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent) !important; border-radius: 0 !important; }
:deep(.mini-week-hl--start) { border-radius: 6px 0 0 6px !important; }
:deep(.mini-week-hl--end) { border-radius: 0 6px 6px 0 !important; }
/* Hoje — cartão com borda + sombra */
:deep(.mini-day-today) {
background: color-mix(in srgb, var(--primary-color, #6366f1) 80%, #00000000) !important;
border: 1px solid var(--surface-border) !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.06) !important;
border-radius: 6px !important;
color: #ffffff !important;
font-weight: 600 !important;
}
</style> </style>
<style> <style>
@@ -1,4 +1,19 @@
<!-- src/features/agenda/pages/AgendaRecorrenciasPage.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/pages/AgendaRecorrenciasPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
@@ -303,7 +318,6 @@ onMounted(init)
</script> </script>
<template> <template>
<Toast />
<!-- Header --> <!-- Header -->
<div class="rr-page mx-3 md:mx-5"> <div class="rr-page mx-3 md:mx-5">
+120 -29
View File
@@ -1,6 +1,20 @@
<!-- src/views/pages/agenda/AgendaTerapeutaPage.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/pages/AgendaTerapeutaPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<Toast />
<ConfirmDialog /> <ConfirmDialog />
<!-- AGENDA TERAPEUTA Layout 3 colunas --> <!-- AGENDA TERAPEUTA Layout 3 colunas -->
@@ -117,7 +131,7 @@
<Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" /> <Button icon="pi pi-chevron-right" severity="secondary" text class="h-7 w-7 rounded-full" @click="miniNextMonth" />
</div> </div>
</div> </div>
<Calendar <DatePicker
v-model="miniDate" v-model="miniDate"
inline inline
class="w-full" class="w-full"
@@ -128,7 +142,7 @@
<span class="mini-day-num">{{ date.day }}</span> <span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" /> <span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template> </template>
</Calendar> </DatePicker>
</div> </div>
<div v-if="jornadaHoje" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3"> <div v-if="jornadaHoje" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -140,6 +154,8 @@
<ProximosFeriadosCard :ownerId="ownerId" :tenantId="clinicTenantId" :workRules="workRules" @bloqueado="refetch" /> <ProximosFeriadosCard :ownerId="ownerId" :tenantId="clinicTenantId" :workRules="workRules" @bloqueado="refetch" />
<LoadedPhraseBlock v-if="eventsHasLoaded" />
<!-- Divisor --> <!-- Divisor -->
<div class="border-t border-[var(--surface-border)] my-1" /> <div class="border-t border-[var(--surface-border)] my-1" />
@@ -149,10 +165,15 @@
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-chart-bar" />Hoje</span> <span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-chart-bar" />Hoje</span>
</div> </div>
<div class="grid grid-cols-4 gap-2"> <div class="grid grid-cols-4 gap-2">
<div class="flex flex-col gap-0.5 p-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] text-center" v-for="s in todayStats" :key="s.label"> <template v-if="eventsLoading">
<div class="text-[1.25rem] font-bold leading-none text-[var(--text-color)]" :class="{ 'text-green-500': s.cls === 'ag-stat--ok', 'text-red-500': s.cls === 'ag-stat--warn' }">{{ s.value }}</div> <Skeleton v-for="n in 4" :key="n" height="3rem" class="rounded-md" />
<div class="text-[0.65rem] font-semibold uppercase tracking-[0.04em] text-[var(--text-color-secondary)] opacity-70">{{ s.label }}</div> </template>
</div> <template v-else>
<div class="flex flex-col gap-0.5 p-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] text-center" v-for="s in todayStats" :key="s.label">
<div class="text-[1.25rem] font-bold leading-none text-[var(--text-color)]" :class="{ 'text-green-500': s.cls === 'ag-stat--ok', 'text-red-500': s.cls === 'ag-stat--warn' }">{{ s.value }}</div>
<div class="text-[0.65rem] font-semibold uppercase tracking-[0.04em] text-[var(--text-color-secondary)] opacity-70">{{ s.label }}</div>
</div>
</template>
</div> </div>
</div> </div>
@@ -162,7 +183,12 @@
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-list" />Sessões hoje</span> <span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-list" />Sessões hoje</span>
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold">{{ todayEvents.length }}</span> <span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold">{{ todayEvents.length }}</span>
</div> </div>
<div v-if="!todayEvents.length" class="flex flex-col items-center justify-center gap-2 py-6 text-[var(--text-color-secondary)] text-sm text-center"> <template v-if="eventsLoading">
<div class="flex flex-col gap-1.5 mt-1">
<Skeleton v-for="n in 3" :key="n" height="3.5rem" class="rounded-md" />
</div>
</template>
<div v-else-if="!todayEvents.length" class="flex flex-col items-center justify-center gap-2 py-6 text-[var(--text-color-secondary)] text-sm text-center">
<i class="pi pi-sun text-2xl opacity-20" /> <i class="pi pi-sun text-2xl opacity-20" />
<span>Nenhuma sessão hoje</span> <span>Nenhuma sessão hoje</span>
</div> </div>
@@ -318,7 +344,7 @@
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" /> <Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
</div> </div>
</div> </div>
<Calendar <DatePicker
v-model="miniDate" v-model="miniDate"
inline inline
class="w-full" class="w-full"
@@ -329,7 +355,7 @@
<span class="mini-day-num">{{ date.day }}</span> <span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" /> <span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template> </template>
</Calendar> </DatePicker>
</div> </div>
<div v-if="jornadaHoje" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3"> <div v-if="jornadaHoje" class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -341,6 +367,8 @@
<ProximosFeriadosCard :ownerId="ownerId" :tenantId="clinicTenantId" :workRules="workRules" @bloqueado="refetch" /> <ProximosFeriadosCard :ownerId="ownerId" :tenantId="clinicTenantId" :workRules="workRules" @bloqueado="refetch" />
<LoadedPhraseBlock v-if="eventsHasLoaded" />
</div> </div>
<!-- Botão toggle painel ( mobile <xl) --> <!-- Botão toggle painel ( mobile <xl) -->
@@ -377,7 +405,7 @@
</div> </div>
</div> </div>
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] overflow-hidden"> <div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] overflow-hidden shadow-sm agenda-altura">
<div v-if="calendarView === 'day' && miniBlockedDaySet.has(currentDateISO)" class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-red-700 bg-red-400/10 border-b border-red-400/25"> <div v-if="calendarView === 'day' && miniBlockedDaySet.has(currentDateISO)" class="flex items-center gap-2 px-4 py-2 text-sm font-semibold text-red-700 bg-red-400/10 border-b border-red-400/25">
<i class="pi pi-lock text-xs" /> Dia bloqueado sessões não permitidas <i class="pi pi-lock text-xs" /> Dia bloqueado sessões não permitidas
</div> </div>
@@ -396,10 +424,15 @@
<span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-chart-bar" />Hoje</span> <span class="flex items-center gap-1.5 text-[1rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65"><i class="pi pi-chart-bar" />Hoje</span>
</div> </div>
<div class="grid grid-cols-4 gap-2"> <div class="grid grid-cols-4 gap-2">
<div class="flex flex-col gap-0.5 p-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] text-center" v-for="s in todayStats" :key="s.label"> <template v-if="eventsLoading">
<div class="text-[1.25rem] font-bold leading-none text-[var(--text-color)]" :class="{ 'text-green-500': s.cls === 'ag-stat--ok', 'text-red-500': s.cls === 'ag-stat--warn' }">{{ s.value }}</div> <Skeleton v-for="n in 4" :key="n" height="3rem" class="rounded-md" />
<div class="text-[0.65rem] font-semibold uppercase tracking-[0.04em] text-[var(--text-color-secondary)] opacity-70">{{ s.label }}</div> </template>
</div> <template v-else>
<div class="flex flex-col gap-0.5 p-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] text-center" v-for="s in todayStats" :key="s.label">
<div class="text-[1.25rem] font-bold leading-none text-[var(--text-color)]" :class="{ 'text-green-500': s.cls === 'ag-stat--ok', 'text-red-500': s.cls === 'ag-stat--warn' }">{{ s.value }}</div>
<div class="text-[0.65rem] font-semibold uppercase tracking-[0.04em] text-[var(--text-color-secondary)] opacity-70">{{ s.label }}</div>
</div>
</template>
</div> </div>
</div> </div>
@@ -410,7 +443,12 @@
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold">{{ todayEvents.length }}</span> <span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold">{{ todayEvents.length }}</span>
</div> </div>
<div v-if="!todayEvents.length" class="flex flex-col items-center justify-center gap-2 py-6 text-[var(--text-color-secondary)] text-sm text-center"> <template v-if="eventsLoading">
<div class="flex flex-col gap-1.5 mt-1">
<Skeleton v-for="n in 3" :key="n" height="3.5rem" class="rounded-md" />
</div>
</template>
<div v-else-if="!todayEvents.length" class="flex flex-col items-center justify-center gap-2 py-6 text-[var(--text-color-secondary)] text-sm text-center">
<i class="pi pi-sun text-2xl opacity-20" /> <i class="pi pi-sun text-2xl opacity-20" />
<span>Nenhuma sessão hoje</span> <span>Nenhuma sessão hoje</span>
</div> </div>
@@ -670,7 +708,7 @@
<!-- Month Picker --> <!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }"> <Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<div class="p-2"> <div class="p-2">
<Calendar v-model="monthPickerDate" view="month" dateFormat="mm/yy" class="w-full" /> <DatePicker v-model="monthPickerDate" view="month" dateFormat="mm/yy" class="w-full" />
<div class="mt-3 flex justify-end gap-2"> <div class="mt-3 flex justify-end gap-2">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="monthPickerVisible = false" /> <Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="monthPickerVisible = false" />
<Button label="Ir" class="rounded-full" @click="applyMonthPick" /> <Button label="Ir" class="rounded-full" @click="applyMonthPick" />
@@ -939,7 +977,7 @@ import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast' import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm' import { useConfirm } from 'primevue/useconfirm'
import Calendar from 'primevue/calendar' import DatePicker from 'primevue/datepicker'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
@@ -1043,7 +1081,9 @@ const commitmentOptionsNormalized = computed(() => {
// settings + events // settings + events
// ----------------------------- // -----------------------------
const { error: settingsError, settings, workRules, load: loadSettings } = useAgendaSettings() const { error: settingsError, settings, workRules, load: loadSettings } = useAgendaSettings()
const { error: eventsError, rows, loadMyRange, create, update, remove } = useAgendaEvents() const { error: eventsError, rows, loading: eventsLoading, loadMyRange, create, update, remove } = useAgendaEvents()
const eventsHasLoaded = ref(false)
watch(eventsLoading, (val) => { if (!val) eventsHasLoaded.value = true })
const { const {
loadAndExpand, loadAndExpand,
@@ -1096,9 +1136,10 @@ const onlySessionsOptions = [
{ label: 'Tudo', value: false } { label: 'Tudo', value: false }
] ]
const viewOptions = [ const viewOptions = [
{ label: 'Dia', value: 'day' }, { label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' }, { label: 'Semana', value: 'week' },
{ label: 'Mês', value: 'month' } { label: 'Mês', value: 'month' },
{ label: 'Lista', value: 'list' }
] ]
const timeModeOptions = [ const timeModeOptions = [
{ label: '24h', value: '24' }, { label: '24h', value: '24' },
@@ -1327,8 +1368,9 @@ const slotMaxTime = computed(() => {
}) })
const fcViewName = computed(() => { const fcViewName = computed(() => {
if (calendarView.value === 'day') return 'timeGridDay' if (calendarView.value === 'day') return 'timeGridDay'
if (calendarView.value === 'week') return 'timeGridWeek' if (calendarView.value === 'week') return 'timeGridWeek'
if (calendarView.value === 'list') return 'listWeek'
return 'dayGridMonth' return 'dayGridMonth'
}) })
@@ -1581,7 +1623,7 @@ const _initSlotMax = slotMaxTime.value
// NÃO incluímos 'events' no fcOptions evita que o Vue FC adapter gerencie // NÃO incluímos 'events' no fcOptions evita que o Vue FC adapter gerencie
// a fonte e conflite com o watch que usa getEventSources + addEventSource. // a fonte e conflite com o watch que usa getEventSources + addEventSource.
const fcOptions = computed(() => ({ const fcOptions = computed(() => ({
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin], plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale, locale: ptBrLocale,
timeZone: timezone.value, timeZone: timezone.value,
@@ -1602,12 +1644,16 @@ const fcOptions = computed(() => ({
slotLabelContent, slotLabelContent,
expandRows: false, expandRows: false,
height: 'auto', height: 'auto',
slotMinHeight: 14,
dayMaxEvents: true, dayMaxEvents: true,
weekends: true, weekends: true,
eventMinHeight: 14, eventMinHeight: 14,
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' },
},
},
businessHours: businessHours.value, businessHours: businessHours.value,
datesSet: async (arg) => { datesSet: async (arg) => {
@@ -2052,13 +2098,42 @@ const currentDateISO = computed(() => {
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}` return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}) })
// Mini calendário: set de dias da semana atual
const currentWeekIsoSet = computed(() => {
const now = new Date()
const monday = new Date(now)
monday.setDate(now.getDate() - ((now.getDay() + 6) % 7))
monday.setHours(0, 0, 0, 0)
const set = new Set()
for (let i = 0; i < 7; i++) {
const d = new Date(monday)
d.setDate(monday.getDate() + i)
set.add(`${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`)
}
return set
})
const todayISO = computed(() => {
const n = new Date()
return `${n.getFullYear()}-${String(n.getMonth()+1).padStart(2,'0')}-${String(n.getDate()).padStart(2,'0')}`
})
// Mini calendário: classes por dia // Mini calendário: classes por dia
// Prioridade: bloqueado total > dia de trabalho > folga // Prioridade: bloqueado total > dia de trabalho > folga
function miniDayClass (date) { function miniDayClass (date) {
const iso = `${date.year}-${String(date.month + 1).padStart(2,'0')}-${String(date.day).padStart(2,'0')}` const iso = `${date.year}-${String(date.month + 1).padStart(2,'0')}-${String(date.day).padStart(2,'0')}`
if (miniBlockedDaySet.value.has(iso)) return 'mini-day-blocked'
const dow = new Date(date.year, date.month, date.day).getDay() const dow = new Date(date.year, date.month, date.day).getDay()
return workDowSet.value.has(dow) ? 'mini-day-work' : 'mini-day-off' const classes = []
if (currentWeekIsoSet.value.has(iso)) {
classes.push('mini-week-hl')
if (dow === 1) classes.push('mini-week-hl--start')
else if (dow === 0) classes.push('mini-week-hl--end')
else classes.push('mini-week-hl--mid')
}
if (iso === todayISO.value) classes.push('mini-day-today')
if (miniBlockedDaySet.value.has(iso)) classes.push('mini-day-blocked')
else classes.push(workDowSet.value.has(dow) ? 'mini-day-work' : 'mini-day-off')
return classes
} }
// Mini calendário: bolinhas de compromissos + set de dias bloqueados // Mini calendário: bolinhas de compromissos + set de dias bloqueados
@@ -3232,6 +3307,22 @@ onMounted(async () => {
:deep(.mini-day-blocked) { background: color-mix(in srgb,#ef4444 20%,transparent) !important; border-radius: 4px; } :deep(.mini-day-blocked) { background: color-mix(in srgb,#ef4444 20%,transparent) !important; border-radius: 4px; }
:deep(.mini-day-work) { } :deep(.mini-day-work) { }
:deep(.mini-day-off) { opacity: 0.45; } :deep(.mini-day-off) { opacity: 0.45; }
:deep(.p-disabled.mini-day-work) { background: color-mix(in srgb, #9ca3af 18%, transparent) !important; opacity: 0.6; }
/* Semana atual — faixa de fundo contínua seg→dom */
:deep(.mini-week-hl) { background: color-mix(in srgb, var(--primary-color, #6366f1) 12%, transparent) !important; border-radius: 0 !important; }
:deep(.mini-week-hl--start) { border-radius: 6px 0 0 6px !important; }
:deep(.mini-week-hl--end) { border-radius: 0 6px 6px 0 !important; }
/* Hoje — cartão com borda + sombra */
:deep(.mini-day-today) {
background: color-mix(in srgb, var(--primary-color, #6366f1) 80%, #00000000) !important;
border: 1px solid var(--surface-border) !important;
box-shadow: 0 2px 8px rgba(0,0,0,0.06) !important;
border-radius: 6px !important;
color: #ffffff !important;
font-weight: 600 !important;
}
</style> </style>
<style> <style>
@@ -1,4 +1,19 @@
<!-- src/features/agenda/pages/AgendamentosRecebidosPage.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/pages/AgendamentosRecebidosPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -40,6 +55,7 @@ const statusOpts = [
// Lista // Lista
const solicitacoes = ref([]) const solicitacoes = ref([])
const loading = ref(false) const loading = ref(false)
const hasLoaded = ref(false)
const totalPendentes = ref(0) const totalPendentes = ref(0)
const totalAutorizados = ref(0) const totalAutorizados = ref(0)
@@ -87,6 +103,7 @@ async function load () {
toast.add({ severity: 'error', summary: 'Erro', detail: 'Não foi possível carregar as solicitações.', life: 4000 }) toast.add({ severity: 'error', summary: 'Erro', detail: 'Não foi possível carregar as solicitações.', life: 4000 })
} finally { } finally {
loading.value = false loading.value = false
hasLoaded.value = true
} }
} }
@@ -268,7 +285,6 @@ const emptySub = computed(() => {
</script> </script>
<template> <template>
<Toast />
<!-- Sentinel --> <!-- Sentinel -->
<div class="h-px" /> <div class="h-px" />
@@ -563,6 +579,8 @@ const emptySub = computed(() => {
</div> </div>
</div> </div>
<LoadedPhraseBlock v-if="hasLoaded && !loading" class="mx-3 md:mx-4 mt-3 mb-2" />
<!-- <!--
Dialog: Recusar Dialog: Recusar
--> -->
@@ -1,6 +1,20 @@
<!-- src/features/agenda/pages/CompromissosDeterminados.vue --> <!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/pages/CompromissosDeterminados.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template> <template>
<Toast />
<!-- Sentinel --> <!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" /> <div ref="headerSentinelRef" class="h-px" />
@@ -101,29 +115,37 @@
<!-- <!--
Conteúdo principal Conteúdo principal
--> -->
<div class="px-3 md:px-4 pb-5 flex flex-col gap-3"> <div class="px-3 md:px-4 pb-5 flex gap-4 items-start">
<!-- Coluna principal -->
<div class="flex flex-col gap-3 flex-1 min-w-0">
<!-- Stats row --> <!-- Stats row -->
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<div <template v-if="loading">
v-for="s in stats" <Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
:key="s.label" </template>
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[80px] flex-1" <template v-else>
>
<div <div
class="text-[1.35rem] font-bold leading-none" v-for="s in stats"
:class="{ :key="s.label"
'text-green-500': s.cls === 'stat-ok', class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[80px] flex-1"
'text-red-500': s.cls === 'stat-warn', >
'text-[var(--text-color)]': !s.cls, <div
}" class="text-[1.35rem] font-bold leading-none"
>{{ s.value }}</div> :class="{
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div> 'text-green-500': s.cls === 'stat-ok',
</div> 'text-red-500': s.cls === 'stat-warn',
'text-[var(--text-color)]': !s.cls,
}"
>{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
</template>
</div> </div>
<!-- Cards grid --> <!-- Cards grid (coluna direita visível até xl) -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-6"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:hidden">
<div <div
v-for="c in cardsCommitments" v-for="c in cardsCommitments"
:key="c.id" :key="c.id"
@@ -241,7 +263,8 @@
:loading="loading" :loading="loading"
:paginator="visibleCommitments.length > 10" :paginator="visibleCommitments.length > 10"
:rows="10" :rows="10"
responsiveLayout="scroll" scrollable
scrollHeight="400px"
class="p-datatable-sm cmpr-datatable" class="p-datatable-sm cmpr-datatable"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''" :rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
:filters="filters" :filters="filters"
@@ -324,6 +347,62 @@
</DataTable> </DataTable>
</div> </div>
<LoadedPhraseBlock v-if="hasLoaded" />
</div><!-- fim coluna principal -->
<!-- Coluna direita: cards de tipos ( xl+) -->
<div class="hidden xl:flex flex-col gap-2 w-64 flex-shrink-0">
<div class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60 px-1 mb-1">
Tipos de compromisso
</div>
<template v-if="loading">
<Skeleton v-for="n in 5" :key="n" height="4.5rem" class="rounded-md" />
</template>
<template v-else>
<div
v-for="c in cardsCommitments"
:key="c.id"
class="flex flex-col gap-1.5 px-3 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]"
>
<!-- Nome com dot de cor -->
<div class="flex items-center gap-2 min-w-0">
<div
class="w-2 h-2 rounded-full flex-shrink-0 bg-[var(--surface-border)]"
:style="c.bg_color ? { background: `#${c.bg_color}` } : {}"
/>
<span
class="text-[0.85rem] font-semibold truncate"
:style="c.bg_color ? { color: `#${c.bg_color}` } : {}"
>{{ c.name }}</span>
</div>
<!-- Descrição -->
<div class="text-[0.72rem] text-[var(--text-color-secondary)] line-clamp-1 pl-4">{{ c.description || '—' }}</div>
<!-- Tempo total -->
<div
class="flex items-center gap-1 pl-4 text-[0.72rem] text-[var(--text-color-secondary)]"
v-tooltip.bottom="getTotalMinutes(c.id) === 0 ? 'Você ainda não tem nenhum evento desse tipo agendado e concluído para contabilizar tempo/relatórios' : null"
>
<i class="pi pi-clock text-[0.65rem]" />
<span>Tempo total: {{ formatMinutes(getTotalMinutes(c.id)) }}</span>
</div>
</div>
<!-- Empty state coluna direita -->
<div
v-if="!cardsCommitments.length"
class="flex flex-col items-center gap-2 p-4 rounded-md border border-dashed border-[var(--surface-border)] text-center text-[var(--text-color-secondary)]"
>
<i class="pi pi-list text-2xl opacity-20" />
<div class="text-xs">Nenhum compromisso</div>
</div>
</template>
</div>
</div> </div>
<!-- Dialog --> <!-- Dialog -->
@@ -381,8 +460,9 @@ onMounted(async () => {
onBeforeUnmount(() => { _observer?.disconnect() }) onBeforeUnmount(() => { _observer?.disconnect() })
const loading = ref(false) const loading = ref(false)
const saving = ref(false) const hasLoaded = ref(false)
const saving = ref(false)
const filters = reactive({ const filters = reactive({
global: { value: null, matchMode: 'contains' }, global: { value: null, matchMode: 'contains' },
@@ -503,6 +583,7 @@ async function fetchAll () {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 }) toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4500 })
} finally { } finally {
loading.value = false loading.value = false
hasLoaded.value = true
} }
} }
@@ -0,0 +1,16 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/services/agenda.service.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
@@ -1,4 +1,19 @@
// src/features/agenda/services/agendaClinicRepository.js /*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/services/agendaClinicRepository.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
function assertValidTenantId (tenantId) { function assertValidTenantId (tenantId) {

Some files were not shown because too many files have changed in this diff Show More