+ Menu Hover no Layout Rail, Twilio, Sms, Email, Templates, LNovo Layout Configurações
This commit is contained in:
132
database-novo/migrations/001_twilio_whatsapp_subaccount.sql
Normal file
132
database-novo/migrations/001_twilio_whatsapp_subaccount.sql
Normal file
@@ -0,0 +1,132 @@
|
||||
-- =============================================================================
|
||||
-- AgenciaPsi — Migration 001: Twilio WhatsApp Subaccounts
|
||||
-- =============================================================================
|
||||
-- Adiciona suporte a subcontas Twilio com número WhatsApp dedicado por tenant.
|
||||
-- Cada clínica/terapeuta recebe sua própria subconta Twilio com número exclusivo.
|
||||
-- =============================================================================
|
||||
|
||||
-- ── 1. Campos de subconta Twilio em notification_channels ──────────────────
|
||||
|
||||
ALTER TABLE public.notification_channels
|
||||
ADD COLUMN IF NOT EXISTS twilio_subaccount_sid text,
|
||||
ADD COLUMN IF NOT EXISTS twilio_phone_number text,
|
||||
ADD COLUMN IF NOT EXISTS twilio_phone_sid text,
|
||||
ADD COLUMN IF NOT EXISTS webhook_url text,
|
||||
ADD COLUMN IF NOT EXISTS cost_per_message_usd numeric(8,6) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS price_per_message_brl numeric(8,4) DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS provisioned_at timestamp with time zone;
|
||||
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_subaccount_sid IS 'SID da subconta Twilio criada para este tenant';
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_phone_number IS 'Número WhatsApp provisionado (E.164, ex: +5511999990000)';
|
||||
COMMENT ON COLUMN public.notification_channels.twilio_phone_sid IS 'SID do número de telefone na subconta Twilio';
|
||||
COMMENT ON COLUMN public.notification_channels.webhook_url IS 'URL do webhook configurada na Twilio para receber callbacks de status';
|
||||
COMMENT ON COLUMN public.notification_channels.cost_per_message_usd IS 'Custo real Twilio por mensagem WhatsApp (USD)';
|
||||
COMMENT ON COLUMN public.notification_channels.price_per_message_brl IS 'Valor cobrado do tenant por mensagem (BRL, inclui margem SaaS)';
|
||||
COMMENT ON COLUMN public.notification_channels.provisioned_at IS 'Timestamp do provisionamento da subconta';
|
||||
|
||||
-- Índice para busca rápida por subconta
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_channels_twilio_subaccount_sid
|
||||
ON public.notification_channels (twilio_subaccount_sid)
|
||||
WHERE twilio_subaccount_sid IS NOT NULL;
|
||||
|
||||
-- ── 2. Tabela de consumo por subconta ─────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.twilio_subaccount_usage (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
channel_id uuid NOT NULL,
|
||||
twilio_subaccount_sid text NOT NULL,
|
||||
period_start date NOT NULL,
|
||||
period_end date NOT NULL,
|
||||
messages_sent integer DEFAULT 0 NOT NULL,
|
||||
messages_delivered integer DEFAULT 0 NOT NULL,
|
||||
messages_failed integer DEFAULT 0 NOT NULL,
|
||||
cost_usd numeric(12,6) DEFAULT 0 NOT NULL,
|
||||
cost_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
revenue_brl numeric(12,4) DEFAULT 0 NOT NULL,
|
||||
margin_brl numeric(12,4) GENERATED ALWAYS AS (revenue_brl - cost_brl) STORED,
|
||||
usd_brl_rate numeric(8,4) DEFAULT 0,
|
||||
synced_at timestamp with time zone DEFAULT now(),
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
|
||||
CONSTRAINT twilio_subaccount_usage_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT twilio_subaccount_usage_channel_fk
|
||||
FOREIGN KEY (channel_id) REFERENCES public.notification_channels(id) ON DELETE CASCADE,
|
||||
CONSTRAINT twilio_subaccount_usage_period_check
|
||||
CHECK (period_end >= period_start)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE public.twilio_subaccount_usage IS
|
||||
'Consumo mensal de mensagens WhatsApp por subconta Twilio. Sincronizado via Edge Function.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_twilio_usage_tenant_period
|
||||
ON public.twilio_subaccount_usage (tenant_id, period_start DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_twilio_usage_channel
|
||||
ON public.twilio_subaccount_usage (channel_id, period_start DESC);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_twilio_usage_unique_period
|
||||
ON public.twilio_subaccount_usage (channel_id, period_start, period_end);
|
||||
|
||||
ALTER TABLE public.twilio_subaccount_usage OWNER TO supabase_admin;
|
||||
|
||||
-- ── 3. RLS: twilio_subaccount_usage ───────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.twilio_subaccount_usage ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Tenant vê apenas seu próprio consumo
|
||||
CREATE POLICY "tenant_select_own_usage"
|
||||
ON public.twilio_subaccount_usage
|
||||
FOR SELECT
|
||||
USING (
|
||||
tenant_id IN (
|
||||
SELECT tenant_id FROM public.tenant_members
|
||||
WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Apenas service_role pode inserir/atualizar (via Edge Function)
|
||||
CREATE POLICY "service_role_manage_usage"
|
||||
ON public.twilio_subaccount_usage
|
||||
FOR ALL
|
||||
USING (auth.role() = 'service_role');
|
||||
|
||||
-- ── 4. RLS: notification_channels — acesso ao twilio_subaccount_sid ───────
|
||||
-- As políticas existentes já cobrem SELECT/UPDATE. Nenhuma alteração necessária.
|
||||
|
||||
-- ── 5. View: resumo de subcontas para o painel SaaS admin ─────────────────
|
||||
|
||||
CREATE OR REPLACE VIEW public.v_twilio_whatsapp_overview AS
|
||||
SELECT
|
||||
nc.id AS channel_id,
|
||||
nc.tenant_id,
|
||||
nc.owner_id,
|
||||
nc.is_active,
|
||||
nc.connection_status,
|
||||
nc.display_name,
|
||||
nc.twilio_subaccount_sid,
|
||||
nc.twilio_phone_number,
|
||||
nc.twilio_phone_sid,
|
||||
nc.cost_per_message_usd,
|
||||
nc.price_per_message_brl,
|
||||
nc.provisioned_at,
|
||||
nc.created_at,
|
||||
nc.updated_at,
|
||||
-- Uso do mês atual
|
||||
COALESCE(u.messages_sent, 0) AS current_month_sent,
|
||||
COALESCE(u.messages_delivered, 0) AS current_month_delivered,
|
||||
COALESCE(u.messages_failed, 0) AS current_month_failed,
|
||||
COALESCE(u.cost_usd, 0) AS current_month_cost_usd,
|
||||
COALESCE(u.cost_brl, 0) AS current_month_cost_brl,
|
||||
COALESCE(u.revenue_brl, 0) AS current_month_revenue_brl,
|
||||
COALESCE(u.margin_brl, 0) AS current_month_margin_brl
|
||||
FROM public.notification_channels nc
|
||||
LEFT JOIN public.twilio_subaccount_usage u
|
||||
ON u.channel_id = nc.id
|
||||
AND u.period_start = date_trunc('month', CURRENT_DATE)::date
|
||||
WHERE nc.channel = 'whatsapp'
|
||||
AND nc.provider = 'twilio'
|
||||
AND nc.deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW public.v_twilio_whatsapp_overview IS
|
||||
'Visão consolidada de subcontas Twilio WhatsApp com uso do mês corrente.';
|
||||
@@ -139,49 +139,7 @@
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
/* ── Subheader de seção ──────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* Brilho sutil no canto */
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.cfg-card__icon-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
|
||||
@@ -204,7 +204,7 @@ function skipProcedures() {
|
||||
<template #header>
|
||||
<div class="flex w-full items-center justify-between gap-3 px-1">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0" :style="{ background: isServico ? '#6366f1' : isProcedureOnly ? '#f59e0b' : '#22c55e' }">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm shrink-0" :style="{ background: isServico ? '#6366f1' : isProcedureOnly ? '#f59e0b' : '#22c55e' }">
|
||||
<i :class="isServico ? 'pi pi-tag' : isProcedureOnly ? 'pi pi-list' : 'pi pi-id-card'" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
|
||||
@@ -149,7 +149,7 @@ function menuItems() {
|
||||
<!-- Dialog: Desativar -->
|
||||
<Dialog v-model:visible="deactivateDialogOpen" modal :draggable="false" header="Desativar paciente" :style="{ width: '460px', maxWidth: '95vw' }">
|
||||
<div class="flex gap-3 items-start py-2">
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-exclamation-triangle text-amber-500 text-2xl mt-0.5 shrink-0" />
|
||||
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
|
||||
<p class="m-0 font-semibold">Atenção: este paciente pode possuir sessões agendadas e/ou recorrências ativas.</p>
|
||||
<p class="m-0">Ao desativar, todas as sessões e recorrências serão mantidas na agenda — porém novos agendamentos ficarão bloqueados.</p>
|
||||
@@ -167,7 +167,7 @@ function menuItems() {
|
||||
<!-- Dialog: Arquivar -->
|
||||
<Dialog v-model:visible="archiveDialogOpen" modal :draggable="false" header="Arquivar paciente" :style="{ width: '460px', maxWidth: '95vw' }">
|
||||
<div class="flex gap-3 items-start py-2">
|
||||
<i class="pi pi-archive text-slate-500 text-2xl mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-archive text-slate-500 text-2xl mt-0.5 shrink-0" />
|
||||
<div class="text-sm text-[var(--text-color)] leading-relaxed space-y-2">
|
||||
<p class="m-0 font-semibold">O que acontece ao arquivar um paciente?</p>
|
||||
<ul class="m-0 pl-4 space-y-1 text-[var(--text-color)]">
|
||||
@@ -190,7 +190,7 @@ function menuItems() {
|
||||
<!-- Dialog: Exclusão bloqueada (paciente com histórico) -->
|
||||
<Dialog v-model:visible="hasHistoryDialogOpen" modal :draggable="false" header="Exclusão não permitida" :style="{ width: '420px', maxWidth: '95vw' }">
|
||||
<div class="flex gap-3 items-start py-2">
|
||||
<i class="pi pi-info-circle text-amber-500 text-2xl mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-info-circle text-amber-500 text-2xl mt-0.5 shrink-0" />
|
||||
<p class="text-[var(--text-color)] text-sm leading-relaxed m-0">
|
||||
Este paciente possui histórico clínico e <strong>não pode ser removido permanentemente</strong>. Apenas o arquivamento é permitido para pacientes com registros de atendimento.
|
||||
</p>
|
||||
|
||||
@@ -120,7 +120,7 @@ defineExpose({ toggle, close });
|
||||
<div class="flex flex-col min-w-[230px]">
|
||||
<!-- Cadastro rápido -->
|
||||
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onQuickCreate">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-bolt text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -131,7 +131,7 @@ defineExpose({ toggle, close });
|
||||
|
||||
<!-- Cadastro completo -->
|
||||
<button class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="onGoComplete">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-user-plus text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2326,10 +2326,10 @@ onMounted(async () => {
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-indigo-500/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.07]" />
|
||||
</div>
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-calendar text-base" /></div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-calendar text-base" /></div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-base font-bold tracking-tight text-[var(--text-color)]">Agenda</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">{{ subtitleText }}</div>
|
||||
@@ -2523,7 +2523,7 @@ onMounted(async () => {
|
||||
agPanelOpen = false;
|
||||
"
|
||||
>
|
||||
<div class="flex flex-col items-end min-w-[36px] flex-shrink-0">
|
||||
<div class="flex flex-col items-end min-w-[36px] shrink-0">
|
||||
<span class="text-xs font-bold text-[var(--text-color)]">{{ fmtHoraEvento(ev.inicio_em) }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">{{ fmtDuracao(ev.inicio_em, ev.fim_em) }}</span>
|
||||
</div>
|
||||
@@ -2539,13 +2539,13 @@ onMounted(async () => {
|
||||
</div>
|
||||
<button
|
||||
v-if="ev.patient_id || ev.paciente_id"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer shrink-0"
|
||||
title="Opções"
|
||||
@click.stop="openTodayEvMenu($event, ev)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
||||
</button>
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] shrink-0" :class="statusIcon(ev.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2587,23 +2587,23 @@ onMounted(async () => {
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
||||
@click="router.push('/therapist/patients')"
|
||||
>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-list" /></div>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-list" /></div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Lista de pacientes</span>
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Todos os cadastros ativos</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 flex-shrink-0" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
||||
@click="router.push('/therapist/patients/cadastro')"
|
||||
>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] flex-shrink-0 bg-green-500/10 text-green-600"><i class="pi pi-user-plus" /></div>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-green-500/10 text-green-600"><i class="pi pi-user-plus" /></div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Novo paciente</span>
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Cadastrar manualmente</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 flex-shrink-0" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
||||
</button>
|
||||
<!-- Link de cadastro externo -->
|
||||
<div v-if="cadastroExternoLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
||||
@@ -2616,7 +2616,7 @@ onMounted(async () => {
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs flex-shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
@click="copyLink(cadastroExternoLink)"
|
||||
v-tooltip.top="'Copiar link'"
|
||||
>
|
||||
@@ -2637,12 +2637,12 @@ onMounted(async () => {
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
||||
@click="router.push('/therapist/agendador/solicitacoes')"
|
||||
>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-inbox" /></div>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-inbox" /></div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Solicitações</span>
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Pedidos de agendamento</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 flex-shrink-0" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
||||
</button>
|
||||
<!-- Link do agendador -->
|
||||
<div v-if="agendadorLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
||||
@@ -2650,7 +2650,7 @@ onMounted(async () => {
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input readonly :value="agendadorLink" class="flex-1 min-w-0 text-[0.72rem] text-[var(--primary-color,#6366f1)] bg-transparent border-none outline-none truncate cursor-text font-mono" @click="$event.target.select()" />
|
||||
<button
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs flex-shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
@click="copyLink(agendadorLink)"
|
||||
v-tooltip.top="'Copiar link'"
|
||||
>
|
||||
@@ -2714,7 +2714,7 @@ onMounted(async () => {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
|
||||
<span class="truncate">Calendário · Sessões de hoje</span>
|
||||
<span v-if="todayEvents.length" 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.68rem] font-bold flex-shrink-0">{{
|
||||
<span v-if="todayEvents.length" 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.68rem] font-bold shrink-0">{{
|
||||
todayEvents.length
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -2804,7 +2804,7 @@ onMounted(async () => {
|
||||
}"
|
||||
@click="onEventRowClick(ev)"
|
||||
>
|
||||
<div class="flex flex-col items-end min-w-[36px] flex-shrink-0">
|
||||
<div class="flex flex-col items-end min-w-[36px] shrink-0">
|
||||
<span class="text-xs font-bold text-[var(--text-color)]">{{ fmtHoraEvento(ev.inicio_em) }}</span>
|
||||
<span class="text-[0.65rem] text-[var(--text-color-secondary)]">{{ fmtDuracao(ev.inicio_em, ev.fim_em) }}</span>
|
||||
</div>
|
||||
@@ -2820,13 +2820,13 @@ onMounted(async () => {
|
||||
</div>
|
||||
<button
|
||||
v-if="ev.patient_id || ev.paciente_id"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer shrink-0"
|
||||
title="Opções"
|
||||
@click.stop="openTodayEvMenu($event, ev)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
||||
</button>
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] shrink-0" :class="statusIcon(ev.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2860,23 +2860,23 @@ onMounted(async () => {
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
||||
@click="router.push('/therapist/patients')"
|
||||
>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-list" /></div>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-list" /></div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Lista de pacientes</span>
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Todos os cadastros ativos</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 flex-shrink-0" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
||||
@click="router.push('/therapist/patients/cadastro')"
|
||||
>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] flex-shrink-0 bg-green-500/10 text-green-600"><i class="pi pi-user-plus" /></div>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-green-500/10 text-green-600"><i class="pi pi-user-plus" /></div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Novo paciente</span>
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Cadastrar manualmente</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 flex-shrink-0" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
||||
</button>
|
||||
<!-- Link de cadastro externo -->
|
||||
<div v-if="cadastroExternoLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
||||
@@ -2889,7 +2889,7 @@ onMounted(async () => {
|
||||
@click="$event.target.select()"
|
||||
/>
|
||||
<button
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs flex-shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
@click="copyLink(cadastroExternoLink)"
|
||||
v-tooltip.top="'Copiar link'"
|
||||
>
|
||||
@@ -2910,12 +2910,12 @@ onMounted(async () => {
|
||||
class="flex items-center gap-2.5 px-2.5 py-2 rounded-md border-none bg-transparent cursor-pointer w-full text-left transition-colors duration-100 hover:bg-[var(--surface-hover)]"
|
||||
@click="router.push('/therapist/agendador/solicitacoes')"
|
||||
>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-inbox" /></div>
|
||||
<div class="w-[30px] h-[30px] rounded-md grid place-items-center text-[0.8rem] shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-inbox" /></div>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-semibold text-[var(--text-color)] truncate">Solicitações</span>
|
||||
<span class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">Pedidos de agendamento</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 flex-shrink-0" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] text-[var(--text-color-secondary)] opacity-40 shrink-0" />
|
||||
</button>
|
||||
<!-- Link do agendador -->
|
||||
<div v-if="agendadorLink" class="flex flex-col gap-1 px-2.5 py-2 rounded-md bg-[var(--surface-ground)] border border-[var(--surface-border)] mt-1">
|
||||
@@ -2923,7 +2923,7 @@ onMounted(async () => {
|
||||
<div class="flex items-center gap-1.5">
|
||||
<input readonly :value="agendadorLink" class="flex-1 min-w-0 text-[0.72rem] text-[var(--primary-color,#6366f1)] bg-transparent border-none outline-none truncate cursor-text font-mono" @click="$event.target.select()" />
|
||||
<button
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs flex-shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
class="grid place-items-center w-[26px] h-[26px] rounded border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer text-xs shrink-0 transition-colors duration-100 hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-[var(--primary-color)]"
|
||||
@click="copyLink(agendadorLink)"
|
||||
v-tooltip.top="'Copiar link'"
|
||||
>
|
||||
@@ -3144,11 +3144,11 @@ onMounted(async () => {
|
||||
<!-- Body: split panel -->
|
||||
<div v-if="desativadoSelected" class="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden">
|
||||
<!-- Sidebar esquerda: lista de sessões -->
|
||||
<div class="w-full lg:w-[340px] lg:flex-shrink-0 flex flex-col border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
|
||||
<div class="w-full lg:w-[340px] lg:shrink-0 flex flex-col border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
|
||||
<!-- Patient info -->
|
||||
<div class="px-4 py-3 border-b border-[var(--surface-border)] bg-orange-500/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0" style="background: #f97316">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm shrink-0" style="background: #f97316">
|
||||
{{ (desativadoSelected.nome_completo || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -3157,7 +3157,7 @@ onMounted(async () => {
|
||||
{{ desativadoSelected.status === 'Arquivado' ? 'arquivado' : 'desativado' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-auto text-right flex-shrink-0">
|
||||
<div class="ml-auto text-right shrink-0">
|
||||
<div class="text-lg font-bold text-orange-500">{{ desativadoSelected.sessions.length }}</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">sessão(ões)</div>
|
||||
</div>
|
||||
|
||||
@@ -360,12 +360,12 @@ const emptySub = computed(() => {
|
||||
<div class="absolute w-56 h-56 -bottom-8 right-1/4 rounded-full blur-[55px] bg-orange-400/[0.07]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex flex-col gap-2.5">
|
||||
<div class="relative z-1 flex flex-col gap-2.5">
|
||||
<!-- Linha 1: brand + busca + refresh -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-inbox text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
@@ -386,7 +386,7 @@ const emptySub = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" title="Atualizar" @click="load" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" title="Atualizar" @click="load" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 2: chips de status -->
|
||||
@@ -425,7 +425,7 @@ const emptySub = computed(() => {
|
||||
@click="filtroStatus = 'autorizado'"
|
||||
>
|
||||
<!-- Ícone pulsante -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600">
|
||||
<i class="pi pi-calendar-plus text-[0.95rem]" />
|
||||
</div>
|
||||
@@ -444,7 +444,7 @@ const emptySub = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Badge + seta -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-amber-500 text-white text-[1rem] font-bold">
|
||||
{{ totalAutorizados }}
|
||||
</span>
|
||||
@@ -460,7 +460,7 @@ const emptySub = computed(() => {
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<div v-for="n in 4" :key="n" class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="w-10 h-10 rounded-full shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
@@ -512,7 +512,7 @@ const emptySub = computed(() => {
|
||||
<!-- Linha principal clicável -->
|
||||
<div class="flex items-center gap-3 px-4 py-3.5 cursor-pointer hover:bg-[var(--surface-ground,#f8fafc)] transition-colors duration-100" @click="toggleExpand(s.id)">
|
||||
<!-- Avatar inicial -->
|
||||
<div class="grid place-items-center w-10 h-10 rounded-full flex-shrink-0 font-bold text-[0.95rem] bg-indigo-500/10 text-indigo-600">
|
||||
<div class="grid place-items-center w-10 h-10 rounded-full shrink-0 font-bold text-[0.95rem] bg-indigo-500/10 text-indigo-600">
|
||||
{{ initials(s) }}
|
||||
</div>
|
||||
|
||||
@@ -532,24 +532,24 @@ const emptySub = computed(() => {
|
||||
</div>
|
||||
|
||||
<!-- Ações rápidas — pendente -->
|
||||
<div v-if="s.status === 'pendente'" class="hidden sm:flex items-center gap-1.5 flex-shrink-0" @click.stop>
|
||||
<div v-if="s.status === 'pendente'" class="hidden sm:flex items-center gap-1.5 shrink-0" @click.stop>
|
||||
<Button label="Aprovar" icon="pi pi-check" size="small" severity="success" class="rounded-full" :loading="aprovando === s.id" @click="aprovar(s)" />
|
||||
<Button label="Recusar" icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full" @click="abrirRecusa(s)" />
|
||||
<Button label="Converter" icon="pi pi-calendar-plus" size="small" severity="info" outlined class="rounded-full" :loading="convertendoId === s.id" @click="converterEmSessao(s)" />
|
||||
</div>
|
||||
|
||||
<!-- Ações — autorizado -->
|
||||
<div v-else-if="s.status === 'autorizado'" class="hidden sm:flex items-center flex-shrink-0" @click.stop>
|
||||
<div v-else-if="s.status === 'autorizado'" class="hidden sm:flex items-center shrink-0" @click.stop>
|
||||
<Button label="Converter em sessão" icon="pi pi-calendar-plus" size="small" severity="info" outlined class="rounded-full" :loading="convertendoId === s.id" @click="converterEmSessao(s)" />
|
||||
</div>
|
||||
|
||||
<!-- Ações — convertido -->
|
||||
<div v-else-if="s.status === 'convertido'" class="hidden sm:flex items-center flex-shrink-0" @click.stop>
|
||||
<div v-else-if="s.status === 'convertido'" class="hidden sm:flex items-center shrink-0" @click.stop>
|
||||
<Button label="Ver na agenda" icon="pi pi-calendar" size="small" severity="secondary" outlined class="rounded-full" @click="irParaAgenda(s)" />
|
||||
</div>
|
||||
|
||||
<!-- Chevron -->
|
||||
<i class="pi flex-shrink-0 text-[1rem] text-[var(--text-color-secondary)] transition-transform duration-200" :class="expandedId === s.id ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
<i class="pi shrink-0 text-[1rem] text-[var(--text-color-secondary)] transition-transform duration-200" :class="expandedId === s.id ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
</div>
|
||||
|
||||
<!-- Ações mobile (visíveis só quando expandido, em telas pequenas) -->
|
||||
|
||||
@@ -382,25 +382,25 @@ function isRecent(row) {
|
||||
═══════════════════════════════════════ -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
class="sticky mx-3 md:mx-4 mb-3 z-20 overflow-hidden rounded-md border border-(--surface-border,#e2e8f0) bg-(--surface-card,#fff) px-3 py-2.5 transition-[border-radius] duration-200"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-400/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/9" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-list text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Compromissos</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)]">Configure tipos de compromissos e campos adicionais</div>
|
||||
<div class="text-[1rem] font-bold tracking-tight text-(--text-color)">Compromissos</div>
|
||||
<div class="text-[0.75rem] text-(--text-color-secondary)">Configure tipos de compromissos e campos adicionais</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -419,13 +419,13 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button label="Novo" icon="pi pi-plus" class="rounded-full" :disabled="loading" @click="openCreate()" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll()" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate()" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
@@ -506,7 +506,7 @@ function isRecent(row) {
|
||||
<Column field="name" header="Nome" sortable style="min-width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="data.bg_color" class="w-3 h-3 rounded-full flex-shrink-0" :style="{ background: `#${data.bg_color}` }" />
|
||||
<div v-if="data.bg_color" class="w-3 h-3 rounded-full shrink-0" :style="{ background: `#${data.bg_color}` }" />
|
||||
<span class="font-semibold text-sm">{{ data.name }}</span>
|
||||
<Tag v-if="data.is_native" value="Nativo" severity="info" class="text-xs" />
|
||||
</div>
|
||||
@@ -561,11 +561,11 @@ function isRecent(row) {
|
||||
<!-- fim coluna principal -->
|
||||
|
||||
<!-- ── PAINEL LATERAL: tipos de compromisso ─────────── -->
|
||||
<div class="w-full xl:w-[272px] xl:flex-shrink-0">
|
||||
<div class="w-full xl:w-[272px] xl:shrink-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header do painel -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -596,14 +596,14 @@ function isRecent(row) {
|
||||
@click="openStatsInfo(c)"
|
||||
>
|
||||
<!-- Dot cor -->
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="c.bg_color ? { background: `#${c.bg_color}` } : { background: 'var(--surface-border)' }" />
|
||||
<div class="w-2.5 h-2.5 rounded-full shrink-0" :style="c.bg_color ? { background: `#${c.bg_color}` } : { background: 'var(--surface-border)' }" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)]">{{ c.name }}</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||||
{{ formatMinutes(getTotalMinutes(c.id)) }}
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
<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 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -208,9 +208,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: icon + título + botão -->
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<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">
|
||||
<div class="cfg-subheader__icon grid place-items-center w-10 h-10 rounded-md 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">
|
||||
@@ -218,13 +218,13 @@ onMounted(async () => {
|
||||
<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">
|
||||
<div class="flex items-center gap-2 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">
|
||||
<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)]">
|
||||
@@ -249,7 +249,7 @@ onMounted(async () => {
|
||||
<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" />
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
Pendente
|
||||
</div>
|
||||
</div>
|
||||
@@ -281,7 +281,7 @@ onMounted(async () => {
|
||||
═══════════════════════════════════════ -->
|
||||
<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" />
|
||||
<i class="pi pi-chart-bar cfg-subheader__icon w-10 h-10 rounded-md 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>
|
||||
@@ -303,7 +303,7 @@ onMounted(async () => {
|
||||
═══════════════════════════════════════ -->
|
||||
<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" />
|
||||
<i class="pi pi-calendar cfg-subheader__icon w-10 h-10 rounded-md 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>
|
||||
@@ -320,7 +320,7 @@ onMounted(async () => {
|
||||
|
||||
<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>
|
||||
<span class="font-bold text-[0.8rem] uppercase tracking-wide text-[var(--text-color)] min-w-[3.5rem] 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" />
|
||||
@@ -334,7 +334,7 @@ onMounted(async () => {
|
||||
<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" />
|
||||
<Tag :value="row.count_registros + ' cobranças'" severity="secondary" class="ml-auto text-xs shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,12 +345,12 @@ onMounted(async () => {
|
||||
═══════════════════════════════════════ -->
|
||||
<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" />
|
||||
<i class="pi pi-list cfg-subheader__icon w-10 h-10 rounded-md 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">
|
||||
<button class="flex items-center gap-1 bg-transparent border-none cursor-pointer text-xs font-semibold text-[var(--primary-color,#6366f1)] p-0 shrink-0" @click="goToLancamentos">
|
||||
Ver todos <i class="pi pi-arrow-right text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -302,11 +302,11 @@ onBeforeUnmount(() => {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] px-3 pt-2.5 pb-2">
|
||||
<div class="relative z-1 px-3 pt-2.5 pb-2">
|
||||
<!-- Linha 1: brand + actions -->
|
||||
<div class="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">
|
||||
<div class="cfg-subheader__icon grid place-items-center w-10 h-10 rounded-md shrink-0" style="background: color-mix(in srgb, #10b981 15%, transparent); color: #059669">
|
||||
<i class="pi pi-wallet text-base" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -316,13 +316,13 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden sm:flex items-center gap-2 flex-shrink-0">
|
||||
<div class="hidden sm:flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" v-tooltip.top="'Recarregar'" @click="applyFilters" />
|
||||
<Button label="Lançamento manual" icon="pi pi-plus" class="rounded-full" @click="openManualDlg" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex sm:hidden items-center gap-1 flex-shrink-0">
|
||||
<div class="flex sm:hidden items-center gap-1 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="applyFilters" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openManualDlg" />
|
||||
</div>
|
||||
@@ -349,7 +349,7 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="text-[1.25rem] font-bold leading-none text-amber-500">{{ fmtBRL(summary.totalPending) }}</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" />
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse shrink-0" />
|
||||
Pendente
|
||||
<span class="ml-auto font-bold bg-amber-500/10 rounded-full px-1.5">{{ summary.countByStatus.pending ?? 0 }}</span>
|
||||
</div>
|
||||
@@ -397,12 +397,12 @@ onBeforeUnmount(() => {
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-filter cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
|
||||
<i class="pi pi-filter cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Filtros</div>
|
||||
<div class="dash-card__sub">Refine por status, tipo, paciente ou período</div>
|
||||
</div>
|
||||
<Button v-if="hasActiveFilters" label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="rounded-full ml-auto flex-shrink-0" @click="clearFilters" />
|
||||
<Button v-if="hasActiveFilters" label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="rounded-full ml-auto shrink-0" @click="clearFilters" />
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="flex flex-col sm:flex-row sm:flex-wrap items-start sm:items-end gap-3">
|
||||
@@ -428,7 +428,7 @@ onBeforeUnmount(() => {
|
||||
<Select id="fin-filter-patient" v-model="filterPatient" :options="patients" optionLabel="nome_completo" filter :filterFields="['nome_completo']" showClear class="w-full">
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full flex-shrink-0" :style="option.identification_color ? { background: option.identification_color } : { background: 'var(--surface-border)' }" />
|
||||
<span class="h-2 w-2 rounded-full shrink-0" :style="option.identification_color ? { background: option.identification_color } : { background: 'var(--surface-border)' }" />
|
||||
<span>{{ option.nome_completo }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -452,7 +452,7 @@ onBeforeUnmount(() => {
|
||||
Erro de carregamento
|
||||
═══════════════════════════════════════ -->
|
||||
<div v-if="error" class="rounded-md border border-red-500/30 bg-red-500/5 px-4 py-3 flex items-center gap-3 text-red-600">
|
||||
<i class="pi pi-exclamation-triangle flex-shrink-0" />
|
||||
<i class="pi pi-exclamation-triangle shrink-0" />
|
||||
<span class="text-[1rem]">{{ error }}</span>
|
||||
<Button icon="pi pi-refresh" severity="danger" text size="small" class="ml-auto" @click="applyFilters" />
|
||||
</div>
|
||||
@@ -497,7 +497,7 @@ onBeforeUnmount(() => {
|
||||
<!-- Linha 1: paciente + valor + status -->
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div
|
||||
class="h-8 w-8 rounded-full flex-shrink-0 grid place-items-center text-white text-xs font-bold mt-0.5"
|
||||
class="h-8 w-8 rounded-full shrink-0 grid place-items-center text-white text-xs font-bold mt-0.5"
|
||||
:style="rec.patients?.identification_color ? { background: rec.patients.identification_color } : { background: 'var(--primary-color, #6366f1)' }"
|
||||
>
|
||||
{{ rec.patients?.nome_completo?.[0]?.toUpperCase() ?? '?' }}
|
||||
@@ -510,7 +510,7 @@ onBeforeUnmount(() => {
|
||||
{{ rec.agenda_eventos ? fmtDateTime(rec.agenda_eventos.inicio_em) : 'Lançamento manual' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1 flex-shrink-0">
|
||||
<div class="flex flex-col items-end gap-1 shrink-0">
|
||||
<span class="font-bold text-[var(--text-color)]">{{ fmtBRL(rec.final_amount) }}</span>
|
||||
<Tag :value="STATUS_CFG[rec.status]?.label ?? rec.status" :severity="STATUS_CFG[rec.status]?.severity" class="text-xs" />
|
||||
</div>
|
||||
@@ -547,7 +547,7 @@ onBeforeUnmount(() => {
|
||||
<section class="hidden md:block dash-card rounded-md shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<!-- Header -->
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-table cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
|
||||
<i class="pi pi-table cfg-subheader__icon w-10 h-10 rounded-md shrink-0" />
|
||||
<div class="flex-1">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Registros</div>
|
||||
<div class="dash-card__sub">Lista completa de cobranças e lançamentos</div>
|
||||
@@ -584,7 +584,7 @@ onBeforeUnmount(() => {
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="h-6 w-6 rounded-full flex-shrink-0 grid place-items-center text-white text-[0.6rem] font-bold"
|
||||
class="h-6 w-6 rounded-full shrink-0 grid place-items-center text-white text-[0.6rem] font-bold"
|
||||
:style="data.patients?.identification_color ? { background: data.patients.identification_color } : { background: 'var(--primary-color, #6366f1)' }"
|
||||
>
|
||||
{{ data.patients?.nome_completo?.[0]?.toUpperCase() ?? '?' }}
|
||||
@@ -688,7 +688,7 @@ onBeforeUnmount(() => {
|
||||
<div class="font-semibold text-sm text-[var(--text-color)] truncate">{{ payDlgRecord.patients?.nome_completo ?? '—' }}</div>
|
||||
<div class="text-[0.75rem] text-[var(--text-color-secondary)] mt-0.5">Vencimento: {{ fmtDate(payDlgRecord.due_date) }}</div>
|
||||
</div>
|
||||
<div class="text-right flex-shrink-0">
|
||||
<div class="text-right shrink-0">
|
||||
<div class="text-lg font-bold text-[var(--text-color)]">{{ fmtBRL(payDlgRecord.final_amount) }}</div>
|
||||
<div v-if="payDlgRecord.discount_amount > 0" class="text-[0.7rem] text-[var(--text-color-secondary)] line-through">
|
||||
{{ fmtBRL(payDlgRecord.amount) }}
|
||||
@@ -745,7 +745,7 @@ onBeforeUnmount(() => {
|
||||
<Select v-model="manualForm.patient" :options="patients" optionLabel="nome_completo" filter :filterFields="['nome_completo']" showClear placeholder="Selecionar paciente..." class="w-full">
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full flex-shrink-0" :style="option.identification_color ? { background: option.identification_color } : { background: 'var(--surface-border)' }" />
|
||||
<span class="h-2 w-2 rounded-full shrink-0" :style="option.identification_color ? { background: option.identification_color } : { background: 'var(--surface-border)' }" />
|
||||
<span>{{ option.nome_completo }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -741,10 +741,10 @@ function isRecent(row) {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-users text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -767,7 +767,7 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.top="'Descontos'" @click="router.push('/configuracoes/descontos')" />
|
||||
<Button label="Novo" icon="pi pi-user-plus" class="rounded-full" @click="(e) => createPopoverRef?.toggle(e)" />
|
||||
@@ -776,7 +776,7 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 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="(e) => createPopoverRef?.toggle(e)" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => patMobileMenuRef.toggle(e)" />
|
||||
@@ -1225,14 +1225,14 @@ function isRecent(row) {
|
||||
: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)">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-sm" :style="grpColorStyle(grp)">
|
||||
{{ (grp.name || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<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' : '' }} · 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)">{{ grp.patients.length }}</span>
|
||||
<span class="inline-flex items-center justify-center min-w-[26px] h-6 px-1.5 rounded-full text-white text-xs font-bold shrink-0" :style="grpColorStyle(grp)">{{ grp.patients.length }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Chips de pacientes -->
|
||||
@@ -1258,7 +1258,7 @@ function isRecent(row) {
|
||||
}
|
||||
"
|
||||
>
|
||||
<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)">
|
||||
<span class="w-5 h-5 rounded-full flex items-center justify-center text-[9px] font-bold 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>
|
||||
@@ -1293,7 +1293,7 @@ function isRecent(row) {
|
||||
>
|
||||
<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)">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base shrink-0" :style="grpColorStyle(grpDialog.group)">
|
||||
{{ (grpDialog.group?.name || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -564,11 +564,11 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-user-plus text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -586,7 +586,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- 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">
|
||||
<div v-if="!dialogMode" class="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
v-if="canSee('testMODE')"
|
||||
label="Preencher tudo"
|
||||
@@ -634,7 +634,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<!-- 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">
|
||||
<!-- Foto -->
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] flex-shrink-0">
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] shrink-0">
|
||||
<img
|
||||
v-if="avatarPreviewUrl || form.avatar_url"
|
||||
:src="avatarPreviewUrl || form.avatar_url"
|
||||
@@ -676,7 +676,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="openPanel(Number(item.value))"
|
||||
>
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 flex-shrink-0" />
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -705,7 +705,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="selectNav(item)"
|
||||
>
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 flex-shrink-0" />
|
||||
<i :class="item.icon" class="text-[1rem] opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -802,7 +802,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
</FloatLabel>
|
||||
<div class="mt-1 text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">Usado para puxar um modelo de anamnese.</div>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="flex-shrink-0" @click="openGroupDlg" />
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openGroupDlg" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tags -->
|
||||
@@ -814,7 +814,7 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<label for="f_tags">Tags</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="flex-shrink-0" @click="openTagDlg" />
|
||||
<Button icon="pi pi-plus" severity="secondary" outlined class="shrink-0" @click="openTagDlg" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -928,11 +928,11 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie um grupo para organizar seus pacientes.</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="group-name" class="w-20 text-[1rem] font-semibold flex-shrink-0">Nome</label>
|
||||
<label for="group-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
|
||||
<InputText id="group-name" v-model="newGroup.name" class="flex-1" autocomplete="off" placeholder="Ex: Crianças" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 text-[1rem] font-semibold flex-shrink-0">Cor</label>
|
||||
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
|
||||
<div class="flex flex-1 items-center gap-2.5">
|
||||
<input v-model="newGroup.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
|
||||
<Chip :label="newGroup.color || '#—'" class="font-semibold" :style="{ backgroundColor: newGroup.color, color: '#fff' }" />
|
||||
@@ -963,11 +963,11 @@ defineExpose({ fillRandomPatient, onSubmit, confirmDelete, saving, deleting, can
|
||||
<div class="flex flex-col gap-4 pt-1">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)]">Crie uma tag para facilitar filtros e organização.</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<label for="tag-name" class="w-20 text-[1rem] font-semibold flex-shrink-0">Nome</label>
|
||||
<label for="tag-name" class="w-20 text-[1rem] font-semibold shrink-0">Nome</label>
|
||||
<InputText id="tag-name" v-model="newTag.name" class="flex-1" autocomplete="off" placeholder="Ex: Ansiedade" />
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="w-20 text-[1rem] font-semibold flex-shrink-0">Cor</label>
|
||||
<label class="w-20 text-[1rem] font-semibold shrink-0">Cor</label>
|
||||
<div class="flex flex-1 items-center gap-2.5">
|
||||
<input v-model="newTag.color" type="color" class="h-9 w-12 cursor-pointer rounded-md border border-[var(--surface-border,#e2e8f0)] bg-transparent" />
|
||||
<Chip :label="newTag.color || '#—'" class="font-semibold" :style="{ backgroundColor: newTag.color, color: '#fff' }" />
|
||||
|
||||
@@ -190,10 +190,10 @@ onBeforeUnmount(() => {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-link text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -206,7 +206,7 @@ onBeforeUnmount(() => {
|
||||
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-3">
|
||||
<!-- Badge de status -->
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0 transition-colors"
|
||||
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border shrink-0 transition-colors"
|
||||
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
||||
@@ -224,12 +224,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button label="Gerar novo link" icon="pi pi-refresh" severity="secondary" outlined class="rounded-full" :loading="rotating" @click="rotateLink" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
<Menu ref="mobileMenuRef" :model="mobileMenuItems" :popup="true" />
|
||||
</div>
|
||||
@@ -251,7 +251,7 @@ onBeforeUnmount(() => {
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Envie ao paciente por WhatsApp, e-mail ou mensagem direta</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border flex-shrink-0"
|
||||
class="inline-flex items-center gap-1.5 text-[0.75rem] px-2.5 py-1 rounded-full border shrink-0"
|
||||
:class="inviteToken ? 'border-emerald-200 text-emerald-700 bg-emerald-50' : 'border-[var(--surface-border)] text-[var(--text-color-secondary)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<span class="h-1.5 w-1.5 rounded-full" :class="inviteToken ? 'bg-emerald-500 animate-pulse' : 'bg-[var(--text-color-secondary)]'" />
|
||||
@@ -286,7 +286,7 @@ onBeforeUnmount(() => {
|
||||
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
|
||||
@click="copyLink"
|
||||
>
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-copy" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -300,7 +300,7 @@ onBeforeUnmount(() => {
|
||||
class="flex items-center gap-3 px-3.5 py-3 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] cursor-pointer text-left transition-[background,box-shadow,transform] duration-150 hover:bg-[var(--surface-hover,#f1f5f9)] hover:shadow-[0_2px_12px_rgba(0,0,0,0.06)] hover:-translate-y-px active:translate-y-0"
|
||||
@click="copyInviteMessage"
|
||||
>
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-comment" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -336,11 +336,11 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- ── DIREITA: instruções ────────────────────────── -->
|
||||
<div class="w-full lg:w-[272px] lg:flex-shrink-0 flex flex-col gap-3">
|
||||
<div class="w-full lg:w-[272px] lg:shrink-0 flex flex-col gap-3">
|
||||
<!-- Como funciona -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-list-check text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -351,7 +351,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<ol class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
|
||||
<li v-for="step in howItWorks" :key="step.n" class="flex items-start gap-3 px-3.5 py-3">
|
||||
<div class="grid place-items-center w-7 h-7 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500 text-[0.75rem] font-bold mt-px">
|
||||
<div class="grid place-items-center w-7 h-7 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500 text-[0.75rem] font-bold mt-px">
|
||||
{{ step.n }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -365,7 +365,7 @@ onBeforeUnmount(() => {
|
||||
<!-- Boas práticas -->
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-shield text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -376,7 +376,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<ul class="flex flex-col divide-y divide-[var(--surface-border,#f1f5f9)]">
|
||||
<li v-for="tip in goodPractices" :key="tip" class="flex items-start gap-2.5 px-3.5 py-2.5">
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 flex-shrink-0 text-[1rem]" />
|
||||
<i class="pi pi-check text-emerald-500 mt-0.5 shrink-0 text-[1rem]" />
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">{{ tip }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -495,10 +495,10 @@ onBeforeUnmount(() => {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-inbox text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -537,12 +537,12 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchIntakes" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="recSearchDlgOpen = true" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => recMobileMenuRef.toggle(e)" />
|
||||
<Menu ref="recMobileMenuRef" :model="recMobileMenuItems" :popup="true" />
|
||||
|
||||
@@ -431,10 +431,10 @@ function isRecent(row) {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-sitemap text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -457,14 +457,14 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button v-if="selectedGroups?.length" label="Excluir selecionados" icon="pi pi-trash" severity="danger" outlined class="rounded-full" @click="confirmDeleteSelected" />
|
||||
<Button label="Novo grupo" icon="pi pi-plus" class="rounded-full" @click="openCreate" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchAll" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="grpSearchDlgOpen = true" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="openCreate" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => grpMobileMenuRef.toggle(e)" />
|
||||
@@ -564,7 +564,7 @@ function isRecent(row) {
|
||||
<Column field="nome" header="Nome" sortable style="min-width: 16rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-[3px] h-5 rounded-sm flex-shrink-0" :style="effectiveCor(data) ? colorStyle(effectiveCor(data)) : { background: 'var(--surface-border)' }" />
|
||||
<span class="w-[3px] h-5 rounded-sm shrink-0" :style="effectiveCor(data) ? colorStyle(effectiveCor(data)) : { background: 'var(--surface-border)' }" />
|
||||
<span class="font-medium">{{ data.nome }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -616,18 +616,18 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: grupos com pacientes ─────────── -->
|
||||
<div class="w-full lg:w-[272px] lg:flex-shrink-0">
|
||||
<div class="w-full lg:w-[272px] lg:shrink-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header do painel -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-users text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Pacientes por grupo</span>
|
||||
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Grupos com associações ativas</span>
|
||||
</div>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold flex-shrink-0">{{ cards.length }}</span>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold shrink-0">{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
@@ -651,7 +651,7 @@ function isRecent(row) {
|
||||
@click="openGroupPatientsModal(g)"
|
||||
>
|
||||
<!-- Dot cor -->
|
||||
<div class="w-2.5 h-2.5 rounded-full flex-shrink-0" :style="effectiveCor(g) ? colorStyle(effectiveCor(g)) : { background: 'var(--surface-border)' }" />
|
||||
<div class="w-2.5 h-2.5 rounded-full shrink-0" :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>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
@@ -659,10 +659,10 @@ function isRecent(row) {
|
||||
</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" :class="g.is_system ? 'bg-sky-500/10 text-sky-600' : 'bg-indigo-500/10 text-indigo-600'">{{
|
||||
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] shrink-0" :class="g.is_system ? 'bg-sky-500/10 text-sky-600' : 'bg-indigo-500/10 text-indigo-600'">{{
|
||||
Number(g.patients_count ?? g.patient_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" />
|
||||
<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 shrink-0" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -791,7 +791,7 @@ function isRecent(row) {
|
||||
>
|
||||
<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 }">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base shrink-0" :style="{ background: patientsGroupHex }">
|
||||
{{ (patientsDialog.group?.nome || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -601,7 +601,7 @@ Tags: ${
|
||||
<aside class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:top-2 xl:self-start">
|
||||
<!-- Avatar + info rápida -->
|
||||
<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 xl:text-center">
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] flex-shrink-0">
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] shrink-0">
|
||||
<img :src="avatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -636,7 +636,7 @@ Tags: ${
|
||||
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="openPanel(Number(item.value))"
|
||||
>
|
||||
<i :class="item.icon" class="text-sm opacity-70 flex-shrink-0" />
|
||||
<i :class="item.icon" class="text-sm opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -657,7 +657,7 @@ Tags: ${
|
||||
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="selectNav(item)"
|
||||
>
|
||||
<i :class="item.icon" class="text-sm opacity-70 flex-shrink-0" />
|
||||
<i :class="item.icon" class="text-sm opacity-70 shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -426,10 +426,10 @@ function isRecent(row) {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-tags text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -452,14 +452,14 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0">
|
||||
<Button v-if="etiquetasSelecionadas?.length" label="Excluir selecionados" icon="pi pi-trash" severity="danger" outlined class="rounded-full" @click="confirmarExclusaoSelecionadas" />
|
||||
<Button label="Nova tag" icon="pi pi-plus" class="rounded-full" @click="abrirCriar" />
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="carregando" @click="buscarEtiquetas" />
|
||||
</div>
|
||||
|
||||
<!-- Mobile -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="searchDlgOpen = true" />
|
||||
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" @click="abrirCriar" />
|
||||
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => mobileMenuRef.toggle(e)" />
|
||||
@@ -551,9 +551,9 @@ function isRecent(row) {
|
||||
<template #body="{ data }">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<!-- Barra lateral colorida -->
|
||||
<span class="w-[3px] h-5 rounded-sm flex-shrink-0" :style="{ background: data.cor || '#94a3b8' }" />
|
||||
<span class="w-[3px] h-5 rounded-sm shrink-0" :style="{ background: data.cor || '#94a3b8' }" />
|
||||
<span class="font-medium truncate">{{ data.nome }}</span>
|
||||
<span v-if="data.is_padrao" class="inline-flex items-center px-1.5 py-px rounded text-[0.65rem] font-semibold bg-[var(--surface-border,#e2e8f0)] text-[var(--text-color-secondary)] flex-shrink-0">padrão</span>
|
||||
<span v-if="data.is_padrao" class="inline-flex items-center px-1.5 py-px rounded text-[0.65rem] font-semibold bg-[var(--surface-border,#e2e8f0)] text-[var(--text-color-secondary)] shrink-0">padrão</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -610,18 +610,18 @@ function isRecent(row) {
|
||||
</div>
|
||||
|
||||
<!-- ── PAINEL LATERAL: tags mais usadas ─────────────── -->
|
||||
<div class="w-full lg:w-[272px] lg:flex-shrink-0">
|
||||
<div class="w-full lg:w-[272px] lg:shrink-0">
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
<!-- Header do painel -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2.5 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center flex-shrink-0 bg-pink-500/10 text-pink-500">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center shrink-0 bg-pink-500/10 text-pink-500">
|
||||
<i class="pi pi-tags text-[0.9rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Mais usadas</span>
|
||||
<span class="block text-[0.72rem] text-[var(--text-color-secondary)]">Tags com pacientes associados</span>
|
||||
</div>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold flex-shrink-0">{{ cards.length }}</span>
|
||||
<span v-if="cards.length" class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold shrink-0">{{ cards.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Skeleton -->
|
||||
@@ -645,7 +645,7 @@ function isRecent(row) {
|
||||
@click="abrirModalPacientesDaTag(t)"
|
||||
>
|
||||
<!-- Barra de cor -->
|
||||
<div class="w-[3px] h-5 rounded-sm flex-shrink-0" :style="{ background: t.cor || '#94a3b8' }" />
|
||||
<div class="w-[3px] h-5 rounded-sm shrink-0" :style="{ background: t.cor || '#94a3b8' }" />
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[0.8rem] truncate text-[var(--text-color)] flex items-center gap-1.5">
|
||||
@@ -657,11 +657,11 @@ function isRecent(row) {
|
||||
|
||||
<!-- 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"
|
||||
class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full font-bold text-[0.68rem] 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)' }" />
|
||||
<i class="pi pi-chevron-right text-[0.6rem] opacity-30 group-hover:opacity-100 transition-all duration-150 shrink-0" :style="{ color: 'var(--text-color-secondary)' }" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -787,7 +787,7 @@ function isRecent(row) {
|
||||
>
|
||||
<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 }">
|
||||
<div class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-base shrink-0" :style="{ background: modalTagHex }">
|
||||
{{ (modalPacientes.tag?.nome || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div>
|
||||
@@ -806,7 +806,7 @@ function isRecent(row) {
|
||||
</IconField>
|
||||
<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" />
|
||||
<Button icon="pi pi-refresh" outlined class="h-8 w-8 rounded-full shrink-0" :style="{ borderColor: modalTagHex, color: modalTagHex }" @click="recarregarModalPacientes" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,11 +15,22 @@
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { computed, inject } from 'vue';
|
||||
import { computed, inject, onMounted, onBeforeUnmount, ref } from 'vue';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
import { useConfiguratorBar } from '@/layout/composables/useConfiguratorBar';
|
||||
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options';
|
||||
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant } = useLayout();
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant, setRailOpenMode } = useLayout();
|
||||
const { open, close } = useConfiguratorBar();
|
||||
|
||||
// ── Fechar ao clicar fora ────────────────────────────────────
|
||||
const panelEl = ref(null);
|
||||
function onDocClick(e) {
|
||||
if (!open.value) return;
|
||||
if (!panelEl.value?.contains(e.target)) close();
|
||||
}
|
||||
onMounted(() => document.addEventListener('mousedown', onDocClick, true));
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick, true));
|
||||
|
||||
// ✅ vem do AppTopbar (mesma instância)
|
||||
const queuePatch = inject('queueUserSettingsPatch', null);
|
||||
@@ -74,8 +85,11 @@ function updateColors(type, item) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="config-panel hidden absolute top-[3.25rem] right-0 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-top shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)]"
|
||||
v-if="open"
|
||||
ref="panelEl"
|
||||
class="config-panel fixed bottom-18 left-18 w-64 p-4 bg-surface-0 dark:bg-surface-900 border border-surface rounded-border origin-bottom-left shadow-[0px_3px_5px_rgba(0,0,0,0.02),0px_0px_2px_rgba(0,0,0,0.05),0px_1px_4px_rgba(0,0,0,0.08)] z-2000"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
@@ -122,6 +136,22 @@ function updateColors(type, item) {
|
||||
<SelectButton v-model="menuModeModel" :options="menuModeOptions" :allowEmpty="false" optionLabel="label" optionValue="value" />
|
||||
</div>
|
||||
|
||||
<!-- Abrir Menu: visível apenas no Layout Rail -->
|
||||
<div v-show="layoutConfig.variant === 'rail'" class="flex flex-col gap-1">
|
||||
<span class="text-sm text-muted-color font-semibold">Abrir Menu</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
<button type="button" class="layout-option" :class="{ 'layout-option--active': layoutConfig.railOpenMode === 'hover' }" @click="setRailOpenMode('hover')">
|
||||
<i :class="layoutConfig.railOpenMode === 'hover' ? 'pi pi-check-circle' : 'pi pi-circle'" class="layout-option__icon" />
|
||||
<span class="layout-option__label">Mouse Hover</span>
|
||||
<span v-if="layoutConfig.railOpenMode === 'hover'" class="layout-option__badge layout-option__badge--default">Padrão</span>
|
||||
</button>
|
||||
<button type="button" class="layout-option" :class="{ 'layout-option--active': layoutConfig.railOpenMode === 'click' }" @click="setRailOpenMode('click')">
|
||||
<i :class="layoutConfig.railOpenMode === 'click' ? 'pi pi-check-circle' : 'pi pi-circle'" class="layout-option__icon" />
|
||||
<span class="layout-option__label">Com Clique</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm text-muted-color font-semibold">Layout</span>
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -141,6 +171,7 @@ function updateColors(type, item) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -51,6 +51,7 @@ const route = useRoute();
|
||||
const noticeStore = useNoticeStore();
|
||||
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant, effectiveMenuMode } = useLayout();
|
||||
|
||||
|
||||
const layoutArea = computed(() => route.meta?.area || null);
|
||||
provide('layoutArea', layoutArea);
|
||||
|
||||
|
||||
@@ -303,24 +303,14 @@ defineExpose({ toggle });
|
||||
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon', 'text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150']" />
|
||||
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
|
||||
</button>
|
||||
<div class="relative footer-theme-panel">
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
v-styleclass="{
|
||||
selector: '@next',
|
||||
enterFromClass: 'hidden',
|
||||
enterActiveClass: 'p-anchored-overlay-enter-active',
|
||||
leaveToClass: 'hidden',
|
||||
leaveActiveClass: 'p-anchored-overlay-leave-active',
|
||||
hideOnOutsideClick: true
|
||||
}"
|
||||
>
|
||||
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Cores do tema
|
||||
</button>
|
||||
<AppConfigurator />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="toggleThemeBar"
|
||||
>
|
||||
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Cores do tema
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Sair -->
|
||||
|
||||
@@ -271,7 +271,7 @@ async function irLinkCadastro() {
|
||||
<Popover v-if="item.quickCreate" ref="pop">
|
||||
<div class="flex flex-col gap-0.5 min-w-[190px] py-0.5">
|
||||
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="abrirCadastroRapido">
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-bolt text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -281,7 +281,7 @@ async function irLinkCadastro() {
|
||||
</button>
|
||||
|
||||
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="irCadastroCompleto">
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0 bg-emerald-500/10 text-emerald-600">
|
||||
<i class="pi pi-user-plus text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -293,7 +293,7 @@ async function irLinkCadastro() {
|
||||
<div class="mx-3 my-1 border-t border-[var(--surface-border,#e2e8f0)]" />
|
||||
|
||||
<button class="flex items-center gap-2.5 px-3 py-2 rounded-md cursor-pointer border-0 bg-transparent text-left w-full transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)]" @click="irLinkCadastro">
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0 bg-sky-500/10 text-sky-600">
|
||||
<div class="w-7 h-7 rounded-md flex items-center justify-center shrink-0 bg-sky-500/10 text-sky-600">
|
||||
<i class="pi pi-link text-xs" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -25,7 +25,41 @@ import { sessionUser } from '@/app/session';
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue';
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
const { layoutState } = useLayout();
|
||||
const { layoutState, layoutConfig, clearRailHoverClose, scheduleRailHoverClose } = useLayout();
|
||||
|
||||
// ── Hover com delay ──────────────────────────────────────────
|
||||
let _hoverOpenTimer = null;
|
||||
|
||||
function onRailMouseLeave() {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
clearTimeout(_hoverOpenTimer);
|
||||
scheduleRailHoverClose(200);
|
||||
}
|
||||
|
||||
function onRailMouseEnter() {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
clearRailHoverClose();
|
||||
}
|
||||
|
||||
function onSectionHover(section) {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
clearRailHoverClose();
|
||||
clearTimeout(_hoverOpenTimer);
|
||||
_hoverOpenTimer = setTimeout(() => {
|
||||
layoutState.railSectionKey = section.key;
|
||||
layoutState.railPanelOpen = true;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function onHomeHover() {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
clearRailHoverClose();
|
||||
clearTimeout(_hoverOpenTimer);
|
||||
_hoverOpenTimer = setTimeout(() => {
|
||||
layoutState.railSectionKey = '__home__';
|
||||
layoutState.railPanelOpen = true;
|
||||
}, 120);
|
||||
}
|
||||
|
||||
// ── Seções do rail (derivadas do model) ─────────────────────
|
||||
const railSections = computed(() => {
|
||||
@@ -98,7 +132,7 @@ function toggleUserMenu(e) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="rail w-[60px] shrink-0 h-screen flex flex-col items-center border-r border-[var(--surface-border)] bg-[var(--surface-card)] z-50 select-none">
|
||||
<aside class="rail w-[60px] shrink-0 h-screen flex flex-col items-center border-r border-[var(--surface-border)] bg-[var(--surface-card)] z-50 select-none" @mouseenter="onRailMouseEnter" @mouseleave="onRailMouseLeave">
|
||||
<!-- ── Brand ──────────────────────────────────────────── -->
|
||||
<div class="w-full h-14 shrink-0 grid place-items-center border-b border-[var(--surface-border)]">
|
||||
<span class="text-[1.35rem] font-extrabold leading-none text-[var(--primary-color)] [text-shadow:0_0_20px_color-mix(in_srgb,var(--primary-color)_40%,transparent)]">Ψ</span>
|
||||
@@ -113,6 +147,7 @@ function toggleUserMenu(e) {
|
||||
v-tooltip.right="{ value: 'Início', showDelay: 0 }"
|
||||
aria-label="Início"
|
||||
@click="selectHome"
|
||||
@mouseenter="onHomeHover"
|
||||
>
|
||||
<i class="pi pi-fw pi-home" />
|
||||
</button>
|
||||
@@ -125,6 +160,7 @@ function toggleUserMenu(e) {
|
||||
v-tooltip.right="{ value: section.label, showDelay: 0 }"
|
||||
:aria-label="section.label"
|
||||
@click="selectSection(section)"
|
||||
@mouseenter="onSectionHover(section)"
|
||||
>
|
||||
<i :class="section.icon" />
|
||||
</button>
|
||||
|
||||
@@ -28,7 +28,7 @@ import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
|
||||
|
||||
const menuStore = useMenuStore();
|
||||
const { layoutState } = useLayout();
|
||||
const { layoutState, layoutConfig, clearRailHoverClose, scheduleRailHoverClose } = useLayout();
|
||||
const entitlements = useEntitlementsStore();
|
||||
const menuBadges = useMenuBadges();
|
||||
const router = useRouter();
|
||||
@@ -107,6 +107,16 @@ function closePanel() {
|
||||
layoutState.railPanelOpen = false;
|
||||
}
|
||||
|
||||
// ── Hover: mantém painel aberto enquanto mouse está dentro ───
|
||||
function onPanelMouseEnter() {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
clearRailHoverClose();
|
||||
}
|
||||
function onPanelMouseLeave() {
|
||||
if (layoutConfig.railOpenMode !== 'hover') return;
|
||||
scheduleRailHoverClose(200);
|
||||
}
|
||||
|
||||
// ── QuickCreate (Pacientes) ───────────────────────────────
|
||||
const createPopover = ref(null);
|
||||
const quickDialog = ref(false);
|
||||
@@ -301,7 +311,14 @@ async function goToResult(r) {
|
||||
|
||||
<template>
|
||||
<Transition name="panel-slide">
|
||||
<aside v-if="layoutState.railPanelOpen" class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden" aria-label="Menu lateral">
|
||||
<aside
|
||||
v-if="layoutState.railPanelOpen"
|
||||
class="w-[260px] shrink-0 h-screen flex flex-col border-r border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden"
|
||||
:class="{ 'rp-panel--hover': layoutConfig.railOpenMode === 'hover' }"
|
||||
aria-label="Menu lateral"
|
||||
@mouseenter="onPanelMouseEnter"
|
||||
@mouseleave="onPanelMouseLeave"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="h-14 shrink-0 flex items-center justify-between px-4 border-b border-[var(--surface-border)]">
|
||||
<span class="text-[0.9rem] font-bold tracking-tight text-[var(--text-color)]">
|
||||
@@ -483,6 +500,18 @@ async function goToResult(r) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Hover mode: painel flutua sobre o conteúdo (não empurra) ──
|
||||
Reserva o espaço da barra de ícones (60px) e do topbar (56px),
|
||||
igual ao comportamento do .layout-sidebar no layout clássico. */
|
||||
.rp-panel--hover {
|
||||
position: fixed !important;
|
||||
top: calc(56px + var(--notice-banner-height, 0px)) !important;
|
||||
left: 60px !important;
|
||||
height: calc(100vh - 56px - var(--notice-banner-height, 0px)) !important;
|
||||
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.06) !important;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.panel-slide-enter-active,
|
||||
.panel-slide-leave-active {
|
||||
transition:
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useConfiguratorBar } from '@/layout/composables/useConfiguratorBar';
|
||||
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options';
|
||||
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
|
||||
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant, layoutState, isMobile, effectiveVariant, effectiveMenuMode } = useLayout();
|
||||
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant, setRailOpenMode, layoutState, isMobile, effectiveVariant, effectiveMenuMode, railPanelPushesLayout } = useLayout();
|
||||
const { close } = useConfiguratorBar();
|
||||
|
||||
const { init: initSettings, queuePatch } = useUserSettingsPersistence();
|
||||
@@ -35,7 +35,7 @@ onMounted(() => initSettings());
|
||||
const leftOffset = computed(() => {
|
||||
if (isMobile.value) return '0px';
|
||||
if (effectiveVariant.value === 'rail') {
|
||||
const panelW = layoutState.railPanelOpen ? 260 : 0;
|
||||
const panelW = railPanelPushesLayout.value ? 260 : 0;
|
||||
return `${60 + panelW}px`;
|
||||
}
|
||||
// Clássico
|
||||
@@ -151,6 +151,34 @@ function handleSetVariant(v) {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── Abrir Menu (somente layout rail) ─────────── -->
|
||||
<template v-if="effectiveVariant === 'rail'">
|
||||
<div class="theme-bar__divider" />
|
||||
<div class="theme-bar__section">
|
||||
<span class="theme-bar__label">Abrir Menu</span>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="variant-btn"
|
||||
:class="{ 'variant-btn--active': layoutConfig.railOpenMode === 'hover' }"
|
||||
@click="setRailOpenMode('hover')"
|
||||
>
|
||||
<i :class="layoutConfig.railOpenMode === 'hover' ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
||||
Hover
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="variant-btn"
|
||||
:class="{ 'variant-btn--active': layoutConfig.railOpenMode === 'click' }"
|
||||
@click="setRailOpenMode('click')"
|
||||
>
|
||||
<i :class="layoutConfig.railOpenMode === 'click' ? 'pi pi-check-circle' : 'pi pi-circle'" />
|
||||
Clique
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="theme-bar__divider" />
|
||||
|
||||
<!-- ── Layout variant ────────────────────────────── -->
|
||||
|
||||
@@ -17,11 +17,25 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useLayout } from '@/layout/composables/layout';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const showMenu = ref(false);
|
||||
const asideOpen = ref(false);
|
||||
|
||||
// ── Layout-aware left position (igual ao TherapistDashboard) ──────────────
|
||||
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
|
||||
const isMobileLayout = computed(() => isMobile.value);
|
||||
const asideLeft = computed(() => {
|
||||
if (isMobileLayout.value) return undefined;
|
||||
if (effectiveVariant.value !== 'rail') {
|
||||
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
|
||||
return isStaticActive ? '20rem' : '0';
|
||||
}
|
||||
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
|
||||
});
|
||||
|
||||
// ── Hero sticky ────────────────────────────────────────────
|
||||
const headerEl = ref(null);
|
||||
@@ -118,6 +132,14 @@ const secoes = [
|
||||
to: '/configuracoes/whatsapp',
|
||||
tags: ['WhatsApp', 'Mensagens', 'Notificações']
|
||||
},
|
||||
{
|
||||
key: 'whatsapp-twilio',
|
||||
label: 'WhatsApp Oficial',
|
||||
desc: 'Número WhatsApp Business exclusivo via Twilio.',
|
||||
icon: 'pi pi-whatsapp',
|
||||
to: '/configuracoes/whatsapp-twilio',
|
||||
tags: ['WhatsApp', 'Twilio', 'Número Exclusivo']
|
||||
},
|
||||
{
|
||||
key: 'sms',
|
||||
label: 'SMS',
|
||||
@@ -147,6 +169,7 @@ const activeSecao = computed(() => secoes.find((s) => s.to === activeTo.value));
|
||||
function ir(to) {
|
||||
if (!to) return;
|
||||
if (route.path !== to) router.push(to);
|
||||
asideOpen.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -170,104 +193,160 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
<div class="flex min-h-screen bg-[var(--surface-ground)]">
|
||||
<!-- Overlay mobile -->
|
||||
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
|
||||
|
||||
<!-- Hero compacto -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
>
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-300/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)]">
|
||||
<i class="pi pi-cog text-base" />
|
||||
<!-- Aside drawer -->
|
||||
<aside
|
||||
class="cfg-aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card)] border-r border-[var(--surface-border)]"
|
||||
:class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'"
|
||||
:style="{ left: asideLeft }"
|
||||
>
|
||||
<!-- Cabeçalho da aside -->
|
||||
<div class="flex items-center gap-2 px-4 py-3.5 border-b border-[var(--surface-border)] shrink-0">
|
||||
<div class="grid place-items-center w-8 h-8 rounded-md bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)] shrink-0">
|
||||
<i class="pi pi-cog text-sm" />
|
||||
</div>
|
||||
<div class="min-w-0 lg:block">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Configurações</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)]">
|
||||
<span v-if="activeSecao"> <i :class="activeSecao.icon" class="text-xs mr-1 opacity-60" />{{ activeSecao.label }} </span>
|
||||
<span v-else>Configurações gerais</span>
|
||||
<span class="text-sm font-bold text-[var(--text-color)] tracking-tight">Configurações</span>
|
||||
</div>
|
||||
|
||||
<!-- Label seções -->
|
||||
<div class="flex items-center gap-1.5 px-4 pt-3 pb-1.5 text-[0.68rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-60">
|
||||
<i class="pi pi-list text-[0.65rem]" />
|
||||
<span>Seções</span>
|
||||
</div>
|
||||
|
||||
<!-- Itens de navegação -->
|
||||
<TransitionGroup name="menu" tag="div" class="flex flex-col gap-0 px-2 pb-3">
|
||||
<button
|
||||
v-for="(s, i) in showMenu ? secoes : []"
|
||||
:key="s.key"
|
||||
:style="{ transitionDelay: `${i * 40}ms` }"
|
||||
class="flex items-center gap-2.5 px-3 py-2.5 rounded-md cursor-pointer w-full text-left transition-colors duration-[120ms] hover:bg-[var(--surface-hover)]"
|
||||
:class="activeTo === s.to ? 'cfg-nav-item--active' : ''"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<i
|
||||
:class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-100' : 'text-[var(--text-color-secondary)] opacity-60']"
|
||||
class="text-[0.85rem] shrink-0 w-4 text-center"
|
||||
/>
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[0.88rem] font-semibold truncate" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
|
||||
<span class="text-[0.75rem] text-[var(--text-color-secondary)] opacity-70 truncate">{{ s.desc }}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] shrink-0" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-60' : 'text-[var(--text-color-secondary)] opacity-30'" />
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
</aside>
|
||||
|
||||
<!-- Área principal -->
|
||||
<div class="flex-1 min-w-0 xl:pl-[272px]">
|
||||
<!-- Sentinel -->
|
||||
<div ref="headerSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Hero compacto -->
|
||||
<div
|
||||
ref="headerEl"
|
||||
class="sticky top-[var(--layout-sticky-top,56px)] z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] px-3 py-2.5 mx-3 md:mx-4 mb-3"
|
||||
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
|
||||
>
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-300/10" />
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/[0.12] text-[var(--p-primary-500,#6366f1)]">
|
||||
<i class="pi pi-cog text-base" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<!-- Título: Configurações · Seção -->
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)] flex items-center gap-1.5 flex-wrap">
|
||||
<span>Configurações</span>
|
||||
<template v-if="activeSecao">
|
||||
<span class="text-[var(--text-color-secondary)] opacity-40 font-normal">·</span>
|
||||
<span>{{ activeSecao.label }}</span>
|
||||
</template>
|
||||
</div>
|
||||
<!-- Subtítulo: ícone + descrição da seção -->
|
||||
<div class="text-xs text-[var(--text-color-secondary)] flex items-center gap-1 mt-px">
|
||||
<template v-if="activeSecao">
|
||||
<i :class="activeSecao.icon" class="opacity-50 shrink-0" />
|
||||
<span>{{ activeSecao.desc }}</span>
|
||||
</template>
|
||||
<span v-else class="opacity-60">Configurações gerais do sistema</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-2 ml-auto shrink-0">
|
||||
<!-- Toggle aside — mobile/tablet apenas -->
|
||||
<button
|
||||
class="xl:hidden inline-flex items-center gap-1.5 h-9 px-3 rounded-full border border-[var(--surface-border)] bg-transparent text-xs font-semibold text-[var(--text-color)] cursor-pointer hover:bg-[var(--surface-hover)] transition-colors duration-150"
|
||||
@click="asideOpen = !asideOpen"
|
||||
>
|
||||
<i class="pi pi-bars text-[0.75rem]" />
|
||||
<span class="hidden sm:inline">Menu desta seção</span>
|
||||
</button>
|
||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" v-tooltip.bottom="'Voltar'" @click="router.back()" />
|
||||
<!-- Conteúdo da seção -->
|
||||
<div class="px-3 md:px-4 pb-5">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cards de seção (stats row) -->
|
||||
<div class="flex flex-wrap gap-2 px-3 md:px-4 mb-3">
|
||||
<button
|
||||
v-for="s in secoes"
|
||||
:key="s.key"
|
||||
class="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] cursor-pointer whitespace-nowrap transition-[border-color,background,box-shadow] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="activeTo === s.to ? 'cfg-sec-card--active' : 'hover:border-indigo-500/40'"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<i :class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color-secondary)] opacity-75']" class="text-[0.78rem]" />
|
||||
<span class="text-[0.78rem] font-semibold" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Layout: sidebar + conteúdo -->
|
||||
<div class="flex flex-col xl:flex-row gap-3 px-3 md:px-4 pb-5 items-start">
|
||||
<!-- Sidebar (oculto no mobile) -->
|
||||
<div class="hidden xl:flex flex-col gap-1 w-[300px] shrink-0" style="position: sticky; top: calc(var(--layout-sticky-top, 56px) + 58px); align-self: flex-start">
|
||||
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] overflow-hidden">
|
||||
<!-- Cabeçalho -->
|
||||
<div class="flex items-center gap-1.5 px-3.5 py-2.5 border-b border-[var(--surface-border)] text-[0.7rem] font-bold uppercase tracking-[0.06em] text-[var(--text-color-secondary)] opacity-65">
|
||||
<i class="pi pi-cog" />
|
||||
<span>Seções</span>
|
||||
</div>
|
||||
|
||||
<!-- Itens -->
|
||||
<TransitionGroup name="menu" tag="div" class="flex flex-col gap-0.5">
|
||||
<button
|
||||
v-for="(s, i) in showMenu ? secoes : []"
|
||||
:key="s.key"
|
||||
:style="{ transitionDelay: `${i * 50}ms` }"
|
||||
class="flex items-center gap-2.5 px-3.5 py-2.5 border-b last:border-b-0 bg-transparent cursor-pointer w-full text-left transition-colors duration-[120ms] hover:bg-[var(--surface-hover)]"
|
||||
:class="activeTo === s.to ? 'cfg-nav-item--active border border-[var(--primary-color,#6366f1)] last:border-b-1 last:rounded-bl-[6px] last:rounded-br-[6px]' : ''"
|
||||
@click="ir(s.to)"
|
||||
>
|
||||
<i :class="[s.icon, activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-100' : 'text-[var(--text-color-secondary)] opacity-60']" class="text-[0.85rem] flex-shrink-0 w-4 text-center" />
|
||||
<div class="flex-1 min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[0.90rem] font-semibold truncate" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)]' : 'text-[var(--text-color)]'">{{ s.label }}</span>
|
||||
<span class="text-[0.88rem] text-[var(--text-color-secondary)] opacity-70">{{ s.desc }}</span>
|
||||
</div>
|
||||
<i class="pi pi-chevron-right text-[0.6rem] flex-shrink-0" :class="activeTo === s.to ? 'text-[var(--primary-color,#6366f1)] opacity-60' : 'text-[var(--text-color-secondary)] opacity-30'" />
|
||||
</button>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo da seção -->
|
||||
<div class="flex-1 min-w-0 w-full">
|
||||
<router-view />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-sec-card--active {
|
||||
border-color: var(--primary-color, #6366f1);
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||||
/* Aside drawer — comportamento responsivo */
|
||||
.cfg-aside-drawer {
|
||||
position: fixed;
|
||||
top: calc(56px + var(--notice-banner-height, 0px));
|
||||
left: 0;
|
||||
height: calc(100dvh - 56px - var(--notice-banner-height, 0px));
|
||||
width: min(272px, 85vw);
|
||||
z-index: 40;
|
||||
overflow-y: auto;
|
||||
transition:
|
||||
transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
visibility 0.25s;
|
||||
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.cfg-aside-drawer {
|
||||
position: fixed;
|
||||
top: calc(56px + var(--notice-banner-height, 0px));
|
||||
height: calc(100vh - 56px - var(--notice-banner-height, 0px));
|
||||
width: 272px;
|
||||
transform: none;
|
||||
visibility: visible;
|
||||
box-shadow: none;
|
||||
z-index: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.cfg-nav-item--active {
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, var(--surface-card));
|
||||
background: color-mix(in srgb, var(--primary-color) 8%, var(--surface-card));
|
||||
}
|
||||
|
||||
/* TransitionGroup menu */
|
||||
.menu-enter-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
.menu-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -28,13 +28,22 @@ function _loadVariant() {
|
||||
return 'rail';
|
||||
}
|
||||
|
||||
function _loadRailOpenMode() {
|
||||
try {
|
||||
const v = localStorage.getItem('rail_open_mode');
|
||||
if (v === 'click' || v === 'hover') return v;
|
||||
} catch {}
|
||||
return 'hover';
|
||||
}
|
||||
|
||||
const layoutConfig = reactive({
|
||||
preset: 'Aura',
|
||||
primary: 'emerald',
|
||||
surface: null,
|
||||
darkTheme: false,
|
||||
menuMode: 'static',
|
||||
variant: _loadVariant() // 'classic' | 'rail'
|
||||
variant: _loadVariant(), // 'classic' | 'rail'
|
||||
railOpenMode: _loadRailOpenMode() // 'click' | 'hover'
|
||||
});
|
||||
|
||||
const layoutState = reactive({
|
||||
@@ -84,6 +93,9 @@ if (typeof window !== 'undefined') {
|
||||
window.addEventListener('resize', _onResize, { passive: true });
|
||||
}
|
||||
|
||||
// ── Timer compartilhado para hover do rail ───────────────────
|
||||
let _railHoverCloseTimer = null;
|
||||
|
||||
export function useLayout() {
|
||||
// ✅ garante coerência sempre que alguém usar useLayout()
|
||||
syncDarkFromDomOnce();
|
||||
@@ -188,9 +200,37 @@ export function useLayout() {
|
||||
if (fromUser) layoutState._variantDirty = true;
|
||||
};
|
||||
|
||||
const setRailOpenMode = (mode) => {
|
||||
if (mode !== 'click' && mode !== 'hover') return;
|
||||
layoutConfig.railOpenMode = mode;
|
||||
try {
|
||||
localStorage.setItem('rail_open_mode', mode);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const clearRailHoverClose = () => {
|
||||
if (_railHoverCloseTimer !== null) {
|
||||
clearTimeout(_railHoverCloseTimer);
|
||||
_railHoverCloseTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleRailHoverClose = (delay = 200) => {
|
||||
clearRailHoverClose();
|
||||
_railHoverCloseTimer = setTimeout(() => {
|
||||
layoutState.railPanelOpen = false;
|
||||
_railHoverCloseTimer = null;
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const isDarkTheme = computed(() => layoutConfig.darkTheme);
|
||||
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive);
|
||||
|
||||
// true apenas quando o painel está aberto E empurra o layout (não no modo hover)
|
||||
const railPanelPushesLayout = computed(() =>
|
||||
layoutState.railPanelOpen && layoutConfig.railOpenMode !== 'hover'
|
||||
);
|
||||
|
||||
// ── Em mobile (≤ xl / 1280px) sempre usa o layout clássico, ───────
|
||||
// independente de layoutConfig.variant
|
||||
const isMobile = computed(() => _isMobileRef.value);
|
||||
@@ -210,6 +250,10 @@ export function useLayout() {
|
||||
closeMenuOnNavigate,
|
||||
changeMenuMode,
|
||||
setVariant,
|
||||
setRailOpenMode,
|
||||
railPanelPushesLayout,
|
||||
clearRailHoverClose,
|
||||
scheduleRailHoverClose,
|
||||
isDesktop,
|
||||
isRailMobile,
|
||||
isMobile,
|
||||
|
||||
@@ -302,21 +302,6 @@ const loading = computed(() => loadingF.value || loadingB.value);
|
||||
<ConfirmDialog />
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Subheader degradê -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-ban" /></div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="cfg-subheader__title">Bloqueios</div>
|
||||
<div class="cfg-subheader__sub">Feriados e períodos em que não é possível agendar com pacientes</div>
|
||||
</div>
|
||||
<!-- Nav de ano -->
|
||||
<div class="flex items-center gap-1 shrink-0 relative z-10">
|
||||
<Button icon="pi pi-chevron-left" text rounded size="small" severity="secondary" @click="anoAnterior" />
|
||||
<span class="font-bold text-sm w-12 text-center text-[var(--primary-color)]">{{ ano }}</span>
|
||||
<Button icon="pi pi-chevron-right" text rounded size="small" severity="secondary" @click="anoProximo" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats + ações -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="blk-stat blk-stat--blue">
|
||||
@@ -524,53 +509,6 @@ const loading = computed(() => loadingF.value || loadingB.value);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color, #6366f1);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── Stats ────────────────────────────────────────── */
|
||||
.blk-stat {
|
||||
display: flex;
|
||||
|
||||
@@ -939,15 +939,6 @@ const jornadaEndDate = computed({
|
||||
<div v-if="!loading" class="flex flex-col xl:flex-row gap-4">
|
||||
<!-- ══ COLUNA ESQUERDA: CARDS ══════════════════════════════ -->
|
||||
<div class="anim-child [--delay:0ms] flex flex-col gap-3 xl:w-[58%]">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<i class="pi pi-calendar w-10 h-10 rounded-md cfg-subheader__icon" />
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Agenda</div>
|
||||
<div class="cfg-subheader__sub">Horários semanais, duração e intervalo padrão</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── CARD 1: JORNADA ─────────────────────────────────── -->
|
||||
<div class="cfg-card" :class="{ 'cfg-card--open': expandedCard === 'jornada' }">
|
||||
<!-- Cabeçalho clicável -->
|
||||
|
||||
@@ -550,15 +550,6 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<!-- Subheader (fora das colunas — ocupa a largura toda) -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-calendar-clock" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Agendador Online</div>
|
||||
<div class="cfg-subheader__sub">Personalize a aparência, fluxo e comportamento do seu agendador público</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── 2 COLUNAS: seções (esq) + preview (dir) ───────────── -->
|
||||
<div class="flex flex-col xl:flex-row gap-4 items-start">
|
||||
<!-- Coluna esquerda: todos os cards -->
|
||||
@@ -1134,62 +1125,6 @@ onMounted(load);
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 5%, transparent);
|
||||
}
|
||||
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 1rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Card status (sem accordion) ─────────────────── */
|
||||
.agd-card {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
@@ -248,28 +248,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-id-card" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Convênios</div>
|
||||
<div class="cfg-subheader__sub">Convênios e planos de saúde que você atende</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button
|
||||
label="Novo convênio"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
:disabled="pageLoading || addingNew"
|
||||
class="rounded-full"
|
||||
@click="
|
||||
addingNew = true;
|
||||
cancelEdit();
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SKELETON ══════════════════════════════════════════════ -->
|
||||
<template v-if="pageLoading || loading">
|
||||
<div class="cfg-wrap">
|
||||
@@ -474,62 +452,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Card wrap ────────────────────────────────────── */
|
||||
.cfg-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
@@ -190,28 +190,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-percentage" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Descontos por Paciente</div>
|
||||
<div class="cfg-subheader__sub">Descontos recorrentes aplicados automaticamente por paciente</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button
|
||||
label="Novo desconto"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
:disabled="pageLoading || addingNew"
|
||||
class="rounded-full"
|
||||
@click="
|
||||
addingNew = true;
|
||||
cancelEdit();
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SKELETON ══════════════════════════════════════════════ -->
|
||||
<template v-if="pageLoading || loading">
|
||||
<div class="cfg-wrap">
|
||||
@@ -396,62 +374,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Card wrap ────────────────────────────────────── */
|
||||
.cfg-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
@@ -323,18 +323,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-envelope" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Templates de E-mail</div>
|
||||
<div class="cfg-subheader__sub">Personalize os e-mails enviados aos seus pacientes</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button label="Personalizar +" icon="pi pi-palette" size="small" class="rounded-full" @click="openLayoutDlg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtro -->
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<Button v-for="opt in DOMAIN_OPTIONS" :key="String(opt.value)" :label="opt.label" size="small" :severity="filterDomain === opt.value ? 'primary' : 'secondary'" :outlined="filterDomain !== opt.value" @click="filterDomain = opt.value" />
|
||||
@@ -711,61 +699,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Layout cards ───────────────────────────────────────── */
|
||||
.layout-card {
|
||||
flex: 1;
|
||||
|
||||
@@ -162,15 +162,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-exclamation-triangle" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Exceções Financeiras</div>
|
||||
<div class="cfg-subheader__sub">Defina o que cobrar em cancelamentos, faltas e situações excepcionais</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SKELETON ══════════════════════════════════════════════ -->
|
||||
<template v-if="pageLoading || loading">
|
||||
<div v-for="n in 3" :key="n" class="cfg-wrap">
|
||||
@@ -267,62 +258,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Card wrap ────────────────────────────────────── */
|
||||
.cfg-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
@@ -298,19 +298,6 @@ onMounted(load);
|
||||
<template>
|
||||
<ConfirmDialog />
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-building" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Minha Empresa</div>
|
||||
<div class="cfg-subheader__sub">Dados da empresa, logomarca e presença digital</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button label="Exemplo" icon="pi pi-magic-wand" size="small" severity="secondary" outlined class="rounded-full" @click="preencherExemplo" />
|
||||
<Button label="Salvar" icon="pi pi-check" size="small" class="rounded-full" :loading="saving" @click="save" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SKELETON ══════════════════════════════════════════════ -->
|
||||
<template v-if="loading">
|
||||
<div class="flex gap-4 items-start">
|
||||
@@ -581,62 +568,6 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader ─────────────────────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Layout colunas ────────────────────────────────────────── */
|
||||
.preview-col {
|
||||
width: 40%;
|
||||
|
||||
@@ -217,19 +217,6 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-wallet" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Pagamento</div>
|
||||
<div class="cfg-subheader__sub">Formas de pagamento aceitas: Pix, depósito, dinheiro, cartão e convênio</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button size="small" icon="pi pi-arrows-v" label="Expandir" severity="secondary" outlined class="rounded-full" @click="expandAll" />
|
||||
<Button size="small" icon="pi pi-minus" label="Contrair" severity="secondary" outlined class="rounded-full" @click="collapseAll" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pix ──────────────────────────────────────────────────── -->
|
||||
<div class="rounded-[6px] border bg-[var(--surface-card)] overflow-hidden" :class="cfg.pix_ativo ? 'border-green-300' : 'border-[var(--surface-border)]'">
|
||||
<!-- Header -->
|
||||
@@ -493,62 +480,6 @@ onMounted(load);
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Card wrap ────────────────────────────────────── */
|
||||
.cfg-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
@@ -158,28 +158,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-tag" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">Precificação</div>
|
||||
<div class="cfg-subheader__sub">Valor padrão da sessão e preços por tipo de compromisso</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Button
|
||||
label="Novo serviço"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
:disabled="pageLoading || addingNew"
|
||||
class="rounded-full"
|
||||
@click="
|
||||
addingNew = true;
|
||||
cancelEdit();
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ SKELETON ══════════════════════════════════════════════ -->
|
||||
<template v-if="pageLoading || loading">
|
||||
<div class="cfg-wrap">
|
||||
@@ -336,62 +314,6 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── Subheader degradê ────────────────────────────── */
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── Card wrap ────────────────────────────────────── */
|
||||
.cfg-wrap {
|
||||
border: 1px solid var(--surface-border);
|
||||
|
||||
460
src/layout/configuracoes/ConfiguracoesTwilioWhatsappPage.vue
Normal file
460
src/layout/configuracoes/ConfiguracoesTwilioWhatsappPage.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/layout/configuracoes/ConfiguracoesTwilioWhatsappPage.vue
|
||||
| Data: 2026
|
||||
|--------------------------------------------------------------------------
|
||||
| Painel self-service — WhatsApp via Twilio para o tenant.
|
||||
| A clínica/terapeuta ativa, testa e monitora seu WhatsApp sem suporte.
|
||||
|
|
||||
| Estados:
|
||||
| not_provisioned — nunca foi ativado → botão "Ativar WhatsApp"
|
||||
| connected — ativo e funcionando
|
||||
| suspended — suspenso pelo admin SaaS
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useTwilioWhatsappStore } from '@/stores/twilioWhatsappStore';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const store = useTwilioWhatsappStore();
|
||||
const tenant = useTenantStore();
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────────────
|
||||
const tenantId = computed(() => tenant.tenantId);
|
||||
|
||||
const statusConfig = computed(() => {
|
||||
switch (store.myChannelStatus) {
|
||||
case 'not_provisioned':
|
||||
return {
|
||||
icon: 'pi-whatsapp',
|
||||
color: 'text-gray-400',
|
||||
bg: 'bg-gray-100',
|
||||
label: 'WhatsApp não ativado',
|
||||
sub: 'Ative para enviar lembretes automáticos aos seus pacientes.',
|
||||
severity: 'secondary',
|
||||
};
|
||||
case 'connected':
|
||||
return {
|
||||
icon: 'pi-check-circle',
|
||||
color: 'text-green-600',
|
||||
bg: 'bg-green-50',
|
||||
label: 'WhatsApp ativo',
|
||||
sub: `Enviando de ${store.myChannel?.twilio_phone_number ?? '—'}`,
|
||||
severity: 'success',
|
||||
};
|
||||
case 'suspended':
|
||||
return {
|
||||
icon: 'pi-pause-circle',
|
||||
color: 'text-orange-500',
|
||||
bg: 'bg-orange-50',
|
||||
label: 'WhatsApp suspenso',
|
||||
sub: 'Sua conta está temporariamente suspensa. Entre em contato com o suporte.',
|
||||
severity: 'warn',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'pi-exclamation-circle',
|
||||
color: 'text-red-500',
|
||||
bg: 'bg-red-50',
|
||||
label: 'Erro de conexão',
|
||||
sub: 'Verifique as configurações ou entre em contato com o suporte.',
|
||||
severity: 'danger',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ── Ativação ───────────────────────────────────────────────────────────────
|
||||
const activateDialog = ref(false);
|
||||
const activateForm = ref({ display_name: '' });
|
||||
|
||||
function openActivateDialog() {
|
||||
activateForm.value.display_name = '';
|
||||
activateDialog.value = true;
|
||||
}
|
||||
|
||||
async function doActivate() {
|
||||
try {
|
||||
const result = await store.provisionMyChannel(tenantId.value, {
|
||||
display_name: activateForm.value.display_name || undefined,
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'WhatsApp ativado!',
|
||||
detail: result.message,
|
||||
life: 6000,
|
||||
});
|
||||
activateDialog.value = false;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao ativar', detail: e.message, life: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Teste de envio ─────────────────────────────────────────────────────────
|
||||
const testToNumber = ref('');
|
||||
const testMessage = ref('Olá! Esta é uma mensagem de teste do AgenciaPsi. ✓');
|
||||
|
||||
async function doTest() {
|
||||
if (!testToNumber.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Informe o número', life: 3000 });
|
||||
return;
|
||||
}
|
||||
if (!store.myChannel?.id) return;
|
||||
try {
|
||||
const result = await store.testSendMessage(store.myChannel.id, testToNumber.value, testMessage.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Mensagem enviada!',
|
||||
detail: `SID: ${result.message_sid} — Status: ${result.status}`,
|
||||
life: 5000,
|
||||
});
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro no envio', detail: e.message, life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Logs ───────────────────────────────────────────────────────────────────
|
||||
async function loadLogs() {
|
||||
if (!tenantId.value) return;
|
||||
try {
|
||||
await store.loadMyLogs(tenantId.value);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar histórico', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
function logStatusTag(status) {
|
||||
switch (status) {
|
||||
case 'sent': return { label: 'Enviado', severity: 'info' };
|
||||
case 'delivered': return { label: 'Entregue', severity: 'success' };
|
||||
case 'read': return { label: 'Lido', severity: 'success' };
|
||||
case 'failed': return { label: 'Falhou', severity: 'danger' };
|
||||
case 'bounced': return { label: 'Bounced', severity: 'danger' };
|
||||
default: return { label: status, severity: 'secondary' };
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(dt) {
|
||||
if (!dt) return '—';
|
||||
const d = new Date(dt);
|
||||
return d.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||
+ ' ' + d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtPhone(p) {
|
||||
if (!p) return '—';
|
||||
return p.replace('whatsapp:', '');
|
||||
}
|
||||
|
||||
// ── Tabs ───────────────────────────────────────────────────────────────────
|
||||
const activeTab = ref(0);
|
||||
|
||||
function onTabChange(val) {
|
||||
activeTab.value = val;
|
||||
if (val === 1 && !store.messageLogs.length) loadLogs();
|
||||
}
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
if (tenantId.value) {
|
||||
await store.loadMyChannel(tenantId.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Loading inicial ───────────────────────────────────────────── -->
|
||||
<div v-if="store.loadingMyChannel" class="flex justify-center py-12">
|
||||
<ProgressSpinner style="width: 40px; height: 40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Card de status ─────────────────────────────────────────── -->
|
||||
<div class="status-card" :class="`status-card--${statusConfig.severity}`">
|
||||
<div class="flex items-start gap-4 flex-wrap">
|
||||
<div :class="['status-icon', statusConfig.bg, statusConfig.color]">
|
||||
<i :class="`pi ${statusConfig.icon} text-xl`" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="status-title">{{ statusConfig.label }}</h3>
|
||||
<Tag :value="statusConfig.label" :severity="statusConfig.severity" class="text-xs" />
|
||||
</div>
|
||||
<p class="status-sub">{{ statusConfig.sub }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Ação principal -->
|
||||
<div class="flex gap-2 items-center">
|
||||
<!-- Não provisionado: botão ativar -->
|
||||
<Button
|
||||
v-if="store.myChannelStatus === 'not_provisioned'"
|
||||
label="Ativar WhatsApp"
|
||||
icon="pi pi-whatsapp"
|
||||
:loading="store.provisioning"
|
||||
@click="openActivateDialog"
|
||||
/>
|
||||
|
||||
<!-- Ativo: mostrar número -->
|
||||
<template v-if="store.myChannelStatus === 'connected'">
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-phone text-green-600 text-sm" />
|
||||
<span class="font-mono font-bold text-sm">{{ store.myChannel?.twilio_phone_number }}</span>
|
||||
</div>
|
||||
<span class="text-xs text-(--text-color-secondary)">Seu número WhatsApp exclusivo</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Suspenso: contatar suporte -->
|
||||
<Button
|
||||
v-if="store.myChannelStatus === 'suspended'"
|
||||
label="Contatar suporte"
|
||||
icon="pi pi-envelope"
|
||||
severity="secondary"
|
||||
outlined
|
||||
tag="a"
|
||||
href="mailto:suporte@agenciapsi.com.br"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs (só aparecem quando provisionado) ──────────────────── -->
|
||||
<template v-if="store.myChannelStatus !== 'not_provisioned'">
|
||||
<Tabs :value="activeTab" @update:value="onTabChange">
|
||||
<TabList>
|
||||
<Tab :value="0"><i class="pi pi-cog mr-2" />Configurações</Tab>
|
||||
<Tab :value="1"><i class="pi pi-list mr-2" />Histórico</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<!-- ══ ABA 1 — Configurações ══════════════════════ -->
|
||||
<TabPanel :value="0">
|
||||
<div class="flex flex-col gap-4 pt-3">
|
||||
|
||||
<!-- Informações do canal ──────────────────── -->
|
||||
<div class="info-card">
|
||||
<div class="info-card__header">
|
||||
<i class="pi pi-info-circle opacity-60 text-sm" />
|
||||
<span class="text-sm font-semibold">Informações da conta</span>
|
||||
</div>
|
||||
<div class="info-card__body">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Número WhatsApp</span>
|
||||
<span class="font-mono font-bold">{{ store.myChannel?.twilio_phone_number ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Provedor</span>
|
||||
<span>Twilio WhatsApp Business</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Subconta ID</span>
|
||||
<span class="font-mono text-xs text-(--text-color-secondary)">
|
||||
{{ store.myChannel?.twilio_subaccount_sid?.slice(0, 20) ?? '—' }}…
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Ativado em</span>
|
||||
<span>{{ fmtDateTime(store.myChannel?.provisioned_at) }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Preço por mensagem</span>
|
||||
<span class="font-semibold">
|
||||
{{ new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(store.myChannel?.price_per_message_brl ?? 0) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Envio de teste ────────────────────────── -->
|
||||
<div v-if="store.myChannelStatus === 'connected'" class="info-card">
|
||||
<div class="info-card__header">
|
||||
<i class="pi pi-send opacity-60 text-sm" />
|
||||
<span class="text-sm font-semibold">Envio de teste</span>
|
||||
</div>
|
||||
<div class="info-card__body">
|
||||
<p class="text-sm text-(--text-color-secondary) m-0 mb-3">
|
||||
Envie uma mensagem de teste para confirmar que seu WhatsApp está funcionando.
|
||||
</p>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="field-label">Número destino (E.164)</label>
|
||||
<InputText v-model="testToNumber" placeholder="+5511999990000" class="w-full max-w-xs" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="field-label">Mensagem</label>
|
||||
<Textarea v-model="testMessage" rows="2" class="w-full max-w-lg" auto-resize />
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
label="Enviar teste"
|
||||
icon="pi pi-send"
|
||||
size="small"
|
||||
:loading="store.testingSend"
|
||||
:disabled="!testToNumber || store.testingSend"
|
||||
@click="doTest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações de uso ────────────────────── -->
|
||||
<Message severity="info" :closable="false">
|
||||
<template #messageicon><i class="pi pi-whatsapp" /></template>
|
||||
<div class="text-sm">
|
||||
<strong>Como funciona:</strong> Seu número WhatsApp Business exclusivo é usado para enviar
|
||||
lembretes de sessão, confirmações e outras notificações automáticas aos seus pacientes.
|
||||
Os envios são cobrados por mensagem conforme seu plano.
|
||||
</div>
|
||||
</Message>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ ABA 2 — Histórico ═══════════════════════════ -->
|
||||
<TabPanel :value="1">
|
||||
<div class="flex flex-col gap-3 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-(--text-color-secondary)">
|
||||
Últimas {{ store.messageLogs.length }} mensagens
|
||||
</span>
|
||||
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="store.loadingLogs" class="ml-auto" @click="loadLogs" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="store.messageLogs" :loading="store.loadingLogs" striped-rows responsive-layout="scroll" class="text-sm">
|
||||
<Column header="Destinatário" style="min-width: 140px">
|
||||
<template #body="{ data }">
|
||||
<span class="font-mono text-sm">{{ fmtPhone(data.recipient_address) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Status" style="min-width: 100px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="logStatusTag(data.status).label" :severity="logStatusTag(data.status).severity" class="text-[0.7rem]" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Enviado em" style="min-width: 140px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtDateTime(data.sent_at || data.created_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Entregue" style="min-width: 140px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtDateTime(data.delivered_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="Motivo de falha" style="min-width: 180px">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.failure_reason" class="text-xs text-red-500">{{ data.failure_reason }}</span>
|
||||
<span v-else class="text-xs text-(--text-color-secondary)">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center py-8 text-sm text-(--text-color-secondary)">
|
||||
Nenhuma mensagem enviada ainda.
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<!-- Dialog: Ativar WhatsApp ──────────────────────────────────── -->
|
||||
<Dialog
|
||||
v-model:visible="activateDialog"
|
||||
header="Ativar WhatsApp"
|
||||
modal
|
||||
:style="{ width: '440px', maxWidth: '96vw' }"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<Message severity="success" :closable="false">
|
||||
<template #messageicon><i class="pi pi-whatsapp" /></template>
|
||||
<div class="text-sm">
|
||||
Você receberá um <strong>número WhatsApp exclusivo</strong> para sua clínica.
|
||||
Suas mensagens automáticas aos pacientes serão enviadas por este número.
|
||||
</div>
|
||||
</Message>
|
||||
|
||||
<div class="field-group">
|
||||
<label class="field-label">Nome de exibição (opcional)</label>
|
||||
<InputText
|
||||
v-model="activateForm.display_name"
|
||||
placeholder="Ex: Clínica Bem Estar"
|
||||
class="w-full"
|
||||
/>
|
||||
<small class="text-(--text-color-secondary)">Identificação interna do seu canal.</small>
|
||||
</div>
|
||||
|
||||
<div class="border border-(--surface-border) rounded-lg p-3 bg-(--surface-ground)">
|
||||
<div class="text-xs font-semibold mb-2">O que está incluído:</div>
|
||||
<ul class="text-sm text-(--text-color-secondary) flex flex-col gap-1 m-0 pl-4">
|
||||
<li>Número WhatsApp Business exclusivo</li>
|
||||
<li>Envio de lembretes de sessão automáticos</li>
|
||||
<li>Confirmações e cancelamentos por WhatsApp</li>
|
||||
<li>Histórico completo de mensagens</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="activateDialog = false" />
|
||||
<Button
|
||||
label="Ativar agora"
|
||||
icon="pi pi-check"
|
||||
:loading="store.provisioning"
|
||||
:disabled="store.provisioning"
|
||||
@click="doActivate"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<Toast />
|
||||
<ConfirmDialog />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-card {
|
||||
padding: 1.25rem; border-radius: 8px;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.status-card--success { border-color: color-mix(in srgb, #22c55e 30%, transparent); }
|
||||
.status-card--warn { border-color: color-mix(in srgb, #f59e0b 30%, transparent); }
|
||||
.status-card--danger { border-color: color-mix(in srgb, #ef4444 30%, transparent); }
|
||||
.status-icon {
|
||||
display: grid; place-items: center;
|
||||
width: 3rem; height: 3rem; border-radius: 50%; flex-shrink: 0;
|
||||
}
|
||||
.status-title { font-size: 1rem; font-weight: 700; margin: 0; }
|
||||
.status-sub { font-size: 0.8rem; color: var(--text-color-secondary); margin: 4px 0 0; }
|
||||
.info-card {
|
||||
border: 1px solid var(--surface-border); border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.info-card__header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.625rem 1rem; background: var(--surface-ground);
|
||||
}
|
||||
.info-card__body { padding: 0.875rem 1rem; }
|
||||
.info-row {
|
||||
display: flex; align-items: baseline; justify-content: space-between;
|
||||
gap: 1rem; padding: 0.375rem 0;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-label { font-size: 0.75rem; color: var(--text-color-secondary); flex-shrink: 0; }
|
||||
.field-group { display: flex; flex-direction: column; gap: 0.375rem; }
|
||||
.field-label { font-size: 0.75rem; font-weight: 600; }
|
||||
</style>
|
||||
@@ -474,18 +474,6 @@ onBeforeUnmount(() => {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Subheader -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-comments" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">WhatsApp</div>
|
||||
<div class="cfg-subheader__sub">Configure a integração e os templates de mensagem do WhatsApp</div>
|
||||
</div>
|
||||
<div class="cfg-subheader__actions">
|
||||
<Tag :value="connectionTag.label" :severity="connectionTag.severity" class="text-xs" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Abas -->
|
||||
<Tabs :value="activeTab" @update:value="activeTab = $event">
|
||||
<TabList>
|
||||
@@ -730,58 +718,4 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -20px;
|
||||
right: -20px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.cfg-subheader__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -69,7 +69,8 @@ export default function saasMenu(sessionCtx, opts = {}) {
|
||||
{
|
||||
label: 'Canais',
|
||||
items: [
|
||||
{ label: 'WhatsApp', icon: 'pi pi-fw pi-whatsapp', to: '/saas/whatsapp' },
|
||||
{ label: 'WhatsApp (Evolution API)', icon: 'pi pi-fw pi-whatsapp', to: '/saas/whatsapp' },
|
||||
{ label: 'WhatsApp Twilio (Subcontas)', icon: 'pi pi-fw pi-whatsapp', to: '/saas/twilio-whatsapp' },
|
||||
{ label: 'Templates WhatsApp/SMS', icon: 'pi pi-fw pi-comment', to: '/saas/notification-templates' },
|
||||
{ label: 'Add-ons / Créditos SMS', icon: 'pi pi-fw pi-box', to: '/saas/addons' }
|
||||
]
|
||||
|
||||
@@ -92,6 +92,11 @@ const configuracoesRoutes = {
|
||||
name: 'ConfiguracoesWhatsapp',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesWhatsappPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'whatsapp-twilio',
|
||||
name: 'ConfiguracoesWhatsappTwilio',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesTwilioWhatsappPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'sms',
|
||||
name: 'ConfiguracoesSms',
|
||||
|
||||
@@ -127,6 +127,12 @@ export default {
|
||||
component: () => import('@/views/pages/saas/SaasWhatsappPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'twilio-whatsapp',
|
||||
name: 'saas-twilio-whatsapp',
|
||||
component: () => import('@/views/pages/saas/SaasTwilioWhatsappPage.vue'),
|
||||
meta: { requiresAuth: true, saasAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'addons',
|
||||
name: 'saas-addons',
|
||||
|
||||
181
src/services/twilioWhatsappService.js
Normal file
181
src/services/twilioWhatsappService.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/services/twilioWhatsappService.js
|
||||
| Data: 2026
|
||||
|--------------------------------------------------------------------------
|
||||
| Serviço de integração Twilio WhatsApp com subcontas.
|
||||
| Faz chamadas para as Edge Functions de provisionamento.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const PROVISION_FN = 'twilio-whatsapp-provision'
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function callProvision(action, payload = {}) {
|
||||
const { data, error } = await supabase.functions.invoke(PROVISION_FN, {
|
||||
body: { action, ...payload },
|
||||
})
|
||||
if (error) throw new Error(error.message || 'Erro na Edge Function')
|
||||
if (data?.error) throw new Error(data.error)
|
||||
return data
|
||||
}
|
||||
|
||||
// ── Provisionamento ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Provisiona subconta Twilio + número WhatsApp para um tenant.
|
||||
* @param {string} tenantId
|
||||
* @param {object} options - { phone_number?, country?, display_name?, cost_per_message_usd?, price_per_message_brl? }
|
||||
*/
|
||||
export async function provisionTenant(tenantId, options = {}) {
|
||||
return callProvision('provision', { tenant_id: tenantId, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove subconta: libera número + fecha subconta Twilio + soft-delete do canal.
|
||||
* @param {string} channelId
|
||||
*/
|
||||
export async function deprovisionTenant(channelId) {
|
||||
return callProvision('deprovision', { channel_id: channelId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspende subconta Twilio (bloqueia envio, mantém dados).
|
||||
* @param {string} channelId
|
||||
*/
|
||||
export async function suspendTenant(channelId) {
|
||||
return callProvision('suspend', { channel_id: channelId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Reativa subconta suspensa.
|
||||
* @param {string} channelId
|
||||
*/
|
||||
export async function reactivateTenant(channelId) {
|
||||
return callProvision('reactivate', { channel_id: channelId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Sincroniza dados de uso do mês corrente para um canal (ou todos).
|
||||
* @param {string|null} channelId - null para sincronizar todos
|
||||
*/
|
||||
export async function syncUsage(channelId = null) {
|
||||
const payload = channelId ? { channel_id: channelId } : {}
|
||||
return callProvision('sync_usage', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista números disponíveis para compra no Twilio.
|
||||
* @param {string} country - Código do país (ex: 'BR', 'US')
|
||||
* @param {string} [areaCode]
|
||||
*/
|
||||
export async function searchNumbers(country = 'BR', areaCode = null) {
|
||||
const payload = { country }
|
||||
if (areaCode) payload.area_code = areaCode
|
||||
return callProvision('search_numbers', payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Envia mensagem de teste via subconta do tenant.
|
||||
* @param {string} channelId
|
||||
* @param {string} toNumber - Número destino (E.164, ex: +5511999990000)
|
||||
* @param {string} [message]
|
||||
*/
|
||||
export async function testSend(channelId, toNumber, message = 'Mensagem de teste — AgenciaPsi ✓') {
|
||||
return callProvision('test_send', { channel_id: channelId, to: toNumber, message })
|
||||
}
|
||||
|
||||
// ── Consultas diretas ao banco ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Busca o canal WhatsApp Twilio de um tenant.
|
||||
* @param {string} tenantId
|
||||
*/
|
||||
export async function getChannel(tenantId) {
|
||||
const { data, error } = await supabase
|
||||
.from('notification_channels')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('provider', 'twilio')
|
||||
.is('deleted_at', null)
|
||||
.maybeSingle()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca todos os canais WhatsApp Twilio (para SaaS admin).
|
||||
*/
|
||||
export async function getAllChannels() {
|
||||
const { data, error } = await supabase
|
||||
.from('v_twilio_whatsapp_overview')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualiza campos de precificação de um canal.
|
||||
* @param {string} channelId
|
||||
* @param {object} pricing - { cost_per_message_usd, price_per_message_brl }
|
||||
*/
|
||||
export async function updatePricing(channelId, pricing) {
|
||||
const { data, error } = await supabase
|
||||
.from('notification_channels')
|
||||
.update({
|
||||
cost_per_message_usd: pricing.cost_per_message_usd,
|
||||
price_per_message_brl: pricing.price_per_message_brl,
|
||||
})
|
||||
.eq('id', channelId)
|
||||
.select('id, cost_per_message_usd, price_per_message_brl')
|
||||
.single()
|
||||
if (error) throw error
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca dados de uso mensal (painel admin).
|
||||
* @param {object} filters - { tenant_id?, channel_id?, period_start?, months? }
|
||||
*/
|
||||
export async function getUsageReport(filters = {}) {
|
||||
let query = supabase
|
||||
.from('twilio_subaccount_usage')
|
||||
.select('*')
|
||||
.order('period_start', { ascending: false })
|
||||
|
||||
if (filters.tenant_id) query = query.eq('tenant_id', filters.tenant_id)
|
||||
if (filters.channel_id) query = query.eq('channel_id', filters.channel_id)
|
||||
if (filters.period_start) query = query.gte('period_start', filters.period_start)
|
||||
|
||||
const limit = filters.months ? filters.months * 12 : 100
|
||||
query = query.limit(limit)
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* Busca logs de mensagens WhatsApp de um tenant (para self-service).
|
||||
* @param {string} tenantId
|
||||
* @param {number} [limit=50]
|
||||
*/
|
||||
export async function getMessageLogs(tenantId, limit = 50) {
|
||||
const { data, error } = await supabase
|
||||
.from('notification_logs')
|
||||
.select('id, recipient_address, status, sent_at, delivered_at, read_at, failed_at, failure_reason, estimated_cost_brl, created_at')
|
||||
.eq('tenant_id', tenantId)
|
||||
.eq('channel', 'whatsapp')
|
||||
.eq('provider', 'twilio')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
if (error) throw error
|
||||
return data ?? []
|
||||
}
|
||||
308
src/stores/twilioWhatsappStore.js
Normal file
308
src/stores/twilioWhatsappStore.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/stores/twilioWhatsappStore.js
|
||||
| Data: 2026
|
||||
|--------------------------------------------------------------------------
|
||||
| Pinia store para gerenciamento de subcontas Twilio WhatsApp.
|
||||
| Usado tanto pelo painel SaaS admin quanto pelo self-service do tenant.
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as service from '@/services/twilioWhatsappService'
|
||||
|
||||
export const useTwilioWhatsappStore = defineStore('twilioWhatsapp', () => {
|
||||
// ── Estado ─────────────────────────────────────────────────────────
|
||||
|
||||
// Admin: lista de todos os canais
|
||||
const allChannels = ref([])
|
||||
const loadingChannels = ref(false)
|
||||
|
||||
// Tenant self-service: canal do tenant atual
|
||||
const myChannel = ref(null)
|
||||
const loadingMyChannel = ref(false)
|
||||
|
||||
// Operações em andamento
|
||||
const provisioning = ref(false)
|
||||
const deprovisioning = ref(false)
|
||||
const suspending = ref(false)
|
||||
const reactivating = ref(false)
|
||||
const syncingUsage = ref(false)
|
||||
const testingSend = ref(false)
|
||||
|
||||
// Logs de mensagens (self-service)
|
||||
const messageLogs = ref([])
|
||||
const loadingLogs = ref(false)
|
||||
|
||||
// Dados de uso (admin)
|
||||
const usageReport = ref([])
|
||||
const loadingUsage = ref(false)
|
||||
|
||||
// Números disponíveis para compra
|
||||
const availableNumbers = ref([])
|
||||
const loadingNumbers = ref(false)
|
||||
|
||||
// Erros
|
||||
const lastError = ref(null)
|
||||
|
||||
// ── Computed ───────────────────────────────────────────────────────
|
||||
|
||||
const hasActiveChannel = computed(() => !!myChannel.value?.twilio_subaccount_sid && myChannel.value?.is_active)
|
||||
|
||||
const myChannelStatus = computed(() => {
|
||||
if (!myChannel.value) return 'not_provisioned'
|
||||
if (!myChannel.value.twilio_subaccount_sid) return 'not_provisioned'
|
||||
if (!myChannel.value.is_active) return 'suspended'
|
||||
return myChannel.value.connection_status ?? 'connected'
|
||||
})
|
||||
|
||||
const totalMonthlyCostBrl = computed(() =>
|
||||
allChannels.value.reduce((sum, ch) => sum + parseFloat(ch.current_month_cost_brl ?? 0), 0)
|
||||
)
|
||||
|
||||
const totalMonthlyRevenueBrl = computed(() =>
|
||||
allChannels.value.reduce((sum, ch) => sum + parseFloat(ch.current_month_revenue_brl ?? 0), 0)
|
||||
)
|
||||
|
||||
const totalMonthlyMarginBrl = computed(() => totalMonthlyRevenueBrl.value - totalMonthlyCostBrl.value)
|
||||
|
||||
const activeCount = computed(() => allChannels.value.filter(ch => ch.is_active).length)
|
||||
const suspendedCount = computed(() => allChannels.value.filter(ch => !ch.is_active && ch.twilio_subaccount_sid).length)
|
||||
|
||||
// ── Ações Admin ────────────────────────────────────────────────────
|
||||
|
||||
async function loadAllChannels() {
|
||||
loadingChannels.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
allChannels.value = await service.getAllChannels()
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loadingChannels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function provision(tenantId, options = {}) {
|
||||
provisioning.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.provisionTenant(tenantId, options)
|
||||
await loadAllChannels()
|
||||
return result
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
provisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deprovision(channelId) {
|
||||
deprovisioning.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.deprovisionTenant(channelId)
|
||||
await loadAllChannels()
|
||||
return result
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
deprovisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function suspend(channelId) {
|
||||
suspending.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.suspendTenant(channelId)
|
||||
const ch = allChannels.value.find(c => c.channel_id === channelId)
|
||||
if (ch) {
|
||||
ch.is_active = false
|
||||
ch.connection_status = 'disconnected'
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
suspending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reactivate(channelId) {
|
||||
reactivating.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.reactivateTenant(channelId)
|
||||
const ch = allChannels.value.find(c => c.channel_id === channelId)
|
||||
if (ch) {
|
||||
ch.is_active = true
|
||||
ch.connection_status = 'connected'
|
||||
}
|
||||
return result
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
reactivating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function syncUsageAll(channelId = null) {
|
||||
syncingUsage.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.syncUsage(channelId)
|
||||
await loadAllChannels()
|
||||
return result
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
syncingUsage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsageReport(filters = {}) {
|
||||
loadingUsage.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
usageReport.value = await service.getUsageReport(filters)
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loadingUsage.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePricing(channelId, pricing) {
|
||||
lastError.value = null
|
||||
try {
|
||||
const updated = await service.updatePricing(channelId, pricing)
|
||||
const ch = allChannels.value.find(c => c.channel_id === channelId)
|
||||
if (ch) {
|
||||
ch.cost_per_message_usd = updated.cost_per_message_usd
|
||||
ch.price_per_message_brl = updated.price_per_message_brl
|
||||
}
|
||||
return updated
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAvailableNumbers(country = 'BR', areaCode = null) {
|
||||
loadingNumbers.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.searchNumbers(country, areaCode)
|
||||
availableNumbers.value = result.numbers ?? []
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loadingNumbers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ações Tenant Self-service ──────────────────────────────────────
|
||||
|
||||
async function loadMyChannel(tenantId) {
|
||||
loadingMyChannel.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
myChannel.value = await service.getChannel(tenantId)
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loadingMyChannel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function provisionMyChannel(tenantId, options = {}) {
|
||||
provisioning.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
const result = await service.provisionTenant(tenantId, options)
|
||||
await loadMyChannel(tenantId)
|
||||
return result
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
provisioning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testSendMessage(channelId, toNumber, message) {
|
||||
testingSend.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
return await service.testSend(channelId, toNumber, message)
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
testingSend.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMyLogs(tenantId, limit = 50) {
|
||||
loadingLogs.value = true
|
||||
lastError.value = null
|
||||
try {
|
||||
messageLogs.value = await service.getMessageLogs(tenantId, limit)
|
||||
} catch (e) {
|
||||
lastError.value = e.message
|
||||
throw e
|
||||
} finally {
|
||||
loadingLogs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reset ──────────────────────────────────────────────────────────
|
||||
|
||||
function $reset() {
|
||||
allChannels.value = []
|
||||
myChannel.value = null
|
||||
messageLogs.value = []
|
||||
usageReport.value = []
|
||||
availableNumbers.value = []
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
// Estado
|
||||
allChannels, loadingChannels,
|
||||
myChannel, loadingMyChannel,
|
||||
provisioning, deprovisioning, suspending, reactivating,
|
||||
syncingUsage, testingSend,
|
||||
messageLogs, loadingLogs,
|
||||
usageReport, loadingUsage,
|
||||
availableNumbers, loadingNumbers,
|
||||
lastError,
|
||||
|
||||
// Computed
|
||||
hasActiveChannel, myChannelStatus,
|
||||
totalMonthlyCostBrl, totalMonthlyRevenueBrl, totalMonthlyMarginBrl,
|
||||
activeCount, suspendedCount,
|
||||
|
||||
// Admin
|
||||
loadAllChannels, provision, deprovision, suspend, reactivate,
|
||||
syncUsageAll, loadUsageReport, updatePricing, loadAvailableNumbers,
|
||||
|
||||
// Self-service
|
||||
loadMyChannel, provisionMyChannel, testSendMessage, loadMyLogs,
|
||||
|
||||
$reset,
|
||||
}
|
||||
})
|
||||
@@ -1270,7 +1270,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
|
||||
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
O layout Rail exibe ícones no canto esquerdo. Ao clicar em um ícone, o painel lateral expande com os itens de navegação. Disponível apenas no desktop.
|
||||
</div>
|
||||
|
||||
@@ -408,7 +408,7 @@ onBeforeUnmount(() => {
|
||||
title="Em breve"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24">
|
||||
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24">
|
||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
|
||||
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
|
||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
|
||||
@@ -423,7 +423,7 @@ onBeforeUnmount(() => {
|
||||
title="Em breve"
|
||||
class="flex items-center justify-center gap-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-color-secondary)] opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<svg class="h-4 w-4 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"
|
||||
/>
|
||||
@@ -461,7 +461,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Erro -->
|
||||
<div v-if="authError" class="rounded-xl border border-red-200 bg-red-50 dark:border-red-900/30 dark:bg-red-950/20 px-4 py-3 text-sm text-red-600 dark:text-red-400 flex items-center gap-2">
|
||||
<i class="pi pi-exclamation-triangle flex-shrink-0" />
|
||||
<i class="pi pi-exclamation-triangle shrink-0" />
|
||||
{{ authError }}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -177,19 +177,19 @@ async function submit() {
|
||||
<!-- Dicas -->
|
||||
<ul class="space-y-3">
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0" />
|
||||
Mínimo de 8 caracteres
|
||||
</li>
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-300 shrink-0" />
|
||||
Combine letras maiúsculas, minúsculas e números
|
||||
</li>
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-300 shrink-0" />
|
||||
Não reutilize a mesma senha de outros serviços
|
||||
</li>
|
||||
<li class="flex items-start gap-3 text-sm text-white/70">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-300 shrink-0" />
|
||||
Exemplo seguro: <span class="font-semibold text-white/90">"Noite#Calma7"</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -271,7 +271,7 @@ async function sendResetEmail() {
|
||||
|
||||
<!-- Aviso -->
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] px-4 py-3 flex items-start gap-3">
|
||||
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-info-circle text-[var(--text-color-secondary)] mt-0.5 shrink-0" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">Ao trocar sua senha, você será desconectado de todos os dispositivos por segurança.</div>
|
||||
</div>
|
||||
|
||||
@@ -291,19 +291,19 @@ async function sendResetEmail() {
|
||||
</div>
|
||||
<ul class="space-y-2">
|
||||
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-indigo-400 shrink-0" />
|
||||
Use pelo menos 8 caracteres com maiúscula, minúscula e número.
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-emerald-400 shrink-0" />
|
||||
Evite datas, nomes e sequências óbvias (1234, qwerty).
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-fuchsia-400 shrink-0" />
|
||||
Se estiver em computador compartilhado, encerre a sessão depois.
|
||||
</li>
|
||||
<li class="flex items-start gap-2.5 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 flex-shrink-0" />
|
||||
<span class="mt-1 h-1.5 w-1.5 rounded-full bg-amber-400 shrink-0" />
|
||||
Não reutilize a mesma senha de outros serviços.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -422,10 +422,10 @@ onMounted(fetchMeuPlanoClinic);
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-credit-card text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
@@ -435,14 +435,14 @@ onMounted(fetchMeuPlanoClinic);
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop -->
|
||||
<div class="hidden sm:flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
|
||||
<div class="hidden sm:flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" title="Atualizar" @click="fetchMeuPlanoClinic" />
|
||||
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgradeClinic" />
|
||||
</div>
|
||||
|
||||
<!-- Ações mobile -->
|
||||
<div class="flex sm:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
|
||||
<div class="flex sm:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" @click="fetchMeuPlanoClinic" />
|
||||
<Button label="Upgrade" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgradeClinic" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -480,7 +480,7 @@ onMounted(fetchMeuPlanoClinic);
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<div v-for="n in 3" :key="n" class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="w-10 h-10 rounded-full shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
@@ -574,7 +574,7 @@ onMounted(fetchMeuPlanoClinic);
|
||||
<!-- Grid de features -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||
<div v-for="f in g.items" :key="f.key" class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors" :title="f.description || f.key">
|
||||
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
|
||||
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
|
||||
@@ -623,7 +623,7 @@ onMounted(fetchMeuPlanoClinic);
|
||||
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">Mostrando até 50 eventos (mais recentes).</div>
|
||||
|
||||
@@ -338,10 +338,10 @@ onBeforeUnmount(() => {
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="relative z-1 flex items-center gap-3">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-credit-card text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
@@ -354,13 +354,13 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop (≥ xl) -->
|
||||
<div class="hidden xl:flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="hidden xl:flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchMeuPlanoTherapist" />
|
||||
<Button label="Alterar plano" icon="pi pi-arrow-up-right" class="rounded-full" @click="goUpgrade" />
|
||||
</div>
|
||||
|
||||
<!-- Ações mobile (< xl) -->
|
||||
<div class="flex xl:hidden items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" @click="fetchMeuPlanoTherapist" />
|
||||
<Button label="Alterar plano" icon="pi pi-arrow-up-right" size="small" class="rounded-full" @click="goUpgrade" />
|
||||
</div>
|
||||
@@ -399,7 +399,7 @@ onBeforeUnmount(() => {
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="flex flex-col gap-3">
|
||||
<div v-for="n in 3" :key="n" class="flex items-center gap-4 p-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
|
||||
<div class="w-10 h-10 rounded-full flex-shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="w-10 h-10 rounded-full shrink-0 bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="flex flex-col gap-2 flex-1">
|
||||
<div class="h-3.5 w-3/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
<div class="h-2.5 w-2/5 rounded-md bg-[var(--surface-border,#e2e8f0)] animate-pulse" />
|
||||
@@ -485,7 +485,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-4 gap-y-1">
|
||||
<div v-for="f in g.items" :key="f.key" class="flex items-start gap-2 py-1 px-2 rounded-md hover:bg-[var(--surface-ground,#f8fafc)] transition-colors" :title="f.description || f.key">
|
||||
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-check-circle text-emerald-500 text-[1rem] mt-0.5 shrink-0" />
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-medium truncate text-[var(--text-color)]">{{ f.key }}</div>
|
||||
<div v-if="f.description" class="text-[1rem] text-[var(--text-color-secondary)] leading-snug truncate">{{ f.description }}</div>
|
||||
@@ -533,7 +533,7 @@ onBeforeUnmount(() => {
|
||||
<pre class="m-0 text-[1rem] text-[var(--text-color-secondary)] whitespace-pre-wrap break-words opacity-60">{{ prettyMeta(ev.metadata) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] flex-shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] shrink-0">{{ fmtDate(ev.created_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-1">Mostrando até 50 eventos (mais recentes).</div>
|
||||
|
||||
@@ -275,12 +275,12 @@ onMounted(loadData);
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex flex-col gap-2.5">
|
||||
<div class="relative z-1 flex flex-col gap-2.5">
|
||||
<!-- Linha 1: brand + busca + ações -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-arrow-up-right text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
@@ -298,8 +298,8 @@ onMounted(loadData);
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" :disabled="saving" title="Atualizar" @click="loadData" />
|
||||
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="goBack" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -315,7 +315,7 @@ onMounted(loadData);
|
||||
</div>
|
||||
|
||||
<!-- Intervalo chips -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
|
||||
<button
|
||||
v-for="opt in intervalOptions"
|
||||
|
||||
@@ -360,12 +360,12 @@ watch(
|
||||
<div class="absolute w-56 h-56 -bottom-8 right-1/4 rounded-full blur-[55px] bg-fuchsia-400/[0.07]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex flex-col gap-2.5">
|
||||
<div class="relative z-1 flex flex-col gap-2.5">
|
||||
<!-- Linha 1: brand + busca + ações -->
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-sparkles text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden sm:block">
|
||||
@@ -387,8 +387,8 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex items-center gap-1 flex-shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full flex-shrink-0" :loading="loading" :disabled="upgrading" title="Recarregar" @click="fetchAll" />
|
||||
<div class="flex items-center gap-1 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full shrink-0" :loading="loading" :disabled="upgrading" title="Recarregar" @click="fetchAll" />
|
||||
<Button label="Assinatura" icon="pi pi-credit-card" severity="secondary" outlined class="rounded-full hidden sm:inline-flex" :disabled="upgrading" @click="goBilling" />
|
||||
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" :disabled="upgrading" @click="goBack" />
|
||||
</div>
|
||||
@@ -405,7 +405,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Intervalo chips -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<span class="text-[1rem] text-[var(--text-color-secondary)] opacity-70 hidden sm:inline">Preço:</span>
|
||||
<button
|
||||
v-for="opt in intervalOptions"
|
||||
@@ -431,7 +431,7 @@ watch(
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<Transition name="up-banner">
|
||||
<div v-if="requestedFeatureLabel && !loading" class="mx-3 md:mx-4 mb-3 flex items-center gap-3 px-4 py-3 rounded-md border border-amber-300/60 bg-amber-50">
|
||||
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600 flex-shrink-0">
|
||||
<div class="grid place-items-center w-8 h-8 rounded-md bg-amber-400/20 text-amber-600 shrink-0">
|
||||
<i class="pi pi-lock text-[0.95rem]" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -447,7 +447,7 @@ watch(
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
class="rounded-full flex-shrink-0"
|
||||
class="rounded-full shrink-0"
|
||||
@click="() => document.getElementById('plans-grid')?.scrollIntoView({ behavior: 'smooth', block: 'start' })"
|
||||
/>
|
||||
</div>
|
||||
@@ -524,7 +524,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Cabeçalho do card -->
|
||||
<div class="relative z-[1] flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="relative z-1 flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="text-[0.9rem]" :class="String(p.key).toLowerCase() === 'pro' ? 'pi pi-sparkles text-[var(--primary-color,#6366f1)]' : 'pi pi-leaf text-emerald-500 opacity-70'" />
|
||||
<span class="font-bold text-[0.95rem] text-[var(--text-color)]">Plano {{ String(p.key || '').toUpperCase() }}</span>
|
||||
@@ -536,7 +536,7 @@ watch(
|
||||
</div>
|
||||
|
||||
<!-- Corpo do card -->
|
||||
<div class="relative z-[1] p-4 flex flex-col gap-4 flex-1">
|
||||
<div class="relative z-1 p-4 flex flex-col gap-4 flex-1">
|
||||
<!-- Descrição + preço -->
|
||||
<div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mb-2">
|
||||
@@ -551,7 +551,7 @@ watch(
|
||||
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] p-3">
|
||||
<ul class="list-none p-0 m-0 flex flex-col gap-2.5">
|
||||
<li v-for="(b, i) in planBenefits(p.id)" :key="i" class="flex items-start gap-2">
|
||||
<i class="text-[1rem] mt-0.5 flex-shrink-0" :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle text-[var(--text-color-secondary)] opacity-40'" />
|
||||
<i class="text-[1rem] mt-0.5 shrink-0" :class="b.ok ? 'pi pi-check-circle text-emerald-500' : 'pi pi-times-circle text-[var(--text-color-secondary)] opacity-40'" />
|
||||
<span class="text-[1rem]" :class="b.ok ? 'text-[var(--text-color)]' : 'text-[var(--text-color-secondary)]'">{{ b.text }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -264,7 +264,7 @@ onMounted(init);
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="grid place-items-center w-12 h-12 rounded-full flex-shrink-0"
|
||||
class="grid place-items-center w-12 h-12 rounded-full shrink-0"
|
||||
:style="{ background: `color-mix(in srgb, ${statusInfo.color} 15%, transparent)` }"
|
||||
>
|
||||
<i :class="statusInfo.icon" class="text-xl" :style="{ color: statusInfo.color }" />
|
||||
|
||||
@@ -509,7 +509,7 @@ function mediasCount(doc) {
|
||||
</div>
|
||||
<div class="relative z-10 flex flex-wrap items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
|
||||
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center shrink-0">
|
||||
<i class="pi pi-question-circle text-xl text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -964,7 +964,7 @@ function mediasCount(doc) {
|
||||
<Button icon="pi pi-upload" label="Enviar imagem" severity="secondary" outlined :loading="uploadingIdx.has(idx)" class="w-full" @click="triggerUpload(idx)" />
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<img :src="m.url" class="w-14 h-14 object-cover rounded-md border border-[var(--surface-border)] flex-shrink-0" :alt="`Imagem ${idx + 1}`" />
|
||||
<img :src="m.url" class="w-14 h-14 object-cover rounded-md border border-[var(--surface-border)] shrink-0" :alt="`Imagem ${idx + 1}`" />
|
||||
<div class="flex flex-col gap-1 flex-1 min-w-0">
|
||||
<span class="text-[1rem] font-mono truncate text-[var(--text-color-secondary)]">{{ m.url }}</span>
|
||||
<Button icon="pi pi-refresh" label="Trocar imagem" text size="small" severity="secondary" :loading="uploadingIdx.has(idx)" @click="triggerUpload(idx)" />
|
||||
|
||||
@@ -136,7 +136,7 @@ function selecionarCat(cat) {
|
||||
</div>
|
||||
<div class="relative z-10 flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center flex-shrink-0">
|
||||
<div class="w-12 h-12 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex items-center justify-center shrink-0">
|
||||
<i class="pi pi-comments text-xl text-[var(--text-color)]" />
|
||||
</div>
|
||||
<div>
|
||||
@@ -169,7 +169,7 @@ function selecionarCat(cat) {
|
||||
<template v-else>
|
||||
<div class="flex gap-4 items-start flex-col sm:flex-row">
|
||||
<!-- Sidebar de categorias -->
|
||||
<aside v-if="categorias.length" class="w-full sm:w-48 flex-shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
|
||||
<aside v-if="categorias.length" class="w-full sm:w-48 shrink-0 sticky top-[calc(var(--layout-sticky-top,56px)+8rem)] flex flex-col sm:flex-col flex-row flex-wrap gap-1">
|
||||
<div class="text-[1rem] font-bold uppercase tracking-wider text-[var(--text-color-secondary)] opacity-60 px-2 mb-1 hidden sm:block">Categorias</div>
|
||||
<button
|
||||
class="flex items-center w-full px-2.5 py-1.5 rounded-md text-[1rem] text-[var(--text-color-secondary)] bg-transparent border-none cursor-pointer text-left transition-colors hover:bg-[var(--surface-hover)] hover:text-[var(--text-color)]"
|
||||
@@ -218,7 +218,7 @@ function selecionarCat(cat) {
|
||||
<div v-for="doc in docsComResultado" :key="doc.id" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<!-- Cabeçalho do grupo (doc) -->
|
||||
<div class="group flex items-center gap-3 px-5 py-3.5 border-b border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center flex-shrink-0">
|
||||
<div class="w-8 h-8 rounded-md bg-[color-mix(in_srgb,var(--primary-color)_10%,transparent)] text-[var(--primary-color)] flex items-center justify-center shrink-0">
|
||||
<i class="pi pi-file-edit" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -226,7 +226,7 @@ function selecionarCat(cat) {
|
||||
<div v-if="doc.categoria" class="text-[1rem] text-[var(--text-color-secondary)] opacity-60 mt-px">{{ doc.categoria }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
|
||||
class="flex items-center justify-center w-7 h-7 rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] text-[var(--text-color-secondary)] cursor-pointer shrink-0 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-[var(--surface-hover)] hover:text-[var(--primary-color)]"
|
||||
v-tooltip.top="'Editar documento'"
|
||||
@click="editarDoc(doc.id)"
|
||||
>
|
||||
|
||||
@@ -217,7 +217,7 @@ onMounted(load);
|
||||
<!-- Loading skeleton -->
|
||||
<div v-if="loading" class="flex flex-col divide-y divide-[var(--surface-border)]">
|
||||
<div v-for="i in 4" :key="i" class="flex items-center gap-4 px-5 py-4 animate-pulse">
|
||||
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] flex-shrink-0" />
|
||||
<div class="w-10 h-10 rounded-md bg-[var(--surface-ground)] shrink-0" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-3.5 w-40 rounded bg-[var(--surface-ground)]" />
|
||||
<div class="h-3 w-64 rounded bg-[var(--surface-ground)]" />
|
||||
@@ -248,7 +248,7 @@ onMounted(load);
|
||||
class="grid grid-cols-[auto_1fr_auto_auto] items-center gap-4 px-5 py-3.5 transition-colors duration-100 hover:bg-[var(--surface-ground,#f8fafc)] group"
|
||||
>
|
||||
<!-- Ícone + ordem -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<div class="w-10 h-10 rounded-md flex items-center justify-center text-lg" :class="slide.ativo ? 'bg-indigo-500/10 text-indigo-500' : 'bg-[var(--surface-ground)] text-[var(--text-color-secondary)]'">
|
||||
<i :class="['pi', slide.icon || 'pi-star']" />
|
||||
</div>
|
||||
@@ -356,7 +356,7 @@ onMounted(load);
|
||||
|
||||
<!-- Info -->
|
||||
<div class="rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)] px-4 py-3 text-xs text-[var(--text-color-secondary)] flex items-start gap-2">
|
||||
<i class="pi pi-info-circle text-indigo-500 mt-px flex-shrink-0" />
|
||||
<i class="pi pi-info-circle text-indigo-500 mt-px shrink-0" />
|
||||
<span>Clique nos pontos para navegar entre os slides ativos. A ordem e visibilidade refletem o que o usuário verá no login.</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -469,7 +469,7 @@ create policy "public_read" on public.login_carousel_slides
|
||||
|
||||
<!-- Mini preview -->
|
||||
<div class="relative overflow-hidden rounded-md p-5 flex items-center gap-4" style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%)">
|
||||
<div class="grid h-12 w-12 flex-shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
|
||||
<div class="grid h-12 w-12 shrink-0 place-items-center rounded-md bg-white/15 border border-white/20 shadow">
|
||||
<i :class="['pi', form.icon || 'pi-star', 'text-white text-xl']" />
|
||||
</div>
|
||||
<div class="min-w-0 overflow-hidden">
|
||||
|
||||
@@ -262,7 +262,7 @@ function sessionStatusLabel(session) {
|
||||
</div>
|
||||
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] flex-shrink-0">
|
||||
<div class="flex items-center justify-center w-10 h-10 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)] shrink-0">
|
||||
<i class="pi pi-headphones text-[var(--text-color)]" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -349,7 +349,7 @@ function sessionStatusLabel(session) {
|
||||
</div>
|
||||
|
||||
<div v-if="sessionNote" class="flex items-start gap-2 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-comment mt-0.5 flex-shrink-0" />
|
||||
<i class="pi pi-comment mt-0.5 shrink-0" />
|
||||
<span class="italic">{{ sessionNote }}</span>
|
||||
</div>
|
||||
|
||||
|
||||
864
src/views/pages/saas/SaasTwilioWhatsappPage.vue
Normal file
864
src/views/pages/saas/SaasTwilioWhatsappPage.vue
Normal file
@@ -0,0 +1,864 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Arquivo: src/views/pages/saas/SaasTwilioWhatsappPage.vue
|
||||
| Data: 2026
|
||||
|--------------------------------------------------------------------------
|
||||
| Painel SaaS Admin — Gerenciamento de subcontas Twilio WhatsApp.
|
||||
| Aba 1 — Visão geral: todos os tenants provisionados + KPIs do mês
|
||||
| Aba 2 — Provisionar: criar subconta para um tenant
|
||||
| Aba 3 — Consumo: relatório de uso e margem por período
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import { useConfirm } from 'primevue/useconfirm';
|
||||
import { useTwilioWhatsappStore } from '@/stores/twilioWhatsappStore';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
|
||||
const toast = useToast();
|
||||
const confirm = useConfirm();
|
||||
const store = useTwilioWhatsappStore();
|
||||
|
||||
// ── Card de setup ──────────────────────────────────────────────────────────
|
||||
const showSetupCard = ref(true);
|
||||
|
||||
// ── Tabs ───────────────────────────────────────────────────────────────────
|
||||
const activeTab = ref(0);
|
||||
|
||||
// ── Tenants ────────────────────────────────────────────────────────────────
|
||||
const tenants = ref([]);
|
||||
const tenantMap = ref({});
|
||||
const loadingTenants = ref(false);
|
||||
|
||||
async function loadTenants() {
|
||||
loadingTenants.value = true;
|
||||
try {
|
||||
const { data, error } = await supabase.from('tenants').select('id, name, kind').order('name');
|
||||
if (error) throw error;
|
||||
tenantMap.value = Object.fromEntries((data ?? []).map(t => [t.id, t.name || t.id]));
|
||||
tenants.value = (data ?? []).map(t => ({ value: t.id, label: `${t.name} (${t.kind ?? 'tenant'})` }));
|
||||
} finally {
|
||||
loadingTenants.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provisionar ────────────────────────────────────────────────────────────
|
||||
const provisionForm = ref({
|
||||
tenant_id: null,
|
||||
phone_number: '',
|
||||
country: 'BR',
|
||||
display_name: '',
|
||||
cost_per_message_usd: '0.005',
|
||||
price_per_message_brl: '',
|
||||
});
|
||||
const showNumberSearch = ref(false);
|
||||
|
||||
function calcDefaultPrice() {
|
||||
const cost = parseFloat(provisionForm.value.cost_per_message_usd) || 0;
|
||||
const usdBrl = 5.5;
|
||||
const margin = 1.4;
|
||||
provisionForm.value.price_per_message_brl = (cost * usdBrl * margin).toFixed(4);
|
||||
}
|
||||
|
||||
async function doProvision() {
|
||||
if (!provisionForm.value.tenant_id) {
|
||||
toast.add({ severity: 'warn', summary: 'Selecione um tenant', life: 3000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const opts = {
|
||||
country: provisionForm.value.country,
|
||||
display_name: provisionForm.value.display_name || undefined,
|
||||
cost_per_message_usd: provisionForm.value.cost_per_message_usd,
|
||||
price_per_message_brl: provisionForm.value.price_per_message_brl,
|
||||
};
|
||||
if (provisionForm.value.phone_number) opts.phone_number = provisionForm.value.phone_number;
|
||||
|
||||
const result = await store.provision(provisionForm.value.tenant_id, opts);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Subconta provisionada',
|
||||
detail: result.message,
|
||||
life: 5000,
|
||||
});
|
||||
activeTab.value = 0;
|
||||
resetProvisionForm();
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao provisionar', detail: e.message, life: 6000 });
|
||||
}
|
||||
}
|
||||
|
||||
function resetProvisionForm() {
|
||||
provisionForm.value = {
|
||||
tenant_id: null, phone_number: '', country: 'BR',
|
||||
display_name: '', cost_per_message_usd: '0.005', price_per_message_brl: '',
|
||||
};
|
||||
}
|
||||
|
||||
async function searchNumbers() {
|
||||
showNumberSearch.value = true;
|
||||
try {
|
||||
await store.loadAvailableNumbers(provisionForm.value.country);
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao buscar números', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
function selectNumber(num) {
|
||||
provisionForm.value.phone_number = num.phone_number;
|
||||
showNumberSearch.value = false;
|
||||
}
|
||||
|
||||
// ── Ações sobre canais existentes ─────────────────────────────────────────
|
||||
|
||||
function confirmSuspend(ch) {
|
||||
confirm.require({
|
||||
message: `Suspender WhatsApp de "${tenantMap.value[ch.tenant_id] || ch.tenant_id}"? O tenant não poderá mais enviar mensagens.`,
|
||||
header: 'Suspender subconta',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-warning',
|
||||
accept: async () => {
|
||||
try {
|
||||
await store.suspend(ch.channel_id);
|
||||
toast.add({ severity: 'success', summary: 'Subconta suspensa', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmReactivate(ch) {
|
||||
confirm.require({
|
||||
message: `Reativar WhatsApp de "${tenantMap.value[ch.tenant_id] || ch.tenant_id}"?`,
|
||||
header: 'Reativar subconta',
|
||||
icon: 'pi pi-check-circle',
|
||||
accept: async () => {
|
||||
try {
|
||||
await store.reactivate(ch.channel_id);
|
||||
toast.add({ severity: 'success', summary: 'Subconta reativada', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeprovision(ch) {
|
||||
confirm.require({
|
||||
message: `ATENÇÃO: Isso encerrará permanentemente a subconta Twilio de "${tenantMap.value[ch.tenant_id] || ch.tenant_id}" e liberará o número. Esta ação não pode ser desfeita.`,
|
||||
header: 'Encerrar subconta',
|
||||
icon: 'pi pi-times-circle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await store.deprovision(ch.channel_id);
|
||||
toast.add({ severity: 'success', summary: 'Subconta encerrada', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Teste de envio ─────────────────────────────────────────────────────────
|
||||
const testDialog = ref(false);
|
||||
const testChannel = ref(null);
|
||||
const testToNumber = ref('');
|
||||
const testMessage = ref('Mensagem de teste — AgenciaPsi ✓');
|
||||
|
||||
function openTestDialog(ch) {
|
||||
testChannel.value = ch;
|
||||
testToNumber.value = '';
|
||||
testDialog.value = true;
|
||||
}
|
||||
|
||||
async function doTestSend() {
|
||||
if (!testToNumber.value) {
|
||||
toast.add({ severity: 'warn', summary: 'Informe o número destino', life: 3000 });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await store.testSendMessage(testChannel.value.channel_id, testToNumber.value, testMessage.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Mensagem enviada',
|
||||
detail: `SID: ${result.message_sid}`,
|
||||
life: 5000,
|
||||
});
|
||||
testDialog.value = false;
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro no envio', detail: e.message, life: 5000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Precificação inline ────────────────────────────────────────────────────
|
||||
const editingPricingId = ref(null);
|
||||
const editPricing = ref({ cost_per_message_usd: '', price_per_message_brl: '' });
|
||||
|
||||
function startEditPricing(ch) {
|
||||
editingPricingId.value = ch.channel_id;
|
||||
editPricing.value = {
|
||||
cost_per_message_usd: ch.cost_per_message_usd ?? '',
|
||||
price_per_message_brl: ch.price_per_message_brl ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function savePricing(ch) {
|
||||
try {
|
||||
await store.updatePricing(ch.channel_id, {
|
||||
cost_per_message_usd: parseFloat(editPricing.value.cost_per_message_usd) || 0,
|
||||
price_per_message_brl: parseFloat(editPricing.value.price_per_message_brl) || 0,
|
||||
});
|
||||
editingPricingId.value = null;
|
||||
toast.add({ severity: 'success', summary: 'Precificação atualizada', life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Consumo ────────────────────────────────────────────────────────────────
|
||||
async function loadUsage() {
|
||||
try {
|
||||
await store.loadUsageReport({ months: 3 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao carregar consumo', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
async function syncAll() {
|
||||
try {
|
||||
const result = await store.syncUsageAll();
|
||||
toast.add({ severity: 'success', summary: 'Consumo sincronizado', detail: `${result.synced?.length ?? 0} canal(is) atualizados`, life: 3000 });
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e.message, life: 4000 });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
function statusTag(ch) {
|
||||
if (!ch.twilio_subaccount_sid) return { label: 'Não provisionado', severity: 'secondary' };
|
||||
if (!ch.is_active) return { label: 'Suspenso', severity: 'warn' };
|
||||
switch (ch.connection_status) {
|
||||
case 'connected': return { label: 'Ativo', severity: 'success' };
|
||||
case 'disconnected': return { label: 'Desconectado', severity: 'danger' };
|
||||
case 'error': return { label: 'Erro', severity: 'danger' };
|
||||
default: return { label: 'Ativo', severity: 'success' };
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBrl(v) {
|
||||
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(v ?? 0);
|
||||
}
|
||||
|
||||
function fmtUsd(v) {
|
||||
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v ?? 0);
|
||||
}
|
||||
|
||||
function fmtDate(dt) {
|
||||
if (!dt) return '—';
|
||||
return new Date(dt).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||
}
|
||||
|
||||
function fmtPeriod(dt) {
|
||||
if (!dt) return '—';
|
||||
return new Date(dt + 'T12:00:00').toLocaleDateString('pt-BR', { month: 'short', year: 'numeric' });
|
||||
}
|
||||
|
||||
const countryCodes = [
|
||||
{ label: 'Brasil (+55)', value: 'BR' },
|
||||
{ label: 'EUA (+1)', value: 'US' },
|
||||
{ label: 'Portugal (+351)', value: 'PT' },
|
||||
];
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
store.loadAllChannels(),
|
||||
loadTenants(),
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<!-- Header -->
|
||||
<div class="cfg-subheader">
|
||||
<div class="cfg-subheader__icon"><i class="pi pi-whatsapp" /></div>
|
||||
<div class="min-w-0">
|
||||
<div class="cfg-subheader__title">WhatsApp Twilio — Subcontas</div>
|
||||
<div class="cfg-subheader__sub">Provisione e gerencie subcontas Twilio com número WhatsApp dedicado por tenant</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card de configuração inicial ──────────────────────────────── -->
|
||||
<div class="setup-card" v-if="showSetupCard">
|
||||
<!-- Faixa topo colorida -->
|
||||
<div class="setup-card__stripe" />
|
||||
|
||||
<div class="setup-card__body">
|
||||
<!-- Título + fechar -->
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="setup-card__icon">
|
||||
<i class="pi pi-key text-xl" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="setup-card__title">Configuração de variáveis de ambiente</div>
|
||||
<div class="setup-card__sub">Obrigatório para que as Edge Functions Twilio funcionem</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button icon="pi pi-times" text rounded size="small" severity="secondary" class="shrink-0 mt-0.5" v-tooltip.left="'Ocultar'" @click="showSetupCard = false" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
||||
<!-- Bloco local -->
|
||||
<div class="setup-block">
|
||||
<div class="setup-block__header">
|
||||
<i class="pi pi-desktop text-sm" />
|
||||
<span>Ambiente local — <code class="setup-code-inline">.env</code> na raiz do projeto</span>
|
||||
</div>
|
||||
<pre class="setup-pre">TWILIO_ACCOUNT_SID=<span class="setup-pre__placeholder">ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
|
||||
TWILIO_AUTH_TOKEN=<span class="setup-pre__placeholder">xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx</span>
|
||||
TWILIO_WHATSAPP_WEBHOOK=<span class="setup-pre__placeholder">https://<project>.supabase.co/functions/v1/twilio-whatsapp-webhook</span>
|
||||
USD_BRL_RATE=<span class="setup-pre__placeholder">5.50</span>
|
||||
MARGIN_MULTIPLIER=<span class="setup-pre__placeholder">1.40</span></pre>
|
||||
<div class="setup-block__tip">
|
||||
<i class="pi pi-info-circle text-xs" />
|
||||
O <code class="setup-code-inline">supabase/config.toml</code> já referencia essas vars via <code class="setup-code-inline">env()</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bloco produção -->
|
||||
<div class="setup-block setup-block--prod">
|
||||
<div class="setup-block__header">
|
||||
<i class="pi pi-cloud text-sm" />
|
||||
<span>Produção — Supabase Secrets</span>
|
||||
</div>
|
||||
<pre class="setup-pre">supabase secrets set \
|
||||
TWILIO_ACCOUNT_SID=<span class="setup-pre__placeholder">ACxxx</span> \
|
||||
TWILIO_AUTH_TOKEN=<span class="setup-pre__placeholder">xxx</span> \
|
||||
TWILIO_WHATSAPP_WEBHOOK=<span class="setup-pre__placeholder">https://...</span> \
|
||||
USD_BRL_RATE=5.50 \
|
||||
MARGIN_MULTIPLIER=1.40</pre>
|
||||
<div class="setup-block__tip">
|
||||
<i class="pi pi-info-circle text-xs" />
|
||||
Após setar, faça deploy das 3 Edge Functions:
|
||||
<code class="setup-code-inline">twilio-whatsapp-provision</code>,
|
||||
<code class="setup-code-inline">process-whatsapp-queue</code>,
|
||||
<code class="setup-code-inline">twilio-whatsapp-webhook</code>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canais no menu -->
|
||||
<div class="setup-menu-info mt-3">
|
||||
<i class="pi pi-sitemap text-sm shrink-0" />
|
||||
<div class="text-sm">
|
||||
<strong>Menu SaaS — Canais:</strong>
|
||||
<span class="mx-1 text-[var(--text-color-secondary)]">WhatsApp (Evolution API)</span>
|
||||
<i class="pi pi-arrow-right text-xs opacity-40 mx-1" />
|
||||
<span class="font-semibold text-green-600">/saas/whatsapp</span>
|
||||
<span class="mx-2 opacity-30">|</span>
|
||||
<span class="mx-1 text-[var(--text-color-secondary)]">WhatsApp Twilio (Subcontas)</span>
|
||||
<i class="pi pi-arrow-right text-xs opacity-40 mx-1" />
|
||||
<span class="font-semibold text-green-600">/saas/twilio-whatsapp</span>
|
||||
<span class="ml-2 text-xs text-[var(--text-color-secondary)]">(esta página)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPIs do mês ───────────────────────────────────────────────── -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Subcontas ativas</div>
|
||||
<div class="kpi-value text-green-600">{{ store.activeCount }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Suspensas</div>
|
||||
<div class="kpi-value text-orange-500">{{ store.suspendedCount }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Custo real (mês)</div>
|
||||
<div class="kpi-value text-red-500">{{ fmtBrl(store.totalMonthlyCostBrl) }}</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-label">Margem bruta (mês)</div>
|
||||
<div class="kpi-value text-emerald-600">{{ fmtBrl(store.totalMonthlyMarginBrl) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs ──────────────────────────────────────────────────────── -->
|
||||
<Tabs :value="activeTab" @update:value="activeTab = $event">
|
||||
<TabList>
|
||||
<Tab :value="0"><i class="pi pi-list mr-2" />Visão geral</Tab>
|
||||
<Tab :value="1"><i class="pi pi-plus-circle mr-2" />Provisionar</Tab>
|
||||
<Tab :value="2" @click="loadUsage"><i class="pi pi-chart-bar mr-2" />Consumo</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<!-- ══ ABA 1 — Visão geral ════════════════════════════ -->
|
||||
<TabPanel :value="0">
|
||||
<div class="flex flex-col gap-3 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-[var(--text-color-secondary)]">
|
||||
{{ store.allChannels.length }} subconta(s) Twilio WhatsApp
|
||||
</span>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Button icon="pi pi-sync" size="small" severity="secondary" outlined label="Sincronizar uso" :loading="store.syncingUsage" @click="syncAll" />
|
||||
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="store.loadingChannels" @click="store.loadAllChannels" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :value="store.allChannels" :loading="store.loadingChannels" striped-rows responsive-layout="scroll" class="text-sm">
|
||||
<!-- Tenant -->
|
||||
<Column header="Tenant" style="min-width: 180px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">{{ tenantMap[data.tenant_id] || '—' }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] font-mono">{{ data.tenant_id?.slice(0, 8) }}…</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Número -->
|
||||
<Column header="Número WhatsApp" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<span v-if="data.twilio_phone_number" class="font-mono text-sm font-semibold">
|
||||
{{ data.twilio_phone_number }}
|
||||
</span>
|
||||
<span v-else class="text-[var(--text-color-secondary)] text-xs">—</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Status -->
|
||||
<Column header="Status" style="min-width: 110px">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="statusTag(data).label" :severity="statusTag(data).severity" class="text-[0.7rem]" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Consumo mês -->
|
||||
<Column header="Msgs (mês)" style="min-width: 90px; text-align: right">
|
||||
<template #body="{ data }">
|
||||
<div class="text-right">
|
||||
<span class="font-semibold">{{ data.current_month_sent ?? 0 }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)] ml-1">
|
||||
/ {{ data.current_month_delivered ?? 0 }} entregues
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Custo -->
|
||||
<Column header="Custo real" style="min-width: 100px; text-align: right">
|
||||
<template #body="{ data }">
|
||||
<span class="text-red-500 font-mono text-xs">
|
||||
{{ fmtBrl(data.current_month_cost_brl) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Receita / Margem -->
|
||||
<Column header="Margem" style="min-width: 110px; text-align: right">
|
||||
<template #body="{ data }">
|
||||
<div class="text-right">
|
||||
<span class="text-emerald-600 font-mono text-xs font-semibold">
|
||||
{{ fmtBrl(data.current_month_margin_brl) }}
|
||||
</span>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">
|
||||
receita {{ fmtBrl(data.current_month_revenue_brl) }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Precificação inline -->
|
||||
<Column header="Preço/msg" style="min-width: 150px">
|
||||
<template #body="{ data }">
|
||||
<div v-if="editingPricingId === data.channel_id" class="flex gap-1 items-center">
|
||||
<InputNumber v-model="editPricing.price_per_message_brl" mode="currency" currency="BRL" locale="pt-BR" :min-fraction-digits="4" :max-fraction-digits="4" class="w-24 text-xs" input-class="text-xs p-1" />
|
||||
<Button icon="pi pi-check" text rounded size="small" severity="success" @click="savePricing(data)" />
|
||||
<Button icon="pi pi-times" text rounded size="small" severity="secondary" @click="editingPricingId = null" />
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1 cursor-pointer group" @click="startEditPricing(data)">
|
||||
<span class="font-mono text-xs">{{ fmtBrl(data.price_per_message_brl) }}</span>
|
||||
<i class="pi pi-pencil text-[0.6rem] opacity-0 group-hover:opacity-50" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Provisionado em -->
|
||||
<Column header="Provisionado" style="min-width: 110px">
|
||||
<template #body="{ data }">
|
||||
<span class="text-xs">{{ fmtDate(data.provisioned_at) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<!-- Ações -->
|
||||
<Column header="" style="width: 130px">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<Button
|
||||
icon="pi pi-send"
|
||||
text rounded size="small" severity="info"
|
||||
v-tooltip.left="'Testar envio'"
|
||||
:disabled="!data.twilio_subaccount_sid || !data.is_active"
|
||||
@click="openTestDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
v-if="data.is_active"
|
||||
icon="pi pi-pause"
|
||||
text rounded size="small" severity="warning"
|
||||
v-tooltip.left="'Suspender'"
|
||||
:loading="store.suspending"
|
||||
@click="confirmSuspend(data)"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="data.twilio_subaccount_sid"
|
||||
icon="pi pi-play"
|
||||
text rounded size="small" severity="success"
|
||||
v-tooltip.left="'Reativar'"
|
||||
:loading="store.reactivating"
|
||||
@click="confirmReactivate(data)"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-trash"
|
||||
text rounded size="small" severity="danger"
|
||||
v-tooltip.left="'Encerrar subconta'"
|
||||
:disabled="!data.twilio_subaccount_sid"
|
||||
:loading="store.deprovisioning"
|
||||
@click="confirmDeprovision(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
Nenhuma subconta Twilio WhatsApp provisionada ainda.
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ ABA 2 — Provisionar ═════════════════════════════ -->
|
||||
<TabPanel :value="1">
|
||||
<div class="flex flex-col gap-4 pt-3 max-w-2xl">
|
||||
<Message severity="info" :closable="false">
|
||||
<template #messageicon><i class="pi pi-info-circle" /></template>
|
||||
Cada tenant recebe uma subconta Twilio dedicada com número WhatsApp exclusivo.
|
||||
O número precisará ser aprovado pelo WhatsApp Business via Twilio (pode levar alguns dias para números novos).
|
||||
</Message>
|
||||
|
||||
<!-- Tenant -->
|
||||
<div class="field-group">
|
||||
<label class="field-label">Tenant *</label>
|
||||
<Select
|
||||
v-model="provisionForm.tenant_id"
|
||||
:options="tenants" option-label="label" option-value="value"
|
||||
placeholder="Selecione o tenant..." filter
|
||||
:loading="loadingTenants"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Nome exibição -->
|
||||
<div class="field-group">
|
||||
<label class="field-label">Nome de exibição</label>
|
||||
<InputText v-model="provisionForm.display_name" placeholder="Ex: WhatsApp — Clínica Bem Estar" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- País + número -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="field-group">
|
||||
<label class="field-label">País *</label>
|
||||
<Select v-model="provisionForm.country" :options="countryCodes" option-label="label" option-value="value" class="w-full" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Número específico</label>
|
||||
<div class="flex gap-2">
|
||||
<InputText v-model="provisionForm.phone_number" placeholder="+5511..." class="flex-1" />
|
||||
<Button icon="pi pi-search" size="small" severity="secondary" outlined v-tooltip.top="'Buscar disponíveis'" :loading="store.loadingNumbers" @click="searchNumbers" />
|
||||
</div>
|
||||
<small class="text-[var(--text-color-secondary)]">Deixe em branco para provisionar automaticamente.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resultados da busca de números -->
|
||||
<div v-if="showNumberSearch && store.availableNumbers.length" class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
||||
<div class="px-3 py-2 bg-[var(--surface-ground)] flex items-center justify-between">
|
||||
<span class="text-xs font-semibold">Números disponíveis</span>
|
||||
<Button icon="pi pi-times" text rounded size="small" @click="showNumberSearch = false" />
|
||||
</div>
|
||||
<div class="max-h-48 overflow-y-auto divide-y divide-[var(--surface-border)]">
|
||||
<div
|
||||
v-for="num in store.availableNumbers" :key="num.phone_number"
|
||||
class="px-3 py-2 flex items-center justify-between hover:bg-[var(--surface-ground)] cursor-pointer"
|
||||
@click="selectNumber(num)"
|
||||
>
|
||||
<span class="font-mono text-sm font-semibold">{{ num.phone_number }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ num.locality }}, {{ num.region }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Precificação -->
|
||||
<div class="border border-[var(--surface-border)] rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 bg-[var(--surface-ground)] flex items-center gap-2">
|
||||
<i class="pi pi-dollar text-sm opacity-60" />
|
||||
<span class="text-sm font-semibold">Precificação por mensagem</span>
|
||||
</div>
|
||||
<div class="px-4 py-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="field-group">
|
||||
<label class="field-label">Custo Twilio (USD)</label>
|
||||
<InputText
|
||||
v-model="provisionForm.cost_per_message_usd"
|
||||
placeholder="0.005"
|
||||
class="w-full"
|
||||
@blur="calcDefaultPrice"
|
||||
/>
|
||||
<small class="text-[var(--text-color-secondary)]">Custo real da Twilio por mensagem.</small>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Preço cobrado do tenant (BRL)</label>
|
||||
<InputText v-model="provisionForm.price_per_message_brl" placeholder="0.0385" class="w-full" />
|
||||
<small class="text-[var(--text-color-secondary)]">Inclua sua margem (padrão: custo × câmbio × 1,4).</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Limpar" severity="secondary" outlined @click="resetProvisionForm" />
|
||||
<Button
|
||||
label="Provisionar subconta"
|
||||
icon="pi pi-plus"
|
||||
:loading="store.provisioning"
|
||||
:disabled="!provisionForm.tenant_id || store.provisioning"
|
||||
@click="doProvision"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<!-- ══ ABA 3 — Consumo ══════════════════════════════════ -->
|
||||
<TabPanel :value="2">
|
||||
<div class="flex flex-col gap-3 pt-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-[var(--text-color-secondary)]">Últimos 3 meses por subconta</span>
|
||||
<Button icon="pi pi-refresh" size="small" severity="secondary" outlined :loading="store.loadingUsage" class="ml-auto" @click="loadUsage" />
|
||||
</div>
|
||||
|
||||
<DataTable :value="store.usageReport" :loading="store.loadingUsage" striped-rows responsive-layout="scroll" class="text-sm">
|
||||
<Column header="Tenant" style="min-width: 160px">
|
||||
<template #body="{ data }">{{ tenantMap[data.tenant_id] || data.tenant_id?.slice(0, 8) }}</template>
|
||||
</Column>
|
||||
<Column header="Período" style="min-width: 100px">
|
||||
<template #body="{ data }">{{ fmtPeriod(data.period_start) }}</template>
|
||||
</Column>
|
||||
<Column header="Enviadas" field="messages_sent" style="min-width: 90px; text-align: right">
|
||||
<template #body="{ data }"><span class="font-semibold">{{ data.messages_sent }}</span></template>
|
||||
</Column>
|
||||
<Column header="Entregues" field="messages_delivered" style="min-width: 90px; text-align: right">
|
||||
<template #body="{ data }"><span class="text-green-600">{{ data.messages_delivered }}</span></template>
|
||||
</Column>
|
||||
<Column header="Falhas" field="messages_failed" style="min-width: 80px; text-align: right">
|
||||
<template #body="{ data }"><span class="text-red-500">{{ data.messages_failed }}</span></template>
|
||||
</Column>
|
||||
<Column header="Custo (BRL)" style="min-width: 110px; text-align: right">
|
||||
<template #body="{ data }"><span class="text-red-500 font-mono text-xs">{{ fmtBrl(data.cost_brl) }}</span></template>
|
||||
</Column>
|
||||
<Column header="Receita (BRL)" style="min-width: 110px; text-align: right">
|
||||
<template #body="{ data }"><span class="text-blue-600 font-mono text-xs">{{ fmtBrl(data.revenue_brl) }}</span></template>
|
||||
</Column>
|
||||
<Column header="Margem" style="min-width: 110px; text-align: right">
|
||||
<template #body="{ data }">
|
||||
<span class="font-semibold font-mono text-xs" :class="data.margin_brl >= 0 ? 'text-emerald-600' : 'text-red-500'">
|
||||
{{ fmtBrl(data.margin_brl) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<template #empty>
|
||||
<div class="text-center py-8 text-sm text-[var(--text-color-secondary)]">
|
||||
Nenhum dado de consumo. Clique em "Sincronizar uso" na aba Visão geral.
|
||||
</div>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
<!-- Dialog: Teste de envio ────────────────────────────────────── -->
|
||||
<Dialog v-model:visible="testDialog" header="Testar envio WhatsApp" modal :style="{ width: '420px', maxWidth: '96vw' }" :draggable="false">
|
||||
<div class="flex flex-col gap-4 py-2">
|
||||
<div v-if="testChannel" class="text-sm text-[var(--text-color-secondary)]">
|
||||
Enviando de <strong class="font-mono">{{ testChannel.twilio_phone_number }}</strong>
|
||||
({{ tenantMap[testChannel.tenant_id] || testChannel.tenant_id?.slice(0, 8) }})
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Número destino (E.164)</label>
|
||||
<InputText v-model="testToNumber" placeholder="+5511999990000" class="w-full" />
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Mensagem</label>
|
||||
<Textarea v-model="testMessage" rows="3" class="w-full" auto-resize />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Cancelar" text @click="testDialog = false" />
|
||||
<Button label="Enviar" icon="pi pi-send" :loading="store.testingSend" @click="doTestSend" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog />
|
||||
<Toast />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--primary-color, #6366f1) 30%, transparent);
|
||||
background: linear-gradient(135deg, color-mix(in srgb, var(--primary-color, #6366f1) 12%, var(--surface-card)) 0%, color-mix(in srgb, var(--primary-color, #6366f1) 4%, var(--surface-card)) 60%, var(--surface-card) 100%);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.cfg-subheader::before {
|
||||
content: '';
|
||||
position: absolute; top: -20px; right: -20px;
|
||||
width: 80px; height: 80px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 15%, transparent);
|
||||
filter: blur(20px);
|
||||
pointer-events: none;
|
||||
}
|
||||
.cfg-subheader__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2rem; height: 2rem;
|
||||
border-radius: 6px; flex-shrink: 0;
|
||||
background: color-mix(in srgb, var(--primary-color, #6366f1) 20%, transparent);
|
||||
color: var(--primary-color, #6366f1);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.cfg-subheader__title {
|
||||
font-size: 0.95rem; font-weight: 700;
|
||||
letter-spacing: -0.01em; color: var(--primary-color, #6366f1);
|
||||
}
|
||||
.cfg-subheader__sub {
|
||||
font-size: 0.75rem; color: var(--text-color-secondary); opacity: 0.85;
|
||||
}
|
||||
.kpi-card {
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
.kpi-label {
|
||||
font-size: 0.7rem; color: var(--text-color-secondary);
|
||||
text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px;
|
||||
}
|
||||
.kpi-value {
|
||||
font-size: 1.4rem; font-weight: 700; line-height: 1;
|
||||
}
|
||||
.field-group {
|
||||
display: flex; flex-direction: column; gap: 0.375rem;
|
||||
}
|
||||
.field-label {
|
||||
font-size: 0.75rem; font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Card de Setup ──────────────────────────────────────────────────────── */
|
||||
.setup-card {
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 45%, transparent);
|
||||
background: color-mix(in srgb, #fbbf24 6%, var(--surface-card));
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 12px color-mix(in srgb, #f59e0b 12%, transparent);
|
||||
}
|
||||
.setup-card__stripe {
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #f59e0b 0%, #f97316 40%, #ef4444 100%);
|
||||
}
|
||||
.setup-card__body {
|
||||
padding: 1rem 1.25rem 1.25rem;
|
||||
}
|
||||
.setup-card__icon {
|
||||
display: grid; place-items: center;
|
||||
width: 2.75rem; height: 2.75rem; border-radius: 50%; flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #f59e0b, #f97316);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 8px color-mix(in srgb, #f59e0b 40%, transparent);
|
||||
}
|
||||
.setup-card__title {
|
||||
font-size: 0.95rem; font-weight: 700; color: #92400e;
|
||||
}
|
||||
.setup-card__sub {
|
||||
font-size: 0.75rem; color: #b45309; margin-top: 2px;
|
||||
}
|
||||
.setup-block {
|
||||
background: color-mix(in srgb, #000 4%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, #f59e0b 25%, transparent);
|
||||
border-radius: 8px; overflow: hidden;
|
||||
}
|
||||
.setup-block--prod {
|
||||
border-color: color-mix(in srgb, #6366f1 30%, transparent);
|
||||
background: color-mix(in srgb, #6366f1 4%, var(--surface-card));
|
||||
}
|
||||
.setup-block__header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
background: color-mix(in srgb, #f59e0b 10%, var(--surface-ground));
|
||||
font-size: 0.75rem; font-weight: 600; color: #92400e;
|
||||
border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, transparent);
|
||||
}
|
||||
.setup-block--prod .setup-block__header {
|
||||
background: color-mix(in srgb, #6366f1 10%, var(--surface-ground));
|
||||
color: #4338ca;
|
||||
border-color: color-mix(in srgb, #6366f1 20%, transparent);
|
||||
}
|
||||
.setup-pre {
|
||||
margin: 0; padding: 0.75rem 0.875rem;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Courier New', monospace;
|
||||
font-size: 0.72rem; line-height: 1.7;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
white-space: pre-wrap; word-break: break-all;
|
||||
}
|
||||
.setup-pre__placeholder {
|
||||
color: #9ca3af; font-style: italic;
|
||||
}
|
||||
.setup-block__tip {
|
||||
display: flex; align-items: flex-start; gap: 0.4rem;
|
||||
padding: 0.5rem 0.875rem;
|
||||
font-size: 0.72rem; color: var(--text-color-secondary);
|
||||
border-top: 1px dashed color-mix(in srgb, #f59e0b 20%, transparent);
|
||||
background: color-mix(in srgb, #000 2%, transparent);
|
||||
}
|
||||
.setup-code-inline {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 0.7rem; font-style: normal;
|
||||
background: color-mix(in srgb, #6366f1 10%, var(--surface-ground));
|
||||
color: #4338ca;
|
||||
padding: 0.1em 0.35em; border-radius: 3px;
|
||||
border: 1px solid color-mix(in srgb, #6366f1 20%, transparent);
|
||||
}
|
||||
.setup-menu-info {
|
||||
display: flex; align-items: center; gap: 0.75rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: color-mix(in srgb, #22c55e 6%, var(--surface-card));
|
||||
border: 1px solid color-mix(in srgb, #22c55e 25%, transparent);
|
||||
border-radius: 7px; flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -253,10 +253,10 @@ onMounted(loadSessions);
|
||||
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<div class="relative z-[1] flex items-center gap-3 flex-wrap">
|
||||
<div class="relative z-1 flex items-center gap-3 flex-wrap">
|
||||
<!-- Brand -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-chart-bar text-base" />
|
||||
</div>
|
||||
<div class="min-w-0 hidden lg:block">
|
||||
@@ -271,13 +271,13 @@ onMounted(loadSessions);
|
||||
</div>
|
||||
|
||||
<!-- Refresh -->
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0 ml-auto">
|
||||
<div class="flex items-center gap-1.5 shrink-0 ml-auto">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Recarregar" @click="loadSessions" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seletor de período — mobile (abaixo da linha principal) -->
|
||||
<div class="xl:hidden relative z-[1] mt-2.5 flex flex-wrap gap-1.5">
|
||||
<div class="xl:hidden relative z-1 mt-2.5 flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="p in PERIODS"
|
||||
:key="p.value"
|
||||
|
||||
@@ -34,7 +34,7 @@ let _heroObserver = null;
|
||||
const agora = ref(new Date());
|
||||
const asideOpen = ref(false);
|
||||
|
||||
const { effectiveVariant, layoutState, layoutConfig, isMobile } = useLayout();
|
||||
const { effectiveVariant, layoutState, layoutConfig, isMobile, railPanelPushesLayout } = useLayout();
|
||||
// ≤ xl: aside é drawer mobile, não usa left fixo
|
||||
const isMobileLayout = computed(() => isMobile.value);
|
||||
// left do aside fixo:
|
||||
@@ -48,7 +48,7 @@ const asideLeft = computed(() => {
|
||||
const isStaticActive = layoutConfig.menuMode === 'static' && !layoutState.staticMenuInactive;
|
||||
return isStaticActive ? '20rem' : '0';
|
||||
}
|
||||
return layoutState.railPanelOpen ? 'calc(60px + 260px)' : '60px';
|
||||
return railPanelPushesLayout.value ? 'calc(60px + 260px)' : '60px';
|
||||
});
|
||||
let timer = null;
|
||||
onBeforeUnmount(() => {
|
||||
@@ -781,7 +781,7 @@ onMounted(async () => {
|
||||
══════════════════════════════════════════ -->
|
||||
<div v-if="asideOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm z-[39] xl:hidden" @click="asideOpen = false" />
|
||||
|
||||
<aside class="aside-drawer flex flex-col overflow-y-auto flex-shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]" :class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'" :style="{ left: asideLeft }">
|
||||
<aside class="aside-drawer flex flex-col overflow-y-auto shrink-0 bg-[var(--surface-card,#fff)] border-r border-[var(--surface-border,#e2e8f0)]" :class="asideOpen ? 'translate-x-0 visible' : 'max-xl:-translate-x-full max-xl:invisible'" :style="{ left: asideLeft }">
|
||||
<!-- Mini calendário -->
|
||||
<div class="p-3.5 pb-2.5 border-b border-[var(--surface-border,#e2e8f0)]">
|
||||
<div class="flex items-center gap-1.5 text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary,#64748b)] mb-2.5">
|
||||
@@ -871,7 +871,7 @@ onMounted(async () => {
|
||||
<span class="aside-ev__hora">{{ ev.hora }}</span>
|
||||
<span class="aside-ev__dur">{{ ev.dur }}</span>
|
||||
</div>
|
||||
<Avatar :label="initials(ev.nome)" shape="square" size="small" class="flex-shrink-0" />
|
||||
<Avatar :label="initials(ev.nome)" shape="square" size="small" class="shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ ev.nome }}</span>
|
||||
<div class="flex gap-1.5 mt-0.5 items-center">
|
||||
@@ -879,7 +879,7 @@ onMounted(async () => {
|
||||
<i v-if="ev.recorrente" class="pi pi-sync text-[0.6rem] text-[var(--primary-color,#6366f1)]" title="Recorrente" />
|
||||
</div>
|
||||
</div>
|
||||
<i :class="ev.statusIcon" class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" />
|
||||
<i :class="ev.statusIcon" class="text-xs text-[var(--text-color-secondary)] shrink-0" />
|
||||
</div>
|
||||
<div v-if="!eventosDoDia.length" class="flex items-center gap-2 py-3 text-[var(--text-color-secondary)] text-sm"><i class="pi pi-sun" /><span>Sem compromissos</span></div>
|
||||
</div>
|
||||
@@ -908,7 +908,7 @@ onMounted(async () => {
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5 max-h-[210px] overflow-y-auto pr-0.5">
|
||||
<div v-for="r in recorrencias" :key="r.id" class="aside-rec" @click="openRecMenu($event, r)">
|
||||
<Avatar :label="r.initials" shape="square" size="normal" class="flex-shrink-0" />
|
||||
<Avatar :label="r.initials" shape="square" size="normal" class="shrink-0" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[0.8125rem] font-semibold truncate text-[var(--text-color)]">{{ r.nome }}</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">{{ r.freq }}</span>
|
||||
@@ -953,9 +953,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<!-- Linha 1 -->
|
||||
<div class="relative z-[1] flex items-center gap-4">
|
||||
<div class="relative z-1 flex items-center gap-4">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md flex-shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<div class="grid place-items-center w-10 h-10 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
|
||||
<i class="pi pi-home text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
@@ -964,7 +964,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Controles (desktop e mobile — mesmo conteúdo, sempre visível) -->
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="load" />
|
||||
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="$router.push('/configuracoes')" />
|
||||
</div>
|
||||
@@ -973,7 +973,7 @@ onMounted(async () => {
|
||||
<Divider class="hidden xl:block my-2" />
|
||||
|
||||
<!-- Quick stats -->
|
||||
<div class="relative z-[1] mt-2">
|
||||
<div class="relative z-1 mt-2">
|
||||
<div class="flex flex-wrap gap-2.5">
|
||||
<div
|
||||
v-for="s in quickStats"
|
||||
@@ -1009,7 +1009,7 @@ onMounted(async () => {
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<i class="pi pi-calendar-clock text-[var(--primary-color,#6366f1)]" />
|
||||
<span class="truncate">Agenda · {{ labelDiaSelecionado }}</span>
|
||||
<span v-if="eventosDoDia.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-xs font-bold flex-shrink-0">{{ eventosDoDia.length }}</span>
|
||||
<span v-if="eventosDoDia.length" class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-xs font-bold shrink-0">{{ eventosDoDia.length }}</span>
|
||||
</div>
|
||||
<i class="pi transition-transform duration-200" :class="asideOpen ? 'pi-chevron-up' : 'pi-chevron-down'" />
|
||||
</button>
|
||||
@@ -1026,7 +1026,7 @@ onMounted(async () => {
|
||||
<section v-if="!loading" class="bg-[var(--surface-card,#fff)] rounded-md border border-[var(--surface-border,#e2e8f0)] p-2.5 shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="flex items-center justify-between mb-2.5">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon flex-shrink-0" />
|
||||
<i class="pi pi-chart-bar w-10 h-10 rounded-md cfg-subheader__icon shrink-0" />
|
||||
|
||||
<div class="flex flex-col leading-tight">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Linha do tempo — Hoje</div>
|
||||
@@ -1239,9 +1239,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-3 mt-2 flex-wrap">
|
||||
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-indigo-500 to-indigo-400 flex-shrink-0" />Presentes </span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-red-500 to-red-300 flex-shrink-0" />Faltas </span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-amber-500 to-amber-300 flex-shrink-0" />Reposição </span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-indigo-500 to-indigo-400 shrink-0" />Presentes </span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-red-500 to-red-300 shrink-0" />Faltas </span>
|
||||
<span class="flex items-center gap-1.5 text-sm text-[var(--text-color-secondary)] font-semibold"> <span class="w-4 h-2.5 rounded-sm bg-gradient-to-r from-amber-500 to-amber-300 shrink-0" />Reposição </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1284,7 +1284,7 @@ onMounted(async () => {
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div v-for="c in commitments" :key="c.id" class="flex items-center gap-2.5 px-2.5 py-2 rounded-md bg-[var(--surface-ground,#f8fafc)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100">
|
||||
<div
|
||||
class="w-[3px] h-7 rounded-sm flex-shrink-0"
|
||||
class="w-[3px] h-7 rounded-sm shrink-0"
|
||||
:class="{
|
||||
'bg-indigo-500': c.cor === 'blue',
|
||||
'bg-purple-500': c.cor === 'purple',
|
||||
|
||||
Reference in New Issue
Block a user