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
@@ -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>
import { computed, ref, watch, onMounted } from 'vue'
@@ -151,9 +166,12 @@ onMounted(() => {
<template>
<div class="agenda-calendar-wrap">
<div v-if="loading" class="agenda-calendar-loading">
<ProgressSpinner strokeWidth="3" />
<div class="text-sm mt-2" style="color: var(--text-color-secondary);">
Carregando agenda
<div class="flex flex-col gap-2 w-full px-2 py-2">
<Skeleton height="2rem" class="mb-1" />
<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>
@@ -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>
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>
import { computed, ref, watch, nextTick } from 'vue'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
@@ -111,7 +127,7 @@ function gotoDate (date) {
}
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))
}
function setMode () {}
@@ -241,7 +257,7 @@ function emitDebug (col) {
function buildFcOptions (ownerId) {
const base = {
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: props.timezone,
@@ -269,6 +285,12 @@ function buildFcOptions (ownerId) {
businessHours: props.businessHours,
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' },
},
},
height: 'auto',
expandRows: true,
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>
<Dialog
v-model:visible="visible"
@@ -457,7 +472,7 @@
:style="{ background: `#${selectedCommitment.bg_color}20`, color: `#${selectedCommitment.bg_color}`, borderColor: `#${selectedCommitment.bg_color}40` }"
>{{ selectedCommitmentName }}</span>
<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 class="summary-row">
@@ -713,6 +728,13 @@
</FloatLabel>
</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) -->
<template v-if="!hasSerie">
<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 Message from 'primevue/message'
import { useConfirm } from 'primevue/useconfirm'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue'
import { useServices } from '@/features/agenda/composables/useServices'
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
@@ -1193,8 +1217,9 @@ const props = defineProps({
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 toast = useToast()
const router = useRouter()
const route = useRoute()
@@ -1205,6 +1230,7 @@ const visible = computed({
const step = ref(1)
const isEdit = computed(() => !!props.eventRow?.id || !!props.eventRow?.is_occurrence)
const allowBack = computed(() => !props.lockCommitment && !props.presetCommitmentId)
// ── série ─────────────────────────────────────────────────
@@ -1405,6 +1431,48 @@ function isNativeSession (c) {
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 ─────────────────────────────────────────
const { services, getDefaultPrice, load: loadServices } = useServices()
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] isEdit:', isEdit.value, 'hasSerie:', hasSerie.value)
_skipStatusWatch.value = true
form.value = resetForm()
await nextTick()
_skipStatusWatch.value = false
samePatientConflict.value = null
recorrenciaType.value = 'avulsa'
diasSelecionados.value = []
@@ -2760,21 +2831,28 @@ const googleCalendarUrl = computed(() => {
})
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] || '—'
}
function statusSeverity (v) {
if (v === 'agendado') return 'success'
if (v === 'realizado') return 'secondary'
if (v === 'faltou') return 'danger'
if (v === 'agendado') return 'info'
if (v === 'realizado') return 'success'
if (v === 'faltou') return 'warn'
if (v === 'cancelado') return 'danger'
if (v === 'remarcar') return 'secondary' // cor real via classe CSS
return 'secondary'
}
function statusExtraClass (v) {
return v === 'remarcar' ? 'tag-remarcar' : ''
}
</script>
<style scoped>
.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 {
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>
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>
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>
import { ref, computed, watch } from 'vue'
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>
<Dialog
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>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -223,8 +238,8 @@ function fmtDate (iso) {
<!-- Lista -->
<div class="px-4 py-3">
<div v-if="loading" class="flex justify-center py-3">
<i class="pi pi-spinner pi-spin opacity-40" />
<div v-if="loading" class="flex flex-col gap-2 py-1">
<Skeleton v-for="n in 3" :key="n" height="2rem" class="rounded" />
</div>
<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>
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>
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". -->
<script setup>
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 {
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 { 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
* 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 { 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 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 { 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.
//
// 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:
// plans ref([]) todos os planos do owner (ativos e inativos)
// 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.
//
// 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
// 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
* 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:
// services ref([]) todos os serviços do owner (ativos e inativos)
// 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>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
@@ -107,7 +121,7 @@
:slotMinTime="slotMinTime"
:slotMaxTime="slotMaxTime"
:slotDuration="slotDuration"
:slotMinHeight="14"
:expandRows="false"
:businessHours="businessHours"
:staff="staffCols"
@@ -192,7 +206,7 @@
</div>
</div>
<Calendar
<DatePicker
v-model="miniDate"
inline
class="ag-mini-cal"
@@ -203,7 +217,7 @@
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
</DatePicker>
</div>
<ProximosFeriadosCard
@@ -346,7 +360,7 @@
<!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<div class="p-2">
<Calendar
<DatePicker
v-model="monthPickerDate"
view="month"
dateFormat="mm/yy"
@@ -499,7 +513,7 @@ import { useRouter, useRoute } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Calendar from 'primevue/calendar'
import DatePicker from 'primevue/datepicker'
import AgendaClinicMosaic from '@/features/agenda/components/AgendaClinicMosaic.vue'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
@@ -621,9 +635,10 @@ const onlySessionsOptions = [
{ label: 'Tudo', value: false }
]
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' },
{ label: 'Mês', value: 'month' }
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' },
{ label: 'Mês', value: 'month' },
{ label: 'Lista', value: 'list' }
]
const timeModeOptions = [
{ 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)))
)
// ── 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 ──────────────────────────
function miniDayClass (date) {
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()
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 ──────
@@ -2519,12 +2563,28 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
width: 100%; min-width: unset; border-radius: 6px;
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-dot {
position: absolute; bottom: 2px; right: 2px;
width: 4px; height: 4px; border-radius: 50%;
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>
@@ -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>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
@@ -303,7 +318,6 @@ onMounted(init)
</script>
<template>
<Toast />
<!-- Header -->
<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>
<Toast />
<ConfirmDialog />
<!-- 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" />
</div>
</div>
<Calendar
<DatePicker
v-model="miniDate"
inline
class="w-full"
@@ -128,7 +142,7 @@
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
</DatePicker>
</div>
<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" />
<LoadedPhraseBlock v-if="eventsHasLoaded" />
<!-- Divisor -->
<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>
</div>
<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">
<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 v-if="eventsLoading">
<Skeleton v-for="n in 4" :key="n" height="3rem" class="rounded-md" />
</template>
<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>
@@ -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="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 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" />
<span>Nenhuma sessão hoje</span>
</div>
@@ -318,7 +344,7 @@
<Button icon="pi pi-chevron-right" severity="secondary" outlined class="h-8 w-8 rounded-full" @click="goNext" />
</div>
</div>
<Calendar
<DatePicker
v-model="miniDate"
inline
class="w-full"
@@ -329,7 +355,7 @@
<span class="mini-day-num">{{ date.day }}</span>
<span v-if="hasMiniEvent(date)" class="mini-day-dot" />
</template>
</Calendar>
</DatePicker>
</div>
<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" />
<LoadedPhraseBlock v-if="eventsHasLoaded" />
</div>
<!-- Botão toggle painel ( mobile <xl) -->
@@ -377,7 +405,7 @@
</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">
<i class="pi pi-lock text-xs" /> Dia bloqueado sessões não permitidas
</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>
</div>
<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">
<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 v-if="eventsLoading">
<Skeleton v-for="n in 4" :key="n" height="3rem" class="rounded-md" />
</template>
<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>
@@ -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>
</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" />
<span>Nenhuma sessão hoje</span>
</div>
@@ -670,7 +708,7 @@
<!-- Month Picker -->
<Dialog v-model:visible="monthPickerVisible" modal header="Escolher mês" :style="{ width: '420px' }">
<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">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" @click="monthPickerVisible = false" />
<Button label="Ir" class="rounded-full" @click="applyMonthPick" />
@@ -939,7 +977,7 @@ import { supabase } from '@/lib/supabase/client'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Calendar from 'primevue/calendar'
import DatePicker from 'primevue/datepicker'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
@@ -1043,7 +1081,9 @@ const commitmentOptionsNormalized = computed(() => {
// settings + events
// -----------------------------
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 {
loadAndExpand,
@@ -1096,9 +1136,10 @@ const onlySessionsOptions = [
{ label: 'Tudo', value: false }
]
const viewOptions = [
{ label: 'Dia', value: 'day' },
{ label: 'Dia', value: 'day' },
{ label: 'Semana', value: 'week' },
{ label: 'Mês', value: 'month' }
{ label: 'Mês', value: 'month' },
{ label: 'Lista', value: 'list' }
]
const timeModeOptions = [
{ label: '24h', value: '24' },
@@ -1327,8 +1368,9 @@ const slotMaxTime = computed(() => {
})
const fcViewName = computed(() => {
if (calendarView.value === 'day') return 'timeGridDay'
if (calendarView.value === 'week') return 'timeGridWeek'
if (calendarView.value === 'day') return 'timeGridDay'
if (calendarView.value === 'week') return 'timeGridWeek'
if (calendarView.value === 'list') return 'listWeek'
return 'dayGridMonth'
})
@@ -1581,7 +1623,7 @@ const _initSlotMax = slotMaxTime.value
// 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.
const fcOptions = computed(() => ({
plugins: [timeGridPlugin, dayGridPlugin, interactionPlugin],
plugins: [timeGridPlugin, dayGridPlugin, listPlugin, interactionPlugin],
locale: ptBrLocale,
timeZone: timezone.value,
@@ -1602,12 +1644,16 @@ const fcOptions = computed(() => ({
slotLabelContent,
expandRows: false,
height: 'auto',
slotMinHeight: 14,
dayMaxEvents: true,
weekends: true,
eventMinHeight: 14,
views: {
timeGridDay: {
dayHeaderFormat: { day: 'numeric', month: 'long', year: 'numeric' },
},
},
businessHours: businessHours.value,
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')}`
})
// ── 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 ──────────────────────────
// Prioridade: bloqueado total > dia de trabalho > folga
function miniDayClass (date) {
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()
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 ──
@@ -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-work) { }
: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>
@@ -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>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
@@ -40,6 +55,7 @@ const statusOpts = [
// ── Lista ─────────────────────────────────────────────────
const solicitacoes = ref([])
const loading = ref(false)
const hasLoaded = ref(false)
const totalPendentes = 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 })
} finally {
loading.value = false
hasLoaded.value = true
}
}
@@ -268,7 +285,6 @@ const emptySub = computed(() => {
</script>
<template>
<Toast />
<!-- Sentinel -->
<div class="h-px" />
@@ -563,6 +579,8 @@ const emptySub = computed(() => {
</div>
</div>
<LoadedPhraseBlock v-if="hasLoaded && !loading" class="mx-3 md:mx-4 mt-3 mb-2" />
<!--
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>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -101,29 +115,37 @@
<!--
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 -->
<div class="flex flex-wrap gap-2">
<div
v-for="s in stats"
:key="s.label"
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-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
</template>
<template v-else>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-green-500': s.cls === 'stat-ok',
'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>
v-for="s in stats"
:key="s.label"
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"
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-green-500': s.cls === 'stat-ok',
'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>
<!-- Cards grid -->
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-5 2xl:grid-cols-6">
<!-- 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:hidden">
<div
v-for="c in cardsCommitments"
:key="c.id"
@@ -241,7 +263,8 @@
:loading="loading"
:paginator="visibleCommitments.length > 10"
:rows="10"
responsiveLayout="scroll"
scrollable
scrollHeight="400px"
class="p-datatable-sm cmpr-datatable"
:rowClass="(r) => isRecent(r) ? 'row-new-highlight' : ''"
:filters="filters"
@@ -324,6 +347,62 @@
</DataTable>
</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>
<!-- Dialog -->
@@ -381,8 +460,9 @@ onMounted(async () => {
onBeforeUnmount(() => { _observer?.disconnect() })
const loading = ref(false)
const saving = ref(false)
const loading = ref(false)
const hasLoaded = ref(false)
const saving = ref(false)
const filters = reactive({
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 })
} finally {
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'
function assertValidTenantId (tenantId) {
+26 -9
View File
@@ -1,5 +1,19 @@
// src/features/agenda/services/agendaMappers.js
//
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/services/agendaMappers.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Suporta dois tipos de linha:
// 1. Evento real (agenda_eventos do banco) — is_occurrence = false/undefined
// 2. Ocorrência virtual (gerada por useRecurrence) — is_occurrence = true
@@ -117,7 +131,9 @@ function _mapRow (r) {
is_exception: r.is_exception ?? (exceptionType != null),
// financeiro
price: r.price ?? null,
price: r.price ?? null,
billed: r.billed ?? false,
billing_contract_id: r.billing_contract_id ?? null,
insurance_plan_id: r.insurance_plan_id ?? null,
insurance_guide_number: r.insurance_guide_number ?? null,
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
@@ -224,11 +240,12 @@ export function tituloFallback (tipo) {
function _statusBgColor (status) {
const map = {
realizado: '#6b7280',
faltou: '#ef4444',
cancelado: '#f97316',
bloqueado: '#6b7280',
remarcado: '#a855f7',
agendado: '#3b82f6', // azul
realizado: '#22c55e', // verde
faltou: '#f97316', // laranja
cancelado: '#ef4444', // vermelho
remarcar: '#a855f7', // roxo
bloqueado: '#6b7280', // cinza
}
return map[status] ?? null
}
@@ -237,8 +254,8 @@ function _statusIcon (status, isOccurrence, hasSerie) {
if (status === 'realizado') return '✓ '
if (status === 'faltou') return '✗ '
if (status === 'cancelado') return '∅ '
if (status === 'remarcar') return '↺ '
if (status === 'bloqueado') return '⊘ '
if (status === 'remarcado') return '↺ '
if (hasSerie || isOccurrence) return '↻ '
return ''
}
@@ -1,4 +1,19 @@
// src/features/agenda/services/agendaRepository.js
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/services/agendaRepository.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/clinic/components/ModuleRow.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
@@ -0,0 +1,487 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/financeiro/pages/FinanceiroDashboardPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
// helpers
const router = useRouter()
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)
}
async function getUid () {
const { data } = await supabase.auth.getUser()
return data?.user?.id ?? null
}
// meses para o gráfico
function getLast6Months () {
const months = []
const now = new Date()
for (let i = 5; i >= 0; i--) {
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
months.push({ year: d.getFullYear(), month: d.getMonth() + 1, label: d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }) })
}
return months
}
// estado: cards de resumo
const summaryLoading = ref(true)
const totalRecebido = ref(0)
const totalPendente = ref(0)
const totalVencido = ref(0)
const totalDespesas = ref(0)
async function loadSummary (uid) {
summaryLoading.value = true
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
try {
// Receitas e despesas pagas no mês via RPC
const { data: rpc } = await supabase.rpc('get_financial_summary', {
p_owner_id: uid, p_year: year, p_month: month,
})
const s = Array.isArray(rpc) ? rpc[0] : rpc
totalRecebido.value = Number(s?.total_receitas ?? 0)
totalDespesas.value = Number(s?.total_despesas ?? 0)
// Pending e overdue separados (sem filtro de mês)
const { data: pendRows } = await supabase
.from('financial_records')
.select('status, final_amount')
.eq('owner_id', uid)
.is('deleted_at', null)
.in('status', ['pending', 'overdue'])
let pen = 0, ove = 0
for (const r of pendRows ?? []) {
if (r.status === 'pending') pen += Number(r.final_amount ?? 0)
else ove += Number(r.final_amount ?? 0)
}
totalPendente.value = pen
totalVencido.value = ove
} finally {
summaryLoading.value = false
}
}
// estado: gráfico 6 meses
const chartLoading = ref(true)
const chartData = ref(null)
const chartOptions = ref({
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'top' },
tooltip: {
callbacks: {
label: ctx => ` ${_brl.format(ctx.parsed.y)}`,
},
},
},
scales: {
y: {
ticks: { callback: v => _brl.format(v) },
beginAtZero: true,
},
},
})
async function loadChart (uid) {
chartLoading.value = true
const months = getLast6Months()
try {
const results = await Promise.all(
months.map(m =>
supabase.rpc('get_financial_summary', { p_owner_id: uid, p_year: m.year, p_month: m.month })
)
)
const receitas = results.map(r => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0))
const despesas = results.map(r => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0))
chartData.value = {
labels: months.map(m => m.label),
datasets: [
{ label: 'Receita', data: receitas, backgroundColor: '#22c55e', borderRadius: 4 },
{ label: 'Despesa', data: despesas, backgroundColor: '#ef4444', borderRadius: 4 },
],
}
} finally {
chartLoading.value = false
}
}
// estado: fluxo de caixa
const cashflowLoading = ref(true)
const cashflowRows = ref([])
const cashflowError = ref(false)
async function loadCashflow () {
cashflowLoading.value = true
cashflowError.value = false
try {
const { data, error } = await supabase
.from('v_cashflow_projection')
.select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros')
.order('mes', { ascending: true })
if (error) throw error
cashflowRows.value = data ?? []
} catch {
cashflowError.value = true
} finally {
cashflowLoading.value = false
}
}
// estado: últimos lançamentos
const recentLoading = ref(true)
const recentRecords = ref([])
const STATUS_CFG = {
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
partial: { label: 'Parcial', severity: 'info' },
cancelled: { label: 'Cancelado', severity: 'secondary' },
refunded: { label: 'Estornado', severity: 'contrast' },
}
async function loadRecent (uid) {
recentLoading.value = true
try {
const { data } = await supabase.rpc('list_financial_records', {
p_owner_id: uid,
p_limit: 5,
p_offset: 0,
})
recentRecords.value = data ?? []
} finally {
recentLoading.value = false
}
}
// navegação
function goToLancamentos () {
// Tenta navegar pelo nome da rota; fallback para push relativo
const route = router.currentRoute.value
const base = route.matched[0]?.path ?? ''
router.push(base + '/financeiro/lancamentos')
}
// mount
onMounted(async () => {
const uid = await getUid()
if (!uid) return
await Promise.all([
loadSummary(uid),
loadChart(uid),
loadCashflow(),
loadRecent(uid),
])
})
</script>
<template>
<div class="min-h-[calc(100vh-4.5rem)] bg-[var(--surface-ground,#f5f7fa)]">
<div class="px-3 md:px-4 pt-3 pb-4 flex flex-col gap-3.5">
<!--
Hero
-->
<section class="relative overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-2.5">
<!-- Blobs decorativos -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-indigo-500/10" />
<div class="absolute w-80 h-80 top-2 -left-20 rounded-full blur-[70px] bg-emerald-400/[0.08]" />
</div>
<!-- Linha 1: icon + título + botão -->
<div class="relative z-[1] flex items-center gap-3">
<div class="flex items-center gap-2.5 flex-1 min-w-0">
<div class="cfg-subheader__icon grid place-items-center w-10 h-10 rounded-md flex-shrink-0" style="background:color-mix(in srgb,#10b981 15%,transparent);color:#059669">
<i class="pi pi-wallet text-lg" />
</div>
<div class="min-w-0">
<div class="text-[1.05rem] font-bold tracking-tight text-[var(--text-color)]">Financeiro</div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">Resumo e visão geral do período</div>
</div>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<Button
label="Ver lançamentos"
icon="pi pi-list"
severity="secondary"
outlined
class="rounded-full hidden sm:flex"
@click="goToLancamentos"
/>
</div>
</div>
<!-- Linha 2: quick stats -->
<div class="relative z-[1] mt-2.5">
<template v-if="summaryLoading">
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2.5">
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)]">
<Skeleton width="3rem" height="20px" />
<Skeleton width="4.5rem" height="10px" />
</div>
</div>
</template>
<template v-else>
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2.5">
<!-- Recebido -->
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-emerald-500/25 bg-emerald-500/5">
<div class="text-[1.35rem] font-bold leading-none text-emerald-600">{{ fmtBRL(totalRecebido) }}</div>
<div class="flex items-center gap-1.5 text-[0.7rem] text-emerald-700/80 font-semibold">
<i class="pi pi-check-circle text-xs" />
Recebido (mês)
</div>
</div>
<!-- Pendente -->
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-amber-500/25 bg-amber-500/5">
<div class="text-[1.35rem] font-bold leading-none text-amber-500">{{ fmtBRL(totalPendente) }}</div>
<div class="flex items-center gap-1.5 text-[0.7rem] text-amber-600/80 font-semibold">
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse flex-shrink-0" />
Pendente
</div>
</div>
<!-- Vencido -->
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border"
:class="totalVencido > 0 ? 'border-red-500/25 bg-red-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-ground)]'"
>
<div class="text-[1.35rem] font-bold leading-none" :class="totalVencido > 0 ? 'text-red-500' : 'text-[var(--text-color)]'">{{ fmtBRL(totalVencido) }}</div>
<div
class="flex items-center gap-1.5 text-[0.7rem] font-semibold"
:class="totalVencido > 0 ? 'text-red-600/80' : 'text-[var(--text-color-secondary)] opacity-75'"
>
<i class="pi pi-exclamation-circle text-xs" />
Vencido
</div>
</div>
<!-- Despesas -->
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ fmtBRL(totalDespesas) }}</div>
<div class="flex items-center gap-1.5 text-[0.7rem] text-[var(--text-color-secondary)] opacity-75 font-semibold">
<i class="pi pi-arrow-down-left text-xs" />
Despesas (mês)
</div>
</div>
</div>
</template>
</div>
</section>
<!--
Gráfico Receita × Despesa
-->
<section class="dash-card rounded-md">
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-chart-bar cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
<div>
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Receita × Despesa</div>
<div class="dash-card__sub">Comparativo dos últimos 6 meses</div>
</div>
</div>
<div class="px-3 pb-3 pt-2">
<div v-if="chartLoading" class="h-[240px]">
<Skeleton height="100%" />
</div>
<div v-else-if="chartData" class="h-[240px]">
<Chart type="bar" :data="chartData" :options="chartOptions" style="height:100%" />
</div>
<div v-else class="flex items-center justify-center gap-2 py-10 text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle" /> Sem dados para o gráfico.
</div>
</div>
</section>
<!--
Projeção de Caixa
-->
<section class="dash-card rounded-md">
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-calendar cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
<div>
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Projeção de Caixa</div>
<div class="dash-card__sub">Cobranças em aberto próximos 6 meses</div>
</div>
</div>
<div class="px-3 pb-3 pt-1">
<div v-if="cashflowLoading" class="flex flex-col gap-2 pt-1">
<Skeleton v-for="n in 6" :key="n" height="2.4rem" border-radius="6px" />
</div>
<div v-else-if="cashflowError" class="flex items-center justify-center gap-2 py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-info-circle" /> Projeção indisponível.
</div>
<div v-else-if="!cashflowRows.length" class="flex items-center justify-center gap-2 py-8 text-[var(--text-color-secondary)]">
<i class="pi pi-check-circle" /> Sem cobranças futuras em aberto.
</div>
<div v-else class="flex flex-col gap-1.5 pt-1">
<div
v-for="row in cashflowRows"
:key="row.mes_label"
class="flex items-center gap-3 px-3 py-2.5 rounded-md bg-[var(--surface-ground,#f8fafc)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100"
>
<span class="font-bold text-[0.8rem] uppercase tracking-wide text-[var(--text-color)] min-w-[3.5rem] flex-shrink-0">{{ row.mes_label }}</span>
<div class="flex items-center gap-2 flex-1 flex-wrap text-[0.8rem]">
<span class="flex items-center gap-1 text-emerald-600 font-semibold">
<i class="pi pi-arrow-up-right text-xs" />
{{ fmtBRL(row.receitas_projetadas) }}
</span>
<span class="text-[var(--text-color-secondary)] opacity-30">·</span>
<span class="flex items-center gap-1 text-red-500 font-semibold">
<i class="pi pi-arrow-down-left text-xs" />
{{ fmtBRL(row.despesas_projetadas) }}
</span>
<span class="text-[var(--text-color-secondary)] opacity-30">·</span>
<span class="font-bold" :class="Number(row.saldo_projetado) >= 0 ? 'text-emerald-600' : 'text-red-500'">
saldo {{ fmtBRL(row.saldo_projetado) }}
</span>
</div>
<Tag :value="row.count_registros + ' cobranças'" severity="secondary" class="ml-auto text-xs flex-shrink-0" />
</div>
</div>
</div>
</section>
<!--
Últimos lançamentos
-->
<section class="dash-card rounded-md shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
<div class="dash-card__head gap-2.5 p-2.5">
<i class="pi pi-list cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
<div class="flex-1">
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Últimos lançamentos</div>
<div class="dash-card__sub">Cobranças e receitas recentes</div>
</div>
<button
class="flex items-center gap-1 bg-transparent border-none cursor-pointer text-xs font-semibold text-[var(--primary-color,#6366f1)] p-0 flex-shrink-0"
@click="goToLancamentos"
>
Ver todos <i class="pi pi-arrow-right text-xs" />
</button>
</div>
<div v-if="recentLoading" class="px-3 pb-3 pt-1 flex flex-col gap-2">
<Skeleton v-for="n in 5" :key="n" height="2.5rem" border-radius="6px" />
</div>
<div
v-else-if="!recentRecords.length"
class="flex items-center justify-center gap-2 py-10 text-[var(--text-color-secondary)]"
>
<i class="pi pi-wallet opacity-40" /> Nenhum lançamento encontrado.
</div>
<DataTable
v-else
:value="recentRecords"
size="small"
:show-gridlines="false"
class="recent-table"
>
<Column field="due_date" header="Data">
<template #body="{ data }">
{{ fmtDate(data.paid_at ?? data.due_date) }}
</template>
</Column>
<Column field="description" header="Descrição">
<template #body="{ data }">
{{ data.description || data.notes || '—' }}
</template>
</Column>
<Column header="Tipo">
<template #body="{ data }">
<Tag
:value="data.type === 'receita' ? 'Receita' : 'Despesa'"
:severity="data.type === 'receita' ? 'success' : 'danger'"
class="text-xs"
/>
</template>
</Column>
<Column header="Valor">
<template #body="{ data }">
<span :class="data.type === 'receita' ? 'text-emerald-600 font-semibold' : 'text-red-500 font-semibold'">
{{ fmtBRL(data.final_amount ?? data.amount) }}
</span>
</template>
</Column>
<Column header="Status">
<template #body="{ data }">
<Tag
:value="STATUS_CFG[data.status]?.label ?? data.status"
:severity="STATUS_CFG[data.status]?.severity ?? 'secondary'"
:class="data.status === 'refunded' ? 'tag-refunded' : ''"
class="text-xs"
/>
</template>
</Column>
</DataTable>
</section>
</div>
</div>
</template>
<style scoped>
/* ── recent table ───────────────────────────────────────────── */
.recent-table :deep(.p-datatable-thead > tr > th) {
background: var(--surface-ground);
font-size: .75rem;
text-transform: uppercase;
letter-spacing: .04em;
padding: .5rem .75rem;
}
.recent-table :deep(.p-datatable-tbody > tr > td) {
padding: .55rem .75rem;
font-size: .85rem;
}
/* ── tag: refunded (roxo) ───────────────────────────────────── */
:deep(.tag-refunded) { background: #a855f7 !important; color: #fff !important; }
</style>
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -1,4 +1,19 @@
<!-- src/features/notices/GlobalNoticeBanner.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/notices/GlobalNoticeBanner.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<!-- Banner global no topo position fixed acima da topbar, empurra layout via CSS var -->
<script setup>
import { watch, onBeforeUnmount, ref, nextTick } from 'vue'
+16 -1
View File
@@ -1,4 +1,19 @@
// src/features/notices/noticeService.js
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/notices/noticeService.js
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
*/
// Serviço central de acesso ao Supabase para Global Notices
import { supabase } from '@/lib/supabase/client'
+339 -81
View File
@@ -1,5 +1,20 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/PatientsListPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -62,13 +77,19 @@
v-tooltip.top="'Descontos'"
@click="router.push('/configuracoes/descontos')"
/>
<SplitButton label="Novo" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
<PatientCreatePopover ref="createPopoverRef" @quick-create="openQuickCreate" />
<PatientCadastroDialog
v-model="cadastroFullDialog"
:patient-id="editPatientId"
@created="onPatientCreated"
/>
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchMobileDlg = true" />
<Button icon="pi pi-user-plus" class="h-9 w-9 rounded-full" @click="goCreateFull" />
<Button icon="pi pi-user-plus" class="h-9 w-9 rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
<Button
label="Ações"
icon="pi pi-ellipsis-v"
@@ -123,59 +144,65 @@
<!-- Linha 2 (mobile) / parte direita (desktop): KPIs -->
<div class="flex gap-2 flex-1 flex-wrap xl:flex-nowrap">
<!-- Total -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': filters.status === 'Todos' }"
@click="setStatus('Todos')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ kpis.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<template v-if="loading">
<Skeleton v-for="n in 5" :key="n" height="3.5rem" class="flex-1 min-w-[72px] rounded-md" />
</template>
<!-- Ativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Ativo'
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="setStatus('Ativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ kpis.active }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ativos</div>
</div>
<template v-else>
<!-- Total -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': filters.status === 'Todos' }"
@click="setStatus('Todos')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ kpis.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<!-- Inativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Inativo'
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="setStatus('Inativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ kpis.inactive }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
</div>
<!-- Ativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Ativo'
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="setStatus('Ativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ kpis.active }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Ativos</div>
</div>
<!-- Arquivados -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Arquivado'
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
@click="setStatus('Arquivado')"
>
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
</div>
<!-- Inativos -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Inativo'
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="setStatus('Inativo')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ kpis.inactive }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
</div>
<!-- Último atendimento não clicável -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
>
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Último atend.</div>
</div>
<!-- Arquivados -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Arquivado'
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
@click="setStatus('Arquivado')"
>
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
</div>
<!-- Último atendimento não clicável -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
>
<div class="text-[1.1rem] font-bold leading-none text-[var(--text-color)]">{{ prettyDate(kpis.latestLastAttended) || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Último atend.</div>
</div>
</template>
</div>
</div>
@@ -395,7 +422,7 @@
:rowsPerPageOptions="[10, 15, 25, 50]"
stripedRows
scrollable
scrollHeight="flex"
scrollHeight="400px"
sortMode="single"
:sortField="sort.field"
:sortOrder="sort.order"
@@ -512,8 +539,21 @@
<!-- Cards mobile (<md) -->
<div class="md:hidden">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
<div v-if="loading" class="flex flex-col gap-3 pb-4">
<div v-for="n in 6" :key="n" class="rounded-[6px] border border-[var(--surface-border)] bg-[var(--surface-card)] p-4 flex flex-col gap-3">
<div class="flex items-center gap-3">
<Skeleton shape="square" size="3rem" border-radius="6px" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="60%" height="14px" />
<Skeleton width="40%" height="11px" />
</div>
<Skeleton width="50px" height="22px" border-radius="999px" />
</div>
<div class="flex gap-2 justify-end">
<Skeleton width="90px" height="30px" border-radius="999px" />
<Skeleton width="80px" height="30px" border-radius="999px" />
</div>
</div>
</div>
<div v-else-if="filteredRows.length === 0" class="py-10 text-center">
@@ -620,8 +660,8 @@
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
<div v-if="loading" class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<Skeleton v-for="n in 6" :key="n" height="8rem" class="rounded-xl" />
</div>
<!-- Empty -->
@@ -642,38 +682,48 @@
class="rounded-xl border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col shadow-sm hover:shadow-md transition-shadow duration-200"
>
<!-- Barra de cor do grupo -->
<div class="h-1.5 w-full" :style="grpColorStyle(grp.color)" />
<div class="h-1.5 w-full" :style="grpColorStyle(grp)" />
<!-- Header do grupo -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)]">
<!-- Header do grupo (clicável) -->
<button
class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)] w-full text-left bg-transparent border-0 border-b cursor-pointer transition-opacity duration-150 hover:opacity-80"
style="border-bottom-width: 1px"
:style="{ borderBottomColor: `${grp.color || 'var(--surface-border)'}30` }"
@click="openGrpDialog(grp)"
>
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-sm"
:style="grpColorStyle(grp.color)"
:style="grpColorStyle(grp)"
>
{{ (grp.name || '?')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[var(--text-color)] truncate text-sm">{{ grp.name }}</div>
<div class="font-semibold truncate text-sm" :style="{ color: grp.color || 'var(--text-color)' }">{{ grp.name }}</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }}
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }} · clique para ver
</div>
</div>
<span
class="inline-flex items-center justify-center min-w-[26px] h-6 px-1.5 rounded-full text-white text-xs font-bold flex-shrink-0"
:style="grpColorStyle(grp.color)"
:style="grpColorStyle(grp)"
>{{ grp.patients.length }}</span>
</div>
</button>
<!-- Chips de pacientes -->
<div class="p-3 flex flex-wrap gap-1.5 flex-1">
<button
v-for="p in grp.patients.slice(0, 12)"
:key="p.id"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-xs text-[var(--text-color)] hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-transparent cursor-pointer transition-all duration-150 font-medium group"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-xs text-[var(--text-color)] cursor-pointer transition-all duration-150 font-medium group"
v-tooltip.top="p.nome_completo"
@click="goEdit(p)"
@mouseenter="(e) => { e.currentTarget.style.background = grp.color || 'var(--primary-color,#6366f1)'; e.currentTarget.style.color = '#fff'; e.currentTarget.style.borderColor = 'transparent' }"
@mouseleave="(e) => { e.currentTarget.style.background = ''; e.currentTarget.style.color = ''; e.currentTarget.style.borderColor = '' }"
>
<span class="w-5 h-5 rounded-full bg-indigo-500/15 text-indigo-600 group-hover:bg-white/20 group-hover:text-white flex items-center justify-center text-[9px] font-bold flex-shrink-0 transition-colors">
<span
class="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold flex-shrink-0 transition-colors group-hover:bg-white/20 group-hover:text-white"
:style="grpChipAvatarStyle(grp)"
>
{{ (p.nome_completo || '?').charAt(0).toUpperCase() }}
</span>
<span class="max-w-[120px] truncate">{{ (p.nome_completo || '—').split(' ').slice(0, 2).join(' ') }}</span>
@@ -689,6 +739,153 @@
</TabPanels>
</Tabs>
<div class="px-3 md:px-4 pb-5">
<LoadedPhraseBlock v-if="hasLoaded" />
</div>
<!--
Dialog: Pacientes do grupo
-->
<Dialog
v-model:visible="grpDialog.open"
modal
:draggable="false"
:style="{ width: '780px', maxWidth: '95vw' }"
:pt="{
root: { style: `border: 4px solid ${grpDialogHex()}` },
header: { style: `border-bottom: 1px solid ${grpDialogHex()}30` }
}"
pt:mask:class="backdrop-blur-xs"
>
<template #header>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base flex-shrink-0"
:style="grpColorStyle(grpDialog.group)"
>
{{ (grpDialog.group?.name || '?')[0].toUpperCase() }}
</div>
<div>
<div class="text-[1rem] font-bold" :style="{ color: grpDialogHex() }">
Grupo {{ grpDialog.group?.name }}
</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
{{ grpDialog.group?.patients?.length || 0 }} paciente{{ (grpDialog.group?.patients?.length || 0) !== 1 ? 's' : '' }}
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<!-- Busca + contador -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<IconField class="w-full sm:w-72">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="grpDialog.search" placeholder="Buscar paciente..." class="w-full" />
</IconField>
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
:style="{ background: `${grpDialogHex()}18`, color: grpDialogHex() }"
>{{ grpDialogFiltered.length }} paciente(s)</span>
</div>
<!-- Empty -->
<div v-if="grpDialogFiltered.length === 0" class="py-10 text-center">
<div
class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md"
:style="{ background: `${grpDialogHex()}18`, color: grpDialogHex() }"
>
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente encontrado</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">
{{ grpDialog.search ? 'Nenhum paciente corresponde à busca.' : 'Este grupo não possui pacientes associados.' }}
</div>
<Button
v-if="grpDialog.search"
class="mt-3 rounded-full"
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar"
size="small"
@click="grpDialog.search = ''"
/>
</div>
<!-- Tabela -->
<DataTable
v-else
:value="grpDialogFiltered"
dataKey="id"
stripedRows
responsiveLayout="scroll"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
>
<Column header="Paciente" sortable sortField="nome_completo">
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar
v-if="data.avatar_url"
:image="data.avatar_url"
shape="circle"
/>
<Avatar
v-else
:label="(data.nome_completo || '?').charAt(0).toUpperCase()"
shape="circle"
:style="{ background: `${grpDialogHex()}25`, color: grpDialogHex() }"
/>
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email_principal || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="min-width: 10rem">
<template #body="{ data }">
<span class="text-[var(--text-color-secondary)]">{{ data.telefone || '—' }}</span>
</template>
</Column>
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
:style="{ borderColor: grpDialogHex(), color: grpDialogHex() }"
@click="goEdit(data); grpDialog.open = false"
/>
</template>
</Column>
<template #empty>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-sm">Nenhum resultado</div>
</div>
</template>
</DataTable>
</div>
<template #footer>
<Button
label="Fechar"
icon="pi pi-times"
outlined
class="rounded-full"
:style="{ borderColor: grpDialogHex(), color: grpDialogHex() }"
@click="grpDialog.open = false"
/>
</template>
</Dialog>
<!-- MODAL: CADASTRO RÁPIDO -->
<ComponentCadastroRapido
v-model="quickDialog"
@@ -705,6 +902,7 @@
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
@edit="(id) => { closeProntuario(); goEdit({ id }) }"
/>
<ConfirmDialog />
@@ -775,7 +973,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
@@ -788,6 +986,9 @@ import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue'
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue'
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
import { getSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js'
// Descontos por paciente
const discountMap = ref({})
@@ -865,6 +1066,7 @@ const patMobileMenuRef = ref(null)
const patMobileMenuItems = [
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
{ label: 'Link de Cadastro', icon: 'pi pi-link', command: () => router.push('/therapist/patients/link-externo') },
{ separator: true },
{ label: 'Descontos por Paciente', icon: 'pi pi-percentage', command: () => router.push('/configuracoes/descontos') },
{ separator: true },
@@ -874,10 +1076,15 @@ const patMobileMenuItems = [
const uid = ref(null)
const loading = ref(false)
const hasLoaded = ref(false)
const showAdvanced = ref(false)
const quickDialog = ref(false)
const searchMobileDlg = ref(false)
const quickDialog = ref(false)
const cadastroFullDialog = ref(false)
const editPatientId = ref(null)
const dialogSaved = ref(false)
const searchMobileDlg = ref(false)
const createPopoverRef = ref(null)
const prontuarioOpen = ref(false)
const selectedPatient = ref(null)
@@ -947,10 +1154,6 @@ const hasActiveFilters = computed(() => Boolean(
filters.createdFrom || filters.createdTo
))
const createMenu = [
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() }
]
onMounted(async () => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
@@ -996,6 +1199,18 @@ function openProntuario (row) {
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
function openQuickCreate () { quickDialog.value = true }
function onPatientCreated () { dialogSaved.value = true }
watch(cadastroFullDialog, async (isOpen) => {
if (!isOpen) {
editPatientId.value = null
if (dialogSaved.value) {
dialogSaved.value = false
await fetchAll()
}
}
})
function onQuickCreated (row) {
if (!row) return
patients.value = [
@@ -1048,8 +1263,8 @@ function safePush (toObj, fallbackPath) {
}
function goGroups () { const r = getPatientsRoutes(); return safePush({ name: r.groupsName }, r.groupsPath) }
function goCreateFull() { const r = getPatientsRoutes(); return safePush({ name: r.createName }, r.createPath) }
function goEdit (row) { if (!row?.id) return; const r = getPatientsRoutes(); return safePush({ name: r.editName, params: { id: row.id } }, r.editPath(row.id)) }
function goCreateFull() { cadastroFullDialog.value = true }
function goEdit (row) { if (!row?.id) return; editPatientId.value = String(row.id); cadastroFullDialog.value = true }
// Filters & Sort
let searchTimer = null
@@ -1204,7 +1419,7 @@ async function fetchAll () {
updateKpis()
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui carregar pacientes.', life: 3500 })
} finally { loading.value = false }
} finally { loading.value = false; hasLoaded.value = true }
}
async function listPatients () {
@@ -1340,7 +1555,13 @@ const groupedPatientsView = computed(() => {
const all = patients.value || []
const grpMap = new Map()
for (const g of (groups.value || [])) {
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: g.color || g.cor, patients: [], isSystem: !!g.is_system })
const isSystem = !!g.is_system
const storedColor = isSystem ? getSysGroupColor(g.id) : null
const rawColor = storedColor || g.color || g.cor || null
const resolvedColor = rawColor
? (rawColor.startsWith('#') ? rawColor : `#${rawColor}`)
: (isSystem ? `#${systemDefaultColorForGrp(g.name || g.nome)}` : null)
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: resolvedColor, patients: [], isSystem })
}
const ungrouped = { id: '__none__', name: 'Sem grupo', color: null, patients: [], isSystem: false }
for (const p of all) {
@@ -1358,9 +1579,46 @@ const groupedPatientsView = computed(() => {
return result
})
function grpColorStyle (color) {
if (!color) return { background: 'var(--surface-border)' }
return { background: color.startsWith('#') ? color : `#${color}` }
function systemDefaultColorForGrp (nameOrObj) {
const name = typeof nameOrObj === 'string' ? nameOrObj : (nameOrObj?.name || nameOrObj?.nome || '')
return getSystemGroupDefaultColor(name).replace('#', '')
}
function grpColorStyle (grp) {
// aceita string (#hex) ou objeto grp com .color já resolvido
const hex = typeof grp === 'string' ? (grp || null) : (grp?.color || null)
if (!hex) return { background: 'var(--surface-border)' }
return { background: hex }
}
function grpChipAvatarStyle (grp) {
const hex = typeof grp === 'string' ? (grp || null) : (grp?.color || null)
if (!hex) return {}
return { background: `${hex}25`, color: hex }
}
// Dialog: grupo de pacientes
const grpDialog = reactive({ open: false, group: null, search: '' })
const grpDialogFiltered = computed(() => {
const list = grpDialog.group?.patients || []
const s = String(grpDialog.search || '').trim().toLowerCase()
if (!s) return list
return list.filter(p =>
String(p.nome_completo || '').toLowerCase().includes(s) ||
String(p.email_principal || '').toLowerCase().includes(s) ||
String(p.telefone || '').toLowerCase().includes(s)
)
})
function openGrpDialog (grp) {
grpDialog.group = grp
grpDialog.search = ''
grpDialog.open = true
}
function grpDialogHex () {
return grpDialog.group?.color || '#6366f1'
}
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/PatientsCadastroPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, watch, nextTick, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -7,6 +23,12 @@ import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
const props = defineProps({
dialogMode: { type: Boolean, default: false },
patientId: { type: String, default: null }
})
const emit = defineEmits(['cancel', 'created'])
const { canSee } = useRoleGuard()
const route = useRoute()
const router = useRouter()
@@ -91,8 +113,12 @@ onBeforeUnmount(() => {
})
// Route helpers
const patientId = computed(() => String(route.params?.id || '').trim() || null)
const isEdit = computed(() => !!patientId.value)
const patientId = computed(() =>
props.dialogMode
? (props.patientId || null)
: (String(route.params?.id || '').trim() || null)
)
const isEdit = computed(() => !!patientId.value)
function getAreaKey () {
const seg = String(route.path || '').split('/').filter(Boolean)[0] || 'admin'
@@ -121,6 +147,7 @@ async function safePush (toNameObj, fallbackPath) {
}
function goBack () {
if (props.dialogMode) { emit('cancel'); return }
const { listName, listPath } = getPatientsRoutes()
if (window.history.length > 1) router.back()
else safePush({ name: listName }, listPath)
@@ -363,8 +390,7 @@ async function fetchAll () {
} finally { loading.value = false }
}
watch(() => route.params?.id, fetchAll, { immediate: true })
onMounted(fetchAll)
watch(patientId, fetchAll, { immediate: true })
// Tenant resolve
async function resolveTenantContextOrFail () {
@@ -393,13 +419,16 @@ async function onSubmit () {
await maybeUploadAvatar(ownerId, patientId.value)
await replacePatientGroups(patientId.value, grupoIdSelecionado.value)
await replacePatientTags(patientId.value, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 }); return
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente atualizado.', life: 2500 })
if (props.dialogMode) { emit('created', { id: patientId.value }); return }
return
}
const created = await createPatient(payload)
await maybeUploadAvatar(ownerId, created.id)
await replacePatientGroups(created.id, grupoIdSelecionado.value)
await replacePatientTags(created.id, tagIdsSelecionadas.value)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Paciente cadastrado.', life: 2500 })
if (props.dialogMode) { emit('created', created); return }
form.value = resetForm(); grupoIdSelecionado.value = null; tagIdsSelecionadas.value = []
avatarFile.value = null; revokePreview(); avatarPreviewUrl.value = ''
await openPanel(0)
@@ -422,7 +451,9 @@ async function doDelete () {
const { error: e1 } = await supabase.from('patient_group_patient').delete().eq('patient_id', pid); if (e1) throw e1
const { error: e2 } = await supabase.from('patient_patient_tag').delete().eq('patient_id', pid); if (e2) throw e2
const { error: e3 } = await supabase.from('patients').delete().eq('id', pid); if (e3) throw e3
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 }); goBack()
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Paciente excluído.', life: 2500 })
if (props.dialogMode) { emit('created', null); return }
goBack()
} catch (err) {
toast.add({ severity: 'error', summary: 'Erro', detail: err?.message || 'Falha ao excluir paciente', life: 4000 })
} finally { deleting.value = false }
@@ -509,19 +540,21 @@ async function createTagPersist () {
createTagError.value = (e?.code === '23505' || /duplicate key value/i.test(msg)) ? 'Já existe uma tag com esse nome.' : (msg || 'Falha ao criar tag.')
} finally { createTagSaving.value = false }
}
defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, canSee, isEdit })
</script>
<template>
<Toast />
<ConfirmDialog />
<ConfirmDialog v-if="!dialogMode" />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
HERO sticky
HERO sticky (oculto no modo dialog)
-->
<section
v-if="!dialogMode"
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
@@ -552,8 +585,8 @@ async function createTagPersist () {
<!-- Espaçador -->
<div class="flex-1" />
<!-- Ações -->
<div class="flex items-center gap-1.5 flex-shrink-0">
<!-- Ações (ocultas no modo dialog o Dialog tem seu próprio footer) -->
<div v-if="!dialogMode" class="flex items-center gap-1.5 flex-shrink-0">
<Button
v-if="canSee('testMODE')"
label="Preencher tudo"
@@ -593,7 +626,10 @@ async function createTagPersist () {
<div v-else class="grid grid-cols-1 gap-3 xl:grid-cols-[260px_1fr] max-w-[1100px] mx-auto">
<!-- SIDEBAR -->
<aside class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)] xl:self-start">
<aside
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:self-start"
:class="dialogMode ? 'xl:top-4' : 'xl:top-[calc(var(--layout-sticky-top,56px)+3.5rem)]'"
>
<!-- Avatar -->
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2">
@@ -868,8 +904,8 @@ async function createTagPersist () {
</Accordion>
<!-- Botão salvar bottom -->
<div class="mt-4 flex justify-center">
<!-- Botão salvar bottom (oculto no modo dialog o footer cuida disso) -->
<div v-if="!dialogMode" class="mt-4 flex justify-center">
<Button label="Salvar" icon="pi pi-check" :loading="saving" class="min-w-[200px] rounded-full" @click="onSubmit" />
</div>
</div>
@@ -1,5 +1,20 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/PatientsExternalLinkPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -172,6 +187,8 @@
<Button icon="pi pi-copy" label="Copiar mensagem" severity="secondary" outlined class="rounded-full" :disabled="!publicUrl" @click="copyInviteMessage" />
</div>
</div>
<LoadedPhraseBlock v-if="inviteToken" />
</div>
<!-- DIREITA: instruções -->
@@ -223,6 +240,7 @@
</div>
</div>
</div>
</template>
<script setup>
@@ -1,4 +1,19 @@
<!-- src/views/pages/patients/PatientIntakeRequestsPage.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { supabase } from '@/lib/supabase/client'
@@ -19,6 +34,7 @@ const tenantStore = useTenantStore()
const converting = ref(false)
const loading = ref(false)
const hasLoaded = ref(false)
const rows = ref([])
const q = ref('')
@@ -262,6 +278,7 @@ async function fetchIntakes () {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 })
} finally {
loading.value = false
hasLoaded.value = true
}
}
@@ -404,7 +421,6 @@ onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<Toast />
<ConfirmDialog />
<!-- Sentinel -->
@@ -499,47 +515,52 @@ onBeforeUnmount(() => { _observer?.disconnect() })
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div
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)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
@click="toggleStatusFilter('')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[72px] rounded-md" />
</template>
<template v-else>
<div
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)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
@click="toggleStatusFilter('')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Total</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'new'
? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]'
: 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
@click="toggleStatusFilter('new')"
>
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'new'
? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]'
: 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
@click="toggleStatusFilter('new')"
>
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Novos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'converted'
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="toggleStatusFilter('converted')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'converted'
? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]'
: 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="toggleStatusFilter('converted')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Convertidos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'rejected'
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="toggleStatusFilter('rejected')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'rejected'
? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]'
: 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="toggleStatusFilter('rejected')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Rejeitados</div>
</div>
</template>
</div>
<!--
@@ -635,8 +656,21 @@ onBeforeUnmount(() => { _observer?.disconnect() })
CARDS mobile (<md)
-->
<div class="md:hidden mx-3 mb-5">
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
<div v-if="loading" class="flex flex-col gap-2.5">
<div v-for="n in 5" :key="n" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3.5 flex flex-col gap-3">
<div class="flex items-center gap-3">
<Skeleton shape="circle" size="2.5rem" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="55%" height="13px" />
<Skeleton width="40%" height="11px" />
</div>
<Skeleton width="60px" height="22px" border-radius="999px" />
</div>
<div class="flex items-center justify-between">
<Skeleton width="30%" height="11px" />
<Skeleton width="60px" height="28px" border-radius="999px" />
</div>
</div>
</div>
<div v-else-if="filteredRows.length === 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] py-10 text-center">
@@ -681,6 +715,10 @@ onBeforeUnmount(() => { _observer?.disconnect() })
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="hasLoaded" />
</div>
<!--
MODAL detalhe
-->
@@ -1,5 +1,25 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/grupos/GruposPacientesPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<PatientCadastroDialog
v-model="editPatientDialog"
:patient-id="editPatientId"
/>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -88,24 +108,29 @@
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="{
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
}"
>
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
</template>
<template v-else>
<div
class="text-[1.35rem] font-bold leading-none"
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="{
'text-green-500': s.cls === 'qs-ok',
'text-[var(--text-color)]': !s.cls,
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
}"
>{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="{
'text-green-500': s.cls === 'qs-ok',
'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>
<!--
@@ -163,7 +188,7 @@
<div class="flex items-center gap-2">
<span
class="w-[3px] h-5 rounded-sm flex-shrink-0"
:style="data.cor ? colorStyle(data.cor) : { background: 'var(--surface-border)' }"
:style="effectiveCor(data) ? colorStyle(effectiveCor(data)) : { background: 'var(--surface-border)' }"
/>
<span class="font-medium">{{ data.nome }}</span>
</div>
@@ -195,7 +220,7 @@
<div class="flex gap-1.5 justify-end">
<Button v-if="!data.is_system" icon="pi pi-pencil" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar'" @click="openEdit(data)" />
<Button v-if="!data.is_system" icon="pi pi-trash" severity="danger" outlined rounded size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
<Button v-if="data.is_system" icon="pi pi-lock" outlined rounded size="small" disabled v-tooltip.top="'Grupo padrão — inalterável'" />
<Button v-if="data.is_system" icon="pi pi-palette" severity="secondary" outlined rounded size="small" v-tooltip.top="'Editar cor'" @click="openEditColor(data)" />
</div>
</template>
</Column>
@@ -215,6 +240,7 @@
</template>
</DataTable>
</div>
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
</div>
<!-- PAINEL LATERAL: grupos com pacientes -->
@@ -236,8 +262,13 @@
>{{ cards.length }}</span>
</div>
<!-- Skeleton -->
<div v-if="loading" class="flex flex-col gap-2 p-3">
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
</div>
<!-- Empty -->
<div v-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-users text-2xl opacity-20" />
<div class="font-semibold text-[0.8rem]">Nenhuma associação</div>
<div class="text-[0.72rem] opacity-70 leading-relaxed">Quando um grupo tiver pacientes vinculados, ele aparecerá aqui.</div>
@@ -254,7 +285,7 @@
<!-- Dot cor -->
<div
class="w-2.5 h-2.5 rounded-full flex-shrink-0"
:style="g.cor ? colorStyle(g.cor) : { background: 'var(--surface-border)' }"
:style="effectiveCor(g) ? colorStyle(effectiveCor(g)) : { background: 'var(--surface-border)' }"
/>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">{{ g.nome }}</div>
@@ -303,7 +334,7 @@
{{ dlg.nome || (dlg.mode === 'create' ? 'Novo grupo' : 'Editar grupo') }}
</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
{{ dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
{{ dlg.isSystem ? 'Grupo padrão — edição de cor' : dlg.mode === 'create' ? 'Criar tipo de grupo' : 'Editar tipo de grupo' }}
</div>
</div>
</div>
@@ -324,10 +355,14 @@
<FloatLabel variant="on">
<IconField>
<InputIcon><i class="pi pi-sitemap" /></InputIcon>
<InputText id="grp-nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving" @keydown.enter.prevent="saveDialog" />
<InputText id="grp-nome" v-model="dlg.nome" class="w-full" variant="filled" :disabled="dlg.saving || dlg.isSystem" @keydown.enter.prevent="saveDialog" />
</IconField>
<label for="grp-nome">Nome do grupo *</label>
<label for="grp-nome">{{ dlg.isSystem ? 'Nome do grupo (padrão)' : 'Nome do grupo *' }}</label>
</FloatLabel>
<div v-if="dlg.isSystem" class="flex items-center gap-1.5 text-[0.72rem] text-[var(--text-color-secondary)] opacity-60 -mt-1">
<i class="pi pi-info-circle text-[0.65rem]" />
<span>Grupos padrão do sistema apenas a cor pode ser alterada</span>
</div>
<!-- Seletor de cor -->
<div class="border border-[var(--surface-border,#e2e8f0)] rounded-md bg-[var(--surface-ground,#f8fafc)] p-3.5">
@@ -379,7 +414,14 @@
<template #footer>
<div class="flex items-center justify-end gap-2 pt-2">
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="dlg.saving" @click="dlg.open = false" />
<Button label="Salvar" icon="pi pi-check" class="rounded-full" :loading="dlg.saving" :disabled="!String(dlg.nome || '').trim()" @click="saveDialog" />
<Button
:label="dlg.isSystem ? 'Salvar cor' : 'Salvar'"
icon="pi pi-check"
class="rounded-full"
:loading="dlg.saving"
:disabled="!dlg.isSystem && !String(dlg.nome || '').trim()"
@click="saveDialog"
/>
</div>
</template>
</Dialog>
@@ -389,32 +431,50 @@
-->
<Dialog
v-model:visible="patientsDialog.open"
:header="patientsDialog.group?.nome ? `Pacientes — ${patientsDialog.group.nome}` : 'Pacientes do grupo'"
modal
:draggable="false"
:style="{ width: '860px', maxWidth: '95vw' }"
:pt="{
root: { style: `border: 4px solid ${patientsGroupHex}` },
header: { style: `border-bottom: 1px solid ${patientsGroupHex}30` }
}"
pt:mask:class="backdrop-blur-xs"
>
<div class="flex flex-col gap-3">
<!-- Info grupo -->
<div class="flex items-center gap-2">
<div v-if="patientsDialog.group?.cor" class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="colorStyle(patientsDialog.group.cor)" />
<span class="text-[var(--text-color-secondary)] text-[1rem]">
Grupo: <span class="font-semibold text-[var(--text-color)]">{{ patientsDialog.group?.nome || '—' }}</span>
</span>
<template #header>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base flex-shrink-0"
:style="{ background: patientsGroupHex }"
>
{{ (patientsDialog.group?.nome || '?')[0].toUpperCase() }}
</div>
<div>
<div class="text-[1rem] font-bold" :style="{ color: patientsGroupHex }">
Grupo {{ patientsDialog.group?.nome }}
</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
{{ patientsDialog.items.length }} paciente{{ patientsDialog.items.length !== 1 ? 's' : '' }}
</div>
</div>
</div>
</template>
<div class="flex flex-col gap-3">
<!-- Busca + contador -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<IconField class="w-full sm:w-72">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="patientsDialog.search" placeholder="Buscar paciente..." class="w-full" :disabled="patientsDialog.loading" />
</IconField>
<Tag v-if="!patientsDialog.loading" :value="`${patientsDialog.items.length} paciente(s)`" severity="secondary" />
<span
v-if="!patientsDialog.loading"
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
:style="{ background: `${patientsGroupHex}18`, color: patientsGroupHex }"
>{{ patientsDialog.items.length }} paciente(s)</span>
</div>
<!-- Loading -->
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4 text-[var(--text-color-secondary)]">
<div v-if="patientsDialog.loading" class="flex items-center gap-2 py-4" :style="{ color: patientsGroupHex }">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
@@ -423,7 +483,10 @@
<div v-else>
<!-- Empty -->
<div v-if="patientsDialog.items.length === 0" class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-indigo-500/10 text-indigo-500">
<div
class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md"
:style="{ background: `${patientsGroupHex}18`, color: patientsGroupHex }"
>
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente neste grupo</div>
@@ -444,8 +507,17 @@
<Column header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else :label="initials(data.full_name)" shape="circle" />
<Avatar
v-if="data.avatar_url"
:image="data.avatar_url"
shape="circle"
/>
<Avatar
v-else
:label="initials(data.full_name)"
shape="circle"
:style="{ background: `${patientsGroupHex}25`, color: patientsGroupHex }"
/>
<div class="min-w-0">
<div class="font-medium truncate">{{ data.full_name }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">{{ data.email || '—' }}</div>
@@ -462,7 +534,14 @@
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined @click="abrirPaciente(data)" />
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
:style="{ borderColor: patientsGroupHex, color: patientsGroupHex }"
@click="abrirPaciente(data)"
/>
</template>
</Column>
@@ -479,7 +558,14 @@
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="patientsDialog.open = false" />
<Button
label="Fechar"
icon="pi pi-times"
outlined
class="rounded-full"
:style="{ borderColor: patientsGroupHex, color: patientsGroupHex }"
@click="patientsDialog.open = false"
/>
</template>
</Dialog>
@@ -487,7 +573,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
@@ -503,6 +589,9 @@ import {
deleteGroup
} from '@/services/GruposPacientes.service.js'
import { getSysGroupColor, setSysGroupColor, getSystemGroupDefaultColor } from '@/utils/systemGroupColors.js'
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
@@ -529,6 +618,7 @@ const grpMobileMenuItems = computed(() => [
const dt = ref(null)
const loading = ref(false)
const hasLoaded = ref(false)
const groups = ref([])
const selectedGroups = ref([])
@@ -550,8 +640,22 @@ const quickStats = computed(() => {
]
})
function systemDefaultColor (row) {
return getSystemGroupDefaultColor(row.nome).replace('#', '')
}
function effectiveCor (row) {
if (row.is_system) {
const stored = getSysGroupColor(row.id)
if (stored) return stored.replace('#', '')
if (row.cor) return row.cor
return systemDefaultColor(row)
}
return row.cor || ''
}
// Dialog
const dlg = reactive({ open: false, mode: 'create', id: '', nome: '', cor: '', saving: false })
const dlg = reactive({ open: false, mode: 'create', id: '', nome: '', cor: '', saving: false, isSystem: false })
const dlgPresetColors = [
{ bg: '6366f1', name: 'Índigo' },
@@ -609,6 +713,12 @@ function colorStyle (cor) {
return { background: hex }
}
const patientsGroupHex = computed(() => {
if (!patientsDialog.group) return '#6366f1'
const raw = effectiveCor(patientsDialog.group)
return raw ? (raw.startsWith('#') ? raw : `#${raw}`) : '#6366f1'
})
function humanizeError (err) {
const msg = err?.message || err?.error_description || String(err) || 'Erro inesperado.'
const code = err?.code
@@ -628,6 +738,7 @@ async function fetchAll () {
toast.add({ severity: 'error', summary: 'Erro', detail: humanizeError(err), life: 3500 })
} finally {
loading.value = false
hasLoaded.value = true
}
}
@@ -659,18 +770,27 @@ function toggleRowSelection (row, checked) {
}
// CRUD
function openCreate () { dlg.open = true; dlg.mode = 'create'; dlg.id = ''; dlg.nome = ''; dlg.cor = '' }
function openEdit (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = row.cor || '' }
function openCreate () { dlg.open = true; dlg.mode = 'create'; dlg.id = ''; dlg.nome = ''; dlg.cor = ''; dlg.isSystem = false }
function openEdit (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = row.cor || ''; dlg.isSystem = false }
function openEditColor (row) { dlg.open = true; dlg.mode = 'edit'; dlg.id = row.id; dlg.nome = row.nome; dlg.cor = effectiveCor(row); dlg.isSystem = true }
async function saveDialog () {
const nome = String(dlg.nome || '').trim()
if (!nome) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 }); return }
if (nome.length < 2) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 }); return }
if (!dlg.isSystem) {
if (!nome) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Informe um nome.', life: 2500 }); return }
if (nome.length < 2) { toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Nome muito curto.', life: 2500 }); return }
}
const corRaw = String(dlg.cor || '').trim()
const cor = corRaw ? (corRaw.startsWith('#') ? corRaw : `#${corRaw}`) : null
dlg.saving = true
try {
if (dlg.mode === 'create') {
if (dlg.isSystem) {
setSysGroupColor(dlg.id, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Cor do grupo atualizada.', life: 2500 })
dlg.open = false
await fetchAll()
return
} else if (dlg.mode === 'create') {
await createGroup(nome, cor)
toast.add({ severity: 'success', summary: 'Sucesso', detail: 'Grupo criado.', life: 2500 })
} else {
@@ -760,7 +880,10 @@ async function openGroupPatientsModal (groupRow) {
}
}
function abrirPaciente (patient) { router.push(`/features/patients/cadastro/${patient.id}`) }
const editPatientId = ref(null)
const editPatientDialog = ref(false)
function abrirPaciente (patient) { if (!patient?.id) return; editPatientId.value = String(patient.id); editPatientDialog.value = true }
watch(editPatientDialog, (isOpen) => { if (!isOpen) editPatientId.value = null })
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
@@ -1,3 +1,19 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/prontuario/PatientProntuario.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import { useRouter, useRoute } from 'vue-router'
@@ -26,7 +42,7 @@ const props = defineProps({
patient: { type: Object, default: () => ({}) } // precisa ter id
})
const emit = defineEmits(['update:modelValue', 'close'])
const emit = defineEmits(['update:modelValue', 'close', 'edit'])
const model = computed({
get: () => props.modelValue,
@@ -481,17 +497,8 @@ function close() {
function editPatient() {
const id = patientData.value?.id
if (!id) return
// Detecta área pelo path atual mesmo padrão do PatientsCadastroPage
const isTherapist = String(route.path || '').startsWith('/therapist')
close()
if (isTherapist) {
router.push({ name: 'therapist-patients-edit', params: { id } })
} else {
router.push({ name: 'admin-pacientes-cadastro-edit', params: { id } })
}
emit('edit', String(id))
}
// ---------------------------------------------
@@ -578,7 +585,6 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
pt:title:class="text-[0.95rem] font-bold text-[var(--text-color)] truncate"
@hide="close"
>
<Toast />
<!-- CONTEÚDO -->
<div class="bg-[var(--surface-ground,#f5f7fa)]">
+201 -91
View File
@@ -1,5 +1,25 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/tags/TagsPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<Toast />
<PatientCadastroDialog
v-model="editPatientDialog"
:patient-id="editPatientId"
/>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
@@ -88,21 +108,26 @@
QUICK-STATS
-->
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
<div
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="{
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
}"
>
<template v-if="carregando">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="flex-1 min-w-[80px] rounded-md" />
</template>
<template v-else>
<div
class="text-[1.35rem] font-bold leading-none"
:class="s.cls === 'qs-ok' ? 'text-green-500' : 'text-[var(--text-color)]'"
>{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
v-for="s in quickStats"
:key="s.label"
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[80px] transition-colors duration-150"
:class="{
'border-green-500/25 bg-green-500/5': s.cls === 'qs-ok',
'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]': !s.cls,
}"
>
<div
class="text-[1.35rem] font-bold leading-none"
:class="s.cls === 'qs-ok' ? 'text-green-500' : 'text-[var(--text-color)]'"
>{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">{{ s.label }}</div>
</div>
</template>
</div>
<!--
@@ -173,13 +198,16 @@
<Column header="Pacientes" sortable sortField="pacientes_count" style="width: 10rem">
<template #body="{ data }">
<Button
class="p-0"
link
:label="String(data.pacientes_count ?? 0)"
:disabled="Number(data.pacientes_count ?? 0) <= 0"
<button
v-if="Number(data.pacientes_count ?? 0) > 0"
class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-semibold cursor-pointer border-0 transition-opacity hover:opacity-75"
:style="{ background: `${data.cor ? (data.cor.startsWith('#') ? data.cor : '#' + data.cor) : '#6366f1'}18`, color: data.cor ? (data.cor.startsWith('#') ? data.cor : '#' + data.cor) : '#6366f1' }"
@click="abrirModalPacientesDaTag(data)"
/>
>
<i class="pi pi-users text-[0.6rem]" />
{{ data.pacientes_count }}
</button>
<span v-else class="text-[var(--text-color-secondary)] opacity-40 text-xs"></span>
</template>
</Column>
@@ -225,6 +253,7 @@
</template>
</DataTable>
</div>
<LoadedPhraseBlock v-if="hasLoaded" class="mt-3" />
</div>
<!-- PAINEL LATERAL: tags mais usadas -->
@@ -246,8 +275,13 @@
>{{ cards.length }}</span>
</div>
<!-- Skeleton -->
<div v-if="carregando" class="flex flex-col gap-2 p-3">
<Skeleton v-for="n in 4" :key="n" height="2.75rem" class="rounded-md" />
</div>
<!-- Empty -->
<div v-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
<div v-else-if="cards.length === 0" class="flex flex-col items-center justify-center gap-2 px-4 py-8 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-tags text-2xl opacity-20" />
<div class="font-semibold text-[0.8rem]">Nenhuma tag em uso</div>
<div class="text-[0.72rem] opacity-70 leading-relaxed">As tags mais usadas aparecem aqui quando houver pacientes associados.</div>
@@ -280,11 +314,15 @@
</div>
</div>
<!-- Badge contagem -->
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] flex-shrink-0 bg-indigo-500/10 text-indigo-600">
{{ Number(t.pacientes_count ?? 0) }}
</span>
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-30 group-hover:opacity-100 group-hover:text-[var(--primary-color,#6366f1)] transition-all duration-150 flex-shrink-0" />
<!-- Badge contagem com cor da tag -->
<span
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] flex-shrink-0"
:style="{ background: `${t.cor ? (t.cor.startsWith('#') ? t.cor : '#' + t.cor) : '#6366f1'}18`, color: t.cor ? (t.cor.startsWith('#') ? t.cor : '#' + t.cor) : '#6366f1' }"
>{{ Number(t.pacientes_count ?? 0) }}</span>
<i
class="pi pi-chevron-right text-[0.6rem] opacity-30 group-hover:opacity-100 transition-all duration-150 flex-shrink-0"
:style="{ color: 'var(--text-color-secondary)' }"
/>
</button>
</div>
@@ -414,85 +452,149 @@
-->
<Dialog
v-model:visible="modalPacientes.open"
:header="modalPacientesHeader"
modal
:draggable="false"
:style="{ width: '860px', maxWidth: '96vw' }"
:pt="{
root: { style: `border: 4px solid ${modalTagHex}` },
header: { style: `border-bottom: 1px solid ${modalTagHex}30` }
}"
pt:mask:class="backdrop-blur-xs"
>
<div class="flex flex-col gap-3">
<!-- Info da tag -->
<div v-if="modalPacientes.tag" class="flex items-center gap-2">
<span
class="w-[3px] h-4 rounded-sm flex-shrink-0"
:style="{ background: modalPacientes.tag.cor || '#94a3b8' }"
/>
<span class="text-[var(--text-color-secondary)] text-[1rem]">
Tag: <span class="font-semibold text-[var(--text-color)]">{{ modalPacientes.tag.nome }}</span>
· <span class="text-[var(--primary-color,#6366f1)] font-semibold">{{ modalPacientes.items.length }} paciente(s)</span>
</span>
<template #header>
<div class="flex items-center gap-3">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base flex-shrink-0"
:style="{ background: modalTagHex }"
>
{{ (modalPacientes.tag?.nome || '?')[0].toUpperCase() }}
</div>
<div>
<div class="text-[1rem] font-bold" :style="{ color: modalTagHex }">
Tag {{ modalPacientes.tag?.nome }}
</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
{{ modalPacientes.items.length }} paciente{{ modalPacientes.items.length !== 1 ? 's' : '' }}
</div>
</div>
</div>
</template>
<!-- Busca + reload -->
<div class="flex flex-col gap-3">
<!-- Busca + contador -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<IconField class="w-full sm:w-72">
<InputIcon><i class="pi pi-search" /></InputIcon>
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" />
<InputText v-model="modalPacientes.search" placeholder="Buscar paciente..." class="w-full" :disabled="modalPacientes.loading" />
</IconField>
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" @click="recarregarModalPacientes" />
<div class="flex items-center gap-2">
<span
class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold"
:style="{ background: `${modalTagHex}18`, color: modalTagHex }"
>{{ modalPacientes.items.length }} paciente(s)</span>
<Button
icon="pi pi-refresh"
outlined
class="h-8 w-8 rounded-full flex-shrink-0"
:style="{ borderColor: modalTagHex, color: modalTagHex }"
@click="recarregarModalPacientes"
/>
</div>
</div>
<Message v-if="modalPacientes.error" severity="error">{{ modalPacientes.error }}</Message>
<!-- Loading -->
<div v-if="modalPacientes.loading" class="flex items-center gap-2 py-4" :style="{ color: modalTagHex }">
<i class="pi pi-spin pi-spinner" /> Carregando
</div>
<!-- Tabela -->
<DataTable
:value="modalPacientesFiltrado"
:loading="modalPacientes.loading"
dataKey="id"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
stripedRows
responsiveLayout="scroll"
>
<Column field="name" header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate">{{ data.name }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">{{ data.email || '—' }}</div>
<Message v-else-if="modalPacientes.error" severity="error">{{ modalPacientes.error }}</Message>
<div v-else>
<!-- Empty -->
<div v-if="modalPacientes.items.length === 0" class="py-10 text-center">
<div
class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md"
:style="{ background: `${modalTagHex}18`, color: modalTagHex }"
>
<i class="pi pi-users text-xl" />
</div>
<div class="font-semibold">Nenhum paciente nesta tag</div>
<div class="mt-1 text-sm text-[var(--text-color-secondary)]">Associe pacientes a esta tag na página de pacientes.</div>
</div>
<!-- Tabela -->
<DataTable
v-else
:value="modalPacientesFiltrado"
dataKey="id"
paginator
:rows="8"
:rowsPerPageOptions="[8, 15, 30]"
stripedRows
responsiveLayout="scroll"
>
<Column field="name" header="Paciente" sortable>
<template #body="{ data }">
<div class="flex items-center gap-3">
<Avatar
v-if="data.avatar_url"
:image="data.avatar_url"
shape="circle"
/>
<Avatar
v-else
:label="(data.name || '?')[0].toUpperCase()"
shape="circle"
:style="{ background: `${modalTagHex}25`, color: modalTagHex }"
/>
<div class="min-w-0">
<div class="font-medium truncate">{{ data.name }}</div>
<div class="text-[0.75rem] text-[var(--text-color-secondary)] truncate">{{ data.email || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Telefone" style="width: 13rem">
<template #body="{ data }">
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneBR(data.phone) }}</span>
</template>
</Column>
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<Button
label="Abrir"
icon="pi pi-external-link"
size="small"
outlined
:style="{ borderColor: modalTagHex, color: modalTagHex }"
@click="abrirPaciente(data)"
/>
</template>
</Column>
<template #empty>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-[1rem]">Nenhum resultado</div>
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde à busca.</div>
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="modalPacientes.search = ''" />
</div>
</template>
</Column>
<Column header="Telefone" style="width: 13rem">
<template #body="{ data }">
<span class="text-[var(--text-color-secondary)]">{{ fmtPhoneBR(data.phone) }}</span>
</template>
</Column>
<Column header="Ação" style="width: 9rem">
<template #body="{ data }">
<Button label="Abrir" icon="pi pi-external-link" size="small" outlined @click="abrirPaciente(data)" />
</template>
</Column>
<template #empty>
<div class="py-8 text-center">
<i class="pi pi-search text-2xl opacity-20 mb-2 block" />
<div class="font-semibold text-[1rem]">Nenhum resultado</div>
<div class="text-[0.75rem] opacity-60 mt-1">Nenhum paciente corresponde a "{{ modalPacientes.search }}".</div>
<Button class="mt-3" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" size="small" @click="modalPacientes.search = ''" />
</div>
</template>
</DataTable>
</DataTable>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined class="rounded-full" @click="modalPacientes.open = false" />
<Button
label="Fechar"
icon="pi pi-times"
outlined
class="rounded-full"
:style="{ borderColor: modalTagHex, color: modalTagHex }"
@click="modalPacientes.open = false"
/>
</template>
</Dialog>
@@ -500,7 +602,7 @@
</template>
<script setup>
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
import { ref, reactive, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
@@ -509,6 +611,7 @@ import Checkbox from 'primevue/checkbox'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client'
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue'
const router = useRouter()
const toast = useToast()
@@ -537,6 +640,7 @@ const mobileMenuItems = computed(() => [
const dt = ref(null)
const carregando = ref(false)
const hasLoaded = ref(false)
const etiquetas = ref([])
const etiquetasSelecionadas = ref([])
@@ -591,9 +695,11 @@ const isCustomColor = computed(() => {
// Modal pacientes
const modalPacientes = reactive({ open: false, loading: false, error: '', tag: null, items: [], search: '' })
const modalPacientesHeader = computed(() =>
modalPacientes.tag ? `Pacientes — ${modalPacientes.tag.nome}` : 'Pacientes'
)
const modalTagHex = computed(() => {
const cor = modalPacientes.tag?.cor
if (!cor) return '#6366f1'
return cor.startsWith('#') ? cor : `#${cor}`
})
const modalPacientesFiltrado = computed(() => {
const s = String(modalPacientes.search || '').trim().toLowerCase()
@@ -712,6 +818,7 @@ async function buscarEtiquetas () {
toast.add({ severity: 'error', summary: 'Erro ao carregar tags', detail: e?.message || 'Verifique se as tabelas/views existem.', life: 6000 })
} finally {
carregando.value = false
hasLoaded.value = true
}
}
@@ -859,7 +966,10 @@ function fmtPhoneBR (v) {
return d
}
function abrirPaciente (patient) { router.push(`/features/patients/cadastro/${patient.id}`) }
const editPatientId = ref(null)
const editPatientDialog = ref(false)
function abrirPaciente (patient) { if (!patient?.id) return; editPatientId.value = String(patient.id); editPatientDialog.value = true }
watch(editPatientDialog, (isOpen) => { if (!isOpen) editPatientId.value = null })
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) {
+16 -2
View File
@@ -1,7 +1,21 @@
<!-- src/features/setup/SetupWizardPage.vue -->
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/setup/SetupWizardPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<template>
<div class="sw-root" ref="swRootEl">
<Toast />
<!-- Background blobs -->
<div class="sw-bg" aria-hidden="true">