carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline

This commit is contained in:
Leonardo
2026-03-18 09:26:09 -03:00
parent 66f67cd40f
commit d6d2fe29d1
55 changed files with 3655 additions and 1512 deletions

View File

@@ -18,7 +18,9 @@
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
"Bash(find:*)",
"Bash(ls:*)"
"Bash(ls:*)",
"Bash(npx vite:*)",
"Bash(powershell -Command \"$content = [System.IO.File]::ReadAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', [System.Text.Encoding]::UTF8\\); $content = $content -replace [char]0x201C, ''\"\"'' -replace [char]0x201D, ''\"\"''; [System.IO.File]::WriteAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', $content, [System.Text.Encoding]::UTF8\\)\")"
]
}
}

View File

@@ -2,7 +2,7 @@
-- PostgreSQL database dump
--
\restrict WhNBUHGPb7r3TzvGfUbgAGypOAZRhELU6FHGPvMhYkVWhF2Y5HPG9HrKQluVdLN
\restrict exm15ajuo5LlVoZOAon82WdOxbqbyivLILLlrvWu0yn6dCEmYCyZgXRS28Q2h1h
-- Dumped from database version 17.6
-- Dumped by pg_dump version 17.6
@@ -2886,6 +2886,105 @@ $$;
ALTER FUNCTION public.my_tenants() OWNER TO supabase_admin;
--
-- Name: notify_on_intake(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
IF NEW.status = 'new' THEN
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'new_patient',
NEW.id,
'patient_intake_requests',
jsonb_build_object(
'title', 'Novo cadastro externo',
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
'deeplink', '/therapist/patients/cadastro/recebidos',
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.notify_on_intake() OWNER TO supabase_admin;
--
-- Name: notify_on_scheduling(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notify_on_scheduling() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$ BEGIN IF NEW.status = 'pendente' THEN
INSERT INTO public.notifications ( owner_id, tenant_id, type, ref_id, ref_table, payload ) VALUES (
NEW.owner_id, NEW.tenant_id,
'new_scheduling', NEW.id, 'agendador_solicitacoes', jsonb_build_object( 'title', 'Nova solicitação de agendamento', 'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || ' ' || COALESCE(NEW.tipo, ''), 'deeplink', '/therapist/agendamentos-recebidos', 'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1)) ) ); END IF; RETURN NEW; END; $$;
ALTER FUNCTION public.notify_on_scheduling() OWNER TO supabase_admin;
--
-- Name: notify_on_session_status(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
CREATE FUNCTION public.notify_on_session_status() RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
AS $$
DECLARE
v_nome text;
BEGIN
IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
-- tenta buscar nome do paciente
SELECT nome_completo
INTO v_nome
FROM public.patients
WHERE id = NEW.patient_id
LIMIT 1;
INSERT INTO public.notifications (
owner_id,
tenant_id,
type,
ref_id,
ref_table,
payload
)
VALUES (
NEW.owner_id,
NEW.tenant_id,
'session_status',
NEW.id,
'agenda_eventos',
jsonb_build_object(
'title', CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
'detail', COALESCE(v_nome, 'Paciente') || ' ' || to_char(NEW.starts_at, 'DD/MM HH24:MI'),
'deeplink', '/therapist/agenda',
'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
)
);
END IF;
RETURN NEW;
END;
$$;
ALTER FUNCTION public.notify_on_session_status() OWNER TO supabase_admin;
--
-- Name: on_new_user_seed_patient_groups(); Type: FUNCTION; Schema: public; Owner: supabase_admin
--
@@ -7832,6 +7931,24 @@ CREATE TABLE public.insurance_plans (
ALTER TABLE public.insurance_plans OWNER TO supabase_admin;
--
-- Name: login_carousel_slides; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.login_carousel_slides (
id uuid DEFAULT gen_random_uuid() NOT NULL,
title text NOT NULL,
body text NOT NULL,
icon text DEFAULT 'pi-star'::text NOT NULL,
ordem integer DEFAULT 0 NOT NULL,
ativo boolean DEFAULT true NOT NULL,
created_at timestamp with time zone DEFAULT now(),
updated_at timestamp with time zone DEFAULT now()
);
ALTER TABLE public.login_carousel_slides OWNER TO supabase_admin;
--
-- Name: module_features; Type: TABLE; Schema: public; Owner: supabase_admin
--
@@ -7863,6 +7980,27 @@ CREATE TABLE public.modules (
ALTER TABLE public.modules OWNER TO supabase_admin;
--
-- Name: notifications; Type: TABLE; Schema: public; Owner: supabase_admin
--
CREATE TABLE public.notifications (
id uuid DEFAULT gen_random_uuid() NOT NULL,
owner_id uuid NOT NULL,
tenant_id uuid,
type text NOT NULL,
ref_id uuid,
ref_table text,
payload jsonb DEFAULT '{}'::jsonb NOT NULL,
read_at timestamp with time zone,
archived boolean DEFAULT false NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text])))
);
ALTER TABLE public.notifications OWNER TO supabase_admin;
--
-- Name: plan_features; Type: TABLE; Schema: public; Owner: supabase_admin
--
@@ -10497,6 +10635,14 @@ ALTER TABLE ONLY public.insurance_plans
ADD CONSTRAINT insurance_plans_pkey PRIMARY KEY (id);
--
-- Name: login_carousel_slides login_carousel_slides_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
--
ALTER TABLE ONLY public.login_carousel_slides
ADD CONSTRAINT login_carousel_slides_pkey PRIMARY KEY (id);
--
-- Name: module_features module_features_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
--
@@ -10521,6 +10667,14 @@ ALTER TABLE ONLY public.modules
ADD CONSTRAINT modules_pkey PRIMARY KEY (id);
--
-- Name: notifications notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
--
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_pkey PRIMARY KEY (id);
--
-- Name: owner_users owner_users_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
--
@@ -12042,6 +12196,20 @@ CREATE INDEX ix_plan_public_bullets_plan ON public.plan_public_bullets USING btr
CREATE INDEX ix_plan_public_sort ON public.plan_public USING btree (sort_order);
--
-- Name: notifications_owner_created; Type: INDEX; Schema: public; Owner: supabase_admin
--
CREATE INDEX notifications_owner_created ON public.notifications USING btree (owner_id, created_at DESC);
--
-- Name: notifications_owner_unread; Type: INDEX; Schema: public; Owner: supabase_admin
--
CREATE INDEX notifications_owner_unread ON public.notifications USING btree (owner_id, read_at) WHERE (read_at IS NULL);
--
-- Name: patient_discounts_owner_idx; Type: INDEX; Schema: public; Owner: supabase_admin
--
@@ -13050,6 +13218,27 @@ CREATE TRIGGER trg_no_change_plan_target BEFORE UPDATE ON public.plans FOR EACH
CREATE TRIGGER trg_no_delete_core_plans BEFORE DELETE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_delete_core_plans();
--
-- Name: patient_intake_requests trg_notify_on_intake; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notify_on_intake AFTER INSERT ON public.patient_intake_requests FOR EACH ROW EXECUTE FUNCTION public.notify_on_intake();
--
-- Name: agendador_solicitacoes trg_notify_on_scheduling; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notify_on_scheduling AFTER INSERT ON public.agendador_solicitacoes FOR EACH ROW EXECUTE FUNCTION public.notify_on_scheduling();
--
-- Name: agenda_eventos trg_notify_on_session_status; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
CREATE TRIGGER trg_notify_on_session_status AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status();
--
-- Name: tenant_members trg_patient_cannot_own_tenant; Type: TRIGGER; Schema: public; Owner: supabase_admin
--
@@ -13643,6 +13832,14 @@ ALTER TABLE ONLY public.module_features
ADD CONSTRAINT module_features_module_id_fkey FOREIGN KEY (module_id) REFERENCES public.modules(id) ON DELETE CASCADE;
--
-- Name: notifications notifications_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin
--
ALTER TABLE ONLY public.notifications
ADD CONSTRAINT notifications_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
--
-- Name: patient_discounts patient_discounts_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin
--
@@ -14960,6 +15157,12 @@ ALTER TABLE public.insurance_plans ENABLE ROW LEVEL SECURITY;
CREATE POLICY "insurance_plans: owner full access" ON public.insurance_plans USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
--
-- Name: login_carousel_slides; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
--
ALTER TABLE public.login_carousel_slides ENABLE ROW LEVEL SECURITY;
--
-- Name: module_features; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
--
@@ -15000,6 +15203,19 @@ CREATE POLICY modules_read_authenticated ON public.modules FOR SELECT TO authent
CREATE POLICY modules_write_saas_admin ON public.modules TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
--
-- Name: notifications; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
--
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
--
-- Name: notifications owner only; Type: POLICY; Schema: public; Owner: supabase_admin
--
CREATE POLICY "owner only" ON public.notifications USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
--
-- Name: owner_users; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
--
@@ -15333,6 +15549,13 @@ CREATE POLICY profiles_select_own ON public.profiles FOR SELECT USING ((id = aut
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE USING ((id = auth.uid())) WITH CHECK ((id = auth.uid()));
--
-- Name: login_carousel_slides public_read; Type: POLICY; Schema: public; Owner: supabase_admin
--
CREATE POLICY public_read ON public.login_carousel_slides FOR SELECT USING ((ativo = true));
--
-- Name: features read features (auth); Type: POLICY; Schema: public; Owner: supabase_admin
--
@@ -15455,6 +15678,15 @@ CREATE POLICY "saas_admin can update subscription_intents" ON public.subscriptio
WHERE (a.user_id = auth.uid()))));
--
-- Name: login_carousel_slides saas_admin_full; Type: POLICY; Schema: public; Owner: supabase_admin
--
CREATE POLICY saas_admin_full ON public.login_carousel_slides USING ((EXISTS ( SELECT 1
FROM public.profiles
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text)))));
--
-- Name: saas_docs saas_admin_full_access; Type: POLICY; Schema: public; Owner: supabase_admin
--
@@ -15998,6 +16230,29 @@ CREATE PUBLICATION supabase_realtime WITH (publish = 'insert, update, delete, tr
ALTER PUBLICATION supabase_realtime OWNER TO postgres;
--
-- Name: supabase_realtime_messages_publication; Type: PUBLICATION; Schema: -; Owner: supabase_admin
--
CREATE PUBLICATION supabase_realtime_messages_publication WITH (publish = 'insert, update, delete, truncate');
ALTER PUBLICATION supabase_realtime_messages_publication OWNER TO supabase_admin;
--
-- Name: supabase_realtime notifications; Type: PUBLICATION TABLE; Schema: public; Owner: postgres
--
ALTER PUBLICATION supabase_realtime ADD TABLE ONLY public.notifications;
--
-- Name: supabase_realtime_messages_publication messages; Type: PUBLICATION TABLE; Schema: realtime; Owner: supabase_admin
--
ALTER PUBLICATION supabase_realtime_messages_publication ADD TABLE ONLY realtime.messages;
--
-- Name: SCHEMA auth; Type: ACL; Schema: -; Owner: supabase_admin
--
@@ -19290,6 +19545,36 @@ GRANT ALL ON FUNCTION public.my_tenants() TO authenticated;
GRANT ALL ON FUNCTION public.my_tenants() TO service_role;
--
-- Name: FUNCTION notify_on_intake(); Type: ACL; Schema: public; Owner: supabase_admin
--
GRANT ALL ON FUNCTION public.notify_on_intake() TO postgres;
GRANT ALL ON FUNCTION public.notify_on_intake() TO anon;
GRANT ALL ON FUNCTION public.notify_on_intake() TO authenticated;
GRANT ALL ON FUNCTION public.notify_on_intake() TO service_role;
--
-- Name: FUNCTION notify_on_scheduling(); Type: ACL; Schema: public; Owner: supabase_admin
--
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO postgres;
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO anon;
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO authenticated;
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO service_role;
--
-- Name: FUNCTION notify_on_session_status(); Type: ACL; Schema: public; Owner: supabase_admin
--
GRANT ALL ON FUNCTION public.notify_on_session_status() TO postgres;
GRANT ALL ON FUNCTION public.notify_on_session_status() TO anon;
GRANT ALL ON FUNCTION public.notify_on_session_status() TO authenticated;
GRANT ALL ON FUNCTION public.notify_on_session_status() TO service_role;
--
-- Name: FUNCTION oid_dist(oid, oid); Type: ACL; Schema: public; Owner: supabase_admin
--
@@ -20773,6 +21058,16 @@ GRANT ALL ON TABLE public.insurance_plans TO authenticated;
GRANT ALL ON TABLE public.insurance_plans TO service_role;
--
-- Name: TABLE login_carousel_slides; Type: ACL; Schema: public; Owner: supabase_admin
--
GRANT ALL ON TABLE public.login_carousel_slides TO postgres;
GRANT ALL ON TABLE public.login_carousel_slides TO anon;
GRANT ALL ON TABLE public.login_carousel_slides TO authenticated;
GRANT ALL ON TABLE public.login_carousel_slides TO service_role;
--
-- Name: TABLE module_features; Type: ACL; Schema: public; Owner: supabase_admin
--
@@ -20793,6 +21088,16 @@ GRANT ALL ON TABLE public.modules TO authenticated;
GRANT ALL ON TABLE public.modules TO service_role;
--
-- Name: TABLE notifications; Type: ACL; Schema: public; Owner: supabase_admin
--
GRANT ALL ON TABLE public.notifications TO postgres;
GRANT ALL ON TABLE public.notifications TO anon;
GRANT ALL ON TABLE public.notifications TO authenticated;
GRANT ALL ON TABLE public.notifications TO service_role;
--
-- Name: TABLE plan_features; Type: ACL; Schema: public; Owner: supabase_admin
--
@@ -21929,5 +22234,5 @@ ALTER EVENT TRIGGER pgrst_drop_watch OWNER TO supabase_admin;
-- PostgreSQL database dump complete
--
\unrestrict WhNBUHGPb7r3TzvGfUbgAGypOAZRhELU6FHGPvMhYkVWhF2Y5HPG9HrKQluVdLN
\unrestrict exm15ajuo5LlVoZOAon82WdOxbqbyivLILLlrvWu0yn6dCEmYCyZgXRS28Q2h1h

46
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/resource": "^6.1.20",
"@fullcalendar/resource-timegrid": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
@@ -24,6 +25,7 @@
"primevue": "^4.5.4",
"quill": "^2.0.3",
"tailwindcss-primeui": "^0.6.0",
"v-offline": "^3.5.1",
"vue": "^3.4.34",
"vue-router": "^4.4.0"
},
@@ -596,6 +598,14 @@
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/list": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz",
"integrity": "sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==",
"peerDependencies": {
"@fullcalendar/core": "~6.1.20"
}
},
"node_modules/@fullcalendar/premium-common": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
@@ -3945,6 +3955,11 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/ping.js": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/ping.js/-/ping.js-0.3.0.tgz",
"integrity": "sha512-qisFwio7j0cwYbOcRL4BlTdxKALcpGPTkpl8ichGASgkrVqfI3sZfQDsP8wETR5rfutXZJLjlJ117aLkRnk2mA=="
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
@@ -4746,6 +4761,18 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"node_modules/v-offline": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/v-offline/-/v-offline-3.5.1.tgz",
"integrity": "sha512-i9ydsGk9oJfMivtGI85U3m/6Bkqg07DkebbKV21t++OiwEKzArQs9GqqDETD972nNGz5q7+cr1fm6B2A1uht9A==",
"dependencies": {
"ping.js": "^0.3.0"
},
"peerDependencies": {
"ping.js": "^0.3.0",
"vue": "^3.5.27"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -5826,6 +5853,12 @@
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"requires": {}
},
"@fullcalendar/list": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz",
"integrity": "sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==",
"requires": {}
},
"@fullcalendar/premium-common": {
"version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
@@ -7991,6 +8024,11 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true
},
"ping.js": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/ping.js/-/ping.js-0.3.0.tgz",
"integrity": "sha512-qisFwio7j0cwYbOcRL4BlTdxKALcpGPTkpl8ichGASgkrVqfI3sZfQDsP8wETR5rfutXZJLjlJ117aLkRnk2mA=="
},
"pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
@@ -8521,6 +8559,14 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true
},
"v-offline": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/v-offline/-/v-offline-3.5.1.tgz",
"integrity": "sha512-i9ydsGk9oJfMivtGI85U3m/6Bkqg07DkebbKV21t++OiwEKzArQs9GqqDETD972nNGz5q7+cr1fm6B2A1uht9A==",
"requires": {
"ping.js": "^0.3.0"
}
},
"vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

@@ -16,6 +16,7 @@
"@fullcalendar/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/resource": "^6.1.20",
"@fullcalendar/resource-timegrid": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20",
@@ -29,6 +30,7 @@
"primevue": "^4.5.4",
"quill": "^2.0.3",
"tailwindcss-primeui": "^0.6.0",
"v-offline": "^3.5.1",
"vue": "^3.4.34",
"vue-router": "^4.4.0"
},

View File

@@ -4,9 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import { fetchDocsForPath } from '@/composables/useAjuda'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import AppOfflineOverlay from '@/components/AppOfflineOverlay.vue'
const route = useRoute()
const router = useRouter()
const tenantStore = useTenantStore()
@@ -177,4 +179,7 @@ watch(
<Teleport to="body">
<AjudaDrawer />
</Teleport>
<!-- Overlay de sem conexão -->
<AppOfflineOverlay />
</template>

View File

@@ -1,3 +1,31 @@
/* You can add global styles to this file, and also import other style files */
/* ── Imports ─────────────────────────── */
@use 'primeicons/primeicons.css';
@use '@/assets/layout/layout.scss';
/* ── Design Tokens (Tailwind override) ─ */
:root {
--text-xs: 0.8rem;
}
/* ── Dark mode (opcional) ───────────── */
.app-dark {
--text-xs: 0.82rem;
}
/* ── Responsivo (opcional) ─────────── */
@media (min-width: 768px) {
:root {
--text-xs: 0.85rem;
}
}
/* Highlight pulse (acionado externamente via classe JS) */
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 0 rgba(99,102,241,0.7), 0 0 0 0 rgba(99,102,241,0.4); }
40% { box-shadow: 0 0 0 8px rgba(99,102,241,0.3), 0 0 0 16px rgba(99,102,241,0.1); }
100% { box-shadow: 0 0 0 0 rgba(99,102,241,0), 0 0 0 0 rgba(99,102,241,0); }
}
.notif-card--highlight {
animation: highlight-pulse 1s ease-out 3;
border-color: rgba(99,102,241,0.6) !important;
}

View File

@@ -0,0 +1,245 @@
<!-- src/components/AppOfflineOverlay.vue -->
<!-- Detecta offline via eventos nativos do browser + polling de fetch -->
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
const isOnline = ref(true) // começa como true; detecta em onMounted
const wasOffline = ref(false)
const showReconnected = ref(false)
let pollTimer = null
let reconnectedTimer = null
// ── Detecção real: tenta buscar um recurso minúsculo ──────────
async function checkConnectivity () {
try {
// favicon do próprio app (cache busted) — não depende de rede externa
await fetch('/favicon.ico?_t=' + Date.now(), {
method: 'HEAD',
cache: 'no-store',
signal: AbortSignal.timeout(4000)
})
setOnline()
} catch {
setOffline()
}
}
function setOnline () {
if (!isOnline.value && wasOffline.value) {
// acabou de reconectar
showReconnected.value = true
if (reconnectedTimer) clearTimeout(reconnectedTimer)
reconnectedTimer = setTimeout(() => { showReconnected.value = false }, 4000)
}
isOnline.value = true
}
function setOffline () {
if (isOnline.value) wasOffline.value = true
showReconnected.value = false
if (reconnectedTimer) clearTimeout(reconnectedTimer)
isOnline.value = false
}
// ── Eventos nativos do browser ────────────────────────────────
function onBrowserOffline () { setOffline() }
function onBrowserOnline () { checkConnectivity() } // confirma antes de marcar online
onMounted(() => {
window.addEventListener('offline', onBrowserOffline)
window.addEventListener('online', onBrowserOnline)
// Polling a cada 10 s — captura quedas que não disparam evento
pollTimer = setInterval(checkConnectivity, 10_000)
// Verifica estado atual ao montar (útil se já começou offline)
checkConnectivity()
})
onBeforeUnmount(() => {
window.removeEventListener('offline', onBrowserOffline)
window.removeEventListener('online', onBrowserOnline)
clearInterval(pollTimer)
clearTimeout(reconnectedTimer)
})
</script>
<template>
<Teleport to="body">
<!-- Overlay: sem internet -->
<Transition name="offline-fade">
<div
v-if="!isOnline"
class="offline-overlay"
role="alertdialog"
aria-live="assertive"
aria-label="Sem conexão com a internet"
>
<div class="offline-backdrop" />
<div class="offline-card">
<div class="offline-icon-wrap">
<span class="offline-icon-ring" />
<i class="pi pi-wifi offline-icon" />
</div>
<h2 class="offline-title">Sem conexão</h2>
<p class="offline-desc">
Verifique sua internet.<br>Tentando reconectar automaticamente
</p>
<div class="offline-pulse-bar">
<span class="offline-pulse-dot" />
<span class="offline-pulse-dot" style="animation-delay:.2s" />
<span class="offline-pulse-dot" style="animation-delay:.4s" />
</div>
</div>
</div>
</Transition>
<!-- Toast: reconectou -->
<Transition name="reconnect-toast">
<div v-if="showReconnected" class="reconnect-toast" role="status">
<i class="pi pi-check-circle" />
<span>Conexão restabelecida</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
/* ── Overlay ─────────────────────────────────────────────── */
.offline-overlay {
position: fixed;
inset: 0;
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.offline-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
}
/* ── Card ────────────────────────────────────────────────── */
.offline-card {
position: relative;
z-index: 1;
background: var(--surface-card, #fff);
border: 1px solid var(--surface-border, #e0e0e0);
border-radius: 20px;
padding: 40px 48px;
max-width: 380px;
width: 100%;
text-align: center;
box-shadow: 0 24px 60px rgba(0, 0, 0, .25);
}
/* ── Ícone ───────────────────────────────────────────────── */
.offline-icon-wrap {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
margin-bottom: 24px;
}
.offline-icon-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--primary-color, #6366f1);
opacity: 0.25;
animation: ring-pulse 2s ease-in-out infinite;
}
.offline-icon {
font-size: 2.2rem;
color: var(--primary-color, #6366f1);
opacity: 0.85;
position: relative;
}
@keyframes ring-pulse {
0%, 100% { transform: scale(1); opacity: 0.25; }
50% { transform: scale(1.18); opacity: 0.10; }
}
/* ── Texto ───────────────────────────────────────────────── */
.offline-title {
font-size: 1.3rem;
font-weight: 700;
color: var(--text-color, #1a1a2e);
margin: 0 0 10px;
}
.offline-desc {
font-size: 0.88rem;
color: var(--text-color-secondary, #666);
margin: 0 0 28px;
line-height: 1.6;
}
/* ── Dots de pulso ───────────────────────────────────────── */
.offline-pulse-bar {
display: flex;
justify-content: center;
gap: 7px;
}
.offline-pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color, #6366f1);
opacity: 0.7;
animation: dot-bounce 1.2s ease-in-out infinite;
}
@keyframes dot-bounce {
0%, 80%, 100% { transform: scale(0.7); opacity: 0.4; }
40% { transform: scale(1); opacity: 1; }
}
/* ── Transição do overlay ────────────────────────────────── */
.offline-fade-enter-active { transition: opacity 0.3s ease; }
.offline-fade-leave-active { transition: opacity 0.4s ease; }
.offline-fade-enter-from,
.offline-fade-leave-to { opacity: 0; }
/* ── Toast de reconexão ──────────────────────────────────── */
.reconnect-toast {
position: fixed;
bottom: 28px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
display: flex;
align-items: center;
gap: 8px;
background: #16a34a;
color: #fff;
font-size: 0.875rem;
font-weight: 600;
padding: 10px 20px;
border-radius: 999px;
box-shadow: 0 4px 20px rgba(22, 163, 74, .4);
white-space: nowrap;
}
.reconnect-toast .pi { font-size: 1rem; }
.reconnect-toast-enter-active { transition: opacity 0.3s ease, transform 0.3s ease; }
.reconnect-toast-leave-active { transition: opacity 0.4s ease, transform 0.4s ease; }
.reconnect-toast-enter-from { opacity: 0; transform: translateX(-50%) translateY(12px); }
.reconnect-toast-leave-to { opacity: 0; transform: translateX(-50%) translateY(12px); }
</style>

View File

@@ -98,7 +98,7 @@
</div>
<div class="text-xs text-surface-500">
Dica: Gerar usuário preenche automaticamente com dados fictícios.
Dica: "Gerar usuário" preenche automaticamente com dados fictícios.
</div>
</div>
@@ -136,7 +136,7 @@ import { supabase } from '@/lib/supabase/client'
const { canSee } = useRoleGuard()
/**
* Lista curada de pensadores influentes na psicanálise e seu entorno.
* Lista "curada" de pensadores influentes na psicanálise e seu entorno.
* Usada para geração rápida de dados fictícios.
*/
const PSICANALISE_PENSADORES = Object.freeze([

View File

@@ -134,7 +134,7 @@ onMounted(load)
</div>
<div v-else>
<!-- Resumo tipo cards -->
<!-- Resumo tipo "cards" -->
<div class="grid grid-cols-12 gap-3 mb-4">
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
<div class="text-600 text-sm">Tipo de slots</div>
@@ -158,7 +158,7 @@ onMounted(load)
<div class="flex items-center justify-between">
<div class="text-900 font-medium">Jornada do dia</div>
<div class="text-600 text-sm">
(Isso vem das suas janelas semanais)
(Isso vem das suas "janelas semanais")
</div>
</div>

View File

@@ -76,7 +76,7 @@ function normalizeIntervals(list) {
return merged
}
// retorna sobras de [s,e] depois de remover intervalos ocupados
// retorna "sobras" de [s,e] depois de remover intervalos ocupados
function subtractIntervals(s, e, occupiedMerged) {
let segments = [{ s, e }]
for (const occ of occupiedMerged) {
@@ -127,7 +127,7 @@ function addPauseSmart({ label, inicio, fim }) {
fim: minToHHMM(seg.e)
}))
// se houve recorte, avisa
// se houve "recorte", avisa
if (segments.length !== 1 || (segments[0].s !== s || segments[0].e !== e)) {
toast.add({
severity: 'info',

View File

@@ -122,7 +122,7 @@
<div class="text-gray-900 mb-2 text-3xl font-semibold">Joséphine Miller</div>
<span class="text-gray-600 text-2xl">Peak Interactive</span>
<p class="text-gray-900 sm:line-height-2 md:line-height-4 text-2xl mt-6" style="max-width: 800px">
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
</p>
<img src="/demo/images/landing/peak-logo.svg" class="mt-6" alt="Company logo" />
</div>

View File

@@ -103,7 +103,7 @@ function goToHistory () {
<!-- Empty state -->
<div v-else class="notification-drawer__empty">
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
<p class="notification-drawer__empty-text">Tudo em dia</p>
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
</div>
</div>
@@ -113,7 +113,7 @@ function goToHistory () {
<div class="notification-drawer__footer">
<button class="notification-drawer__history-link" @click="goToHistory">
<i class="pi pi-history" />
Ver histórico completo
Ver histórico completo
</button>
</div>
</template>

View File

@@ -4,41 +4,38 @@ import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { formatDistanceToNow } from 'date-fns'
import { ptBR } from 'date-fns/locale'
import { useNotificationStore } from '@/stores/notificationStore'
const props = defineProps({
item: {
type: Object,
required: true
}
item: { type: Object, required: true }
})
const emit = defineEmits(['read', 'archive'])
const router = useRouter()
const store = useNotificationStore()
const typeIconMap = {
new_scheduling: { icon: 'pi-inbox', color: 'text-red-500' },
new_patient: { icon: 'pi-user-plus', color: 'text-sky-500' },
recurrence_alert: { icon: 'pi-refresh', color: 'text-amber-500' },
session_status: { icon: 'pi-calendar-times', color: 'text-orange-500' }
const typeMap = {
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500', },
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500', },
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500', },
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500', },
}
const typeIcon = computed(() => typeIconMap[props.item.type] || { icon: 'pi-bell', color: 'text-gray-400' })
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' })
const isUnread = computed(() => !props.item.read_at)
const timeAgo = computed(() =>
formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR })
)
const avatarInitials = computed(() =>
props.item.payload?.avatar_initials || '??'
)
const initials = computed(() => props.item.payload?.avatar_initials || '?')
function handleRowClick () {
const deeplink = props.item.payload?.deeplink
if (deeplink) {
router.push(deeplink)
store.drawerOpen = false
emit('read', props.item.id)
}
}
@@ -56,45 +53,42 @@ function handleArchive (e) {
<template>
<div
class="notification-item"
:class="{ 'notification-item--unread': isUnread }"
@click="handleRowClick"
class="notif-item"
:class="[meta.border, isUnread ? 'notif-item--unread' : '']"
role="button"
tabindex="0"
@click="handleRowClick"
@keydown.enter="handleRowClick"
>
<!-- Dot indicador -->
<span v-if="isUnread" class="notification-item__dot" aria-hidden="true" />
<!-- Ícone de tipo -->
<span class="notification-item__type-icon" aria-hidden="true">
<i :class="['pi', typeIcon.icon, typeIcon.color]" />
</span>
<!-- Ícone do tipo -->
<div class="notif-item__icon" aria-hidden="true">
<i :class="['pi', meta.icon]" />
</div>
<!-- Avatar -->
<span class="notification-item__avatar" aria-hidden="true">
{{ avatarInitials }}
</span>
<div class="notif-item__avatar" aria-hidden="true">
{{ initials }}
</div>
<!-- Conteúdo -->
<div class="notification-item__content">
<p class="notification-item__title">{{ item.payload?.title }}</p>
<p class="notification-item__detail">{{ item.payload?.detail }}</p>
<p class="notification-item__time">{{ timeAgo }}</p>
<div class="notif-item__body">
<p class="notif-item__title">{{ item.payload?.title }}</p>
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
<p class="notif-item__time">{{ timeAgo }}</p>
</div>
<!-- Ações -->
<div class="notification-item__actions" @click.stop>
<div class="notif-item__actions" @click.stop>
<button
v-if="isUnread"
class="notification-item__action-btn"
class="notif-item__btn"
title="Marcar como lida"
@click="handleMarkRead"
>
<i class="pi pi-check" />
</button>
<button
class="notification-item__action-btn"
class="notif-item__btn"
title="Arquivar"
@click="handleArchive"
>
@@ -105,101 +99,92 @@ function handleArchive (e) {
</template>
<style scoped>
.notification-item {
position: relative;
.notif-item {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
cursor: pointer;
border-left-width: 3px;
border-left-style: solid;
border-bottom: 1px solid var(--surface-border);
background: transparent;
cursor: pointer;
transition: background 0.15s;
}
.notification-item:hover {
.notif-item:hover {
background: var(--surface-hover);
}
.notification-item--unread {
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
.notif-item--unread {
background: rgba(99, 102, 241, 0.05);
}
.notification-item--unread:hover {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
.notif-item--unread:hover {
background: rgba(99, 102, 241, 0.09);
}
.notification-item__dot {
position: absolute;
left: 0.25rem;
top: 50%;
transform: translateY(-50%);
width: 6px;
height: 6px;
border-radius: 50%;
background: #6366f1;
.notif-item__icon {
flex-shrink: 0;
}
.notification-item__type-icon {
display: flex;
align-items: center;
padding-top: 0.125rem;
flex-shrink: 0;
font-size: 0.85rem;
padding-top: 0.15rem;
font-size: 0.9rem;
color: var(--text-color-secondary);
}
.notification-item__avatar {
display: flex;
align-items: center;
justify-content: center;
.notif-item__avatar {
flex-shrink: 0;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--surface-200);
color: var(--text-color);
font-size: 0.7rem;
background: linear-gradient(135deg, #6366f1, #38bdf8);
color: #fff;
font-size: 0.68rem;
font-weight: 700;
flex-shrink: 0;
letter-spacing: 0.05em;
letter-spacing: 0.04em;
display: flex;
align-items: center;
justify-content: center;
}
.notification-item__content {
.notif-item__body {
flex: 1;
min-width: 0;
}
.notification-item__title {
.notif-item__title {
font-weight: 600;
font-size: 0.85rem;
color: var(--text-color);
margin: 0 0 0.125rem;
margin: 0 0 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item__detail {
font-size: 0.8rem;
.notif-item__detail {
font-size: 0.78rem;
color: var(--text-color-secondary);
margin: 0 0 0.125rem;
margin: 0 0 0.1rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item__time {
font-size: 0.72rem;
.notif-item__time {
font-size: 0.7rem;
color: var(--text-color-secondary);
opacity: 0.7;
margin: 0;
opacity: 0.75;
}
.notification-item__actions {
.notif-item__actions {
display: flex;
align-items: center;
gap: 0.125rem;
opacity: 0;
transition: opacity 0.15s;
}
.notification-item:hover .notification-item__actions {
.notif-item:hover .notif-item__actions {
opacity: 1;
}
.notification-item__action-btn {
.notif-item__btn {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
@@ -212,7 +197,7 @@ function handleArchive (e) {
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
}
.notification-item__action-btn:hover {
.notif-item__btn:hover {
background: var(--surface-border);
color: var(--text-color);
}

View File

@@ -0,0 +1,219 @@
<!-- src/components/patients/PatientActionMenu.vue -->
<script setup>
import { ref } from 'vue'
import { useToast } from 'primevue/usetoast'
import { usePatientLifecycle } from '@/composables/usePatientLifecycle'
const props = defineProps({
patient: { type: Object, required: true },
hasHistory: { type: Boolean, default: false }
})
const emit = defineEmits(['updated'])
const toast = useToast()
const { deletePatient, deactivatePatient, archivePatient, reactivatePatient } = usePatientLifecycle()
const loading = ref(false)
const menu = ref()
// ── Dialogs ────────────────────────────────────────────────
const deactivateDialogOpen = ref(false)
const archiveDialogOpen = ref(false)
const hasHistoryDialogOpen = ref(false)
// ── Helpers ───────────────────────────────────────────────
function showSuccess (detail) {
toast.add({ severity: 'success', summary: 'Ok', detail, life: 2500 })
}
function showError (detail) {
toast.add({ severity: 'error', summary: 'Erro', detail, life: 4000 })
}
async function runAction (fn, successMsg) {
loading.value = true
try {
const result = await fn(props.patient.id)
if (!result.ok) { showError(result.message || result.error?.message || 'Falha ao executar ação.'); return }
showSuccess(successMsg)
emit('updated')
} catch (e) {
showError(e?.message || 'Falha inesperada.')
} finally {
loading.value = false
}
}
// ── Desativar — sempre mostra dialog informativo
function handleDeactivate () {
deactivateDialogOpen.value = true
}
async function confirmDeactivate () {
deactivateDialogOpen.value = false
await runAction(deactivatePatient, 'Paciente desativado.')
}
// ── Arquivar — sempre mostra dialog informativo
function handleArchive () {
archiveDialogOpen.value = true
}
async function confirmArchive () {
archiveDialogOpen.value = false
hasHistoryDialogOpen.value = false
await runAction(archivePatient, 'Paciente arquivado.')
}
// ── Excluir — se tem histórico, mostra dialog informativo
function handleDelete () {
if (props.hasHistory) {
hasHistoryDialogOpen.value = true
return
}
doDelete()
}
async function doDelete () {
loading.value = true
try {
const result = await deletePatient(props.patient.id)
if (!result.ok) { showError(result.message || 'Não foi possível excluir.'); return }
showSuccess('Paciente excluído.')
emit('updated')
} catch (e) {
showError(e?.message || 'Falha inesperada.')
} finally {
loading.value = false
}
}
// ── Menu de ações ─────────────────────────────────────────
function menuItems () {
const status = props.patient.status
const items = []
if (status === 'Ativo') {
items.push({ label: 'Desativar', icon: 'pi pi-pause', command: () => handleDeactivate() })
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
}
if (status === 'Inativo') {
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
}
if (status === 'Arquivado') {
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
}
if (status === 'Alta' || status === 'Encaminhado') {
items.push({ label: 'Arquivar', icon: 'pi pi-archive', command: () => handleArchive() })
items.push({ label: 'Reativar', icon: 'pi pi-play', command: () => runAction(reactivatePatient, 'Paciente reativado.') })
}
if (items.length) items.push({ separator: true })
items.push({ label: 'Excluir paciente', icon: 'pi pi-trash', class: 'text-red-500', command: () => handleDelete() })
return items
}
</script>
<template>
<div class="flex gap-1 justify-end">
<Button
icon="pi pi-ellipsis-v"
severity="secondary"
outlined
size="small"
:loading="loading"
v-tooltip.top="'Ações do paciente'"
@click="(e) => menu.toggle(e)"
/>
<Menu ref="menu" :model="menuItems()" popup appendTo="body" />
</div>
<!-- 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" />
<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>
<p class="m-0 text-[var(--text-color-secondary)]">
Recomendamos revisar a agenda e encerrar as recorrências manualmente antes de prosseguir.
</p>
</div>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="deactivateDialogOpen = false" />
<Button label="Entendo, desativar mesmo assim!" icon="pi pi-pause" severity="warn" @click="confirmDeactivate" />
</div>
</template>
</Dialog>
<!-- 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" />
<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)]">
<li>O paciente sairá da listagem ativa e ficará oculto nas buscas padrão.</li>
<li>Todo o histórico clínico, sessões e registros financeiros serão preservados.</li>
<li>Novos agendamentos para este paciente ficarão bloqueados.</li>
<li>O paciente pode ser <strong>reativado</strong> a qualquer momento pelo menu de ações.</li>
</ul>
<p class="m-0 text-[var(--text-color-secondary)]">
Arquivar é indicado para pacientes que concluíram o acompanhamento ou que estão em pausa prolongada.
</p>
</div>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="archiveDialogOpen = false" />
<Button label="Compreendo, arquivar mesmo assim!" icon="pi pi-archive" @click="confirmArchive" />
</div>
</template>
</Dialog>
<!-- 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" />
<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>
</div>
<template #footer>
<div class="flex gap-2 justify-end">
<Button label="Cancelar" severity="secondary" outlined @click="hasHistoryDialogOpen = false" />
<Button label="Arquivar paciente" icon="pi pi-archive" @click="confirmArchive" />
</div>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,85 @@
// src/composables/useMenuBadges.js
// Singleton — contadores para badges do menu (agenda hoje, cadastros e agendamentos recebidos)
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
// ─── estado compartilhado ──────────────────────────────────
const agendaHoje = ref(0)
const cadastrosRecebidos = ref(0)
const agendamentosRecebidos = ref(0)
let _timer = null
let _started = false
async function _refresh () {
try {
const tenantStore = useTenantStore()
const role = tenantStore.role
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
const { data: authData } = await supabase.auth.getUser()
const ownerId = authData?.user?.id
if (!ownerId) return
const isClinic = role === 'clinic_admin' || role === 'tenant_admin' || role === 'clinic'
const now = new Date()
const y = now.getFullYear(), mo = now.getMonth(), d = now.getDate()
const startDay = new Date(y, mo, d).toISOString()
const endDay = new Date(y, mo, d + 1).toISOString()
// 1. Agenda hoje
{
let q = supabase
.from('agenda_eventos')
.select('id', { count: 'exact', head: true })
.gte('inicio_em', startDay)
.lt('inicio_em', endDay)
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId)
else q = q.eq('owner_id', ownerId)
const { count } = await q
agendaHoje.value = count || 0
}
// 2. Cadastros recebidos (status = 'new') — RLS filtra pelo owner
{
const { count } = await supabase
.from('patient_intake_requests')
.select('id', { count: 'exact', head: true })
.eq('status', 'new')
cadastrosRecebidos.value = count || 0
}
// 3. Agendamentos recebidos (status = 'pendente')
{
let q = supabase
.from('agendador_solicitacoes')
.select('id', { count: 'exact', head: true })
.eq('status', 'pendente')
if (isClinic && tenantId) q = q.eq('tenant_id', tenantId)
else q = q.eq('owner_id', ownerId)
const { count } = await q
agendamentosRecebidos.value = count || 0
}
} catch {
// badge falhar não deve quebrar a navegação
}
}
// ─── API pública ───────────────────────────────────────────
export function useMenuBadges () {
if (!_started) {
_started = true
_refresh()
_timer = setInterval(_refresh, 5 * 60 * 1000) // atualiza a cada 5 min
}
return {
agendaHoje,
cadastrosRecebidos,
agendamentosRecebidos,
refresh: _refresh
}
}

View File

@@ -0,0 +1,74 @@
// src/composables/usePatientLifecycle.js
import { supabase } from '@/lib/supabase/client'
export function usePatientLifecycle () {
async function canDelete (patientId) {
const { data, error } = await supabase.rpc('can_delete_patient', { p_patient_id: patientId })
if (error) return false
return !!data
}
async function deletePatient (patientId) {
const { data, error } = await supabase.rpc('safe_delete_patient', { p_patient_id: patientId })
if (error) return { ok: false, error: 'rpc_error', message: error.message }
return data // { ok, error?, message? }
}
async function checkActiveSchedule (patientId) {
const now = new Date().toISOString()
const [evts, recs] = await Promise.all([
supabase
.from('agenda_eventos')
.select('id', { count: 'exact', head: true })
.eq('patient_id', patientId)
.eq('status', 'agendado')
.gt('inicio_em', now),
supabase
.from('recurrence_rules')
.select('id', { count: 'exact', head: true })
.eq('patient_id', patientId)
.eq('status', 'ativo')
])
return {
hasFutureSessions: (evts.count ?? 0) > 0,
hasActiveRecurrence: (recs.count ?? 0) > 0
}
}
async function deactivatePatient (patientId) {
const { error } = await supabase
.from('patients')
.update({ status: 'Inativo', updated_at: new Date().toISOString() })
.eq('id', patientId)
return error ? { ok: false, error } : { ok: true }
}
async function archivePatient (patientId) {
const { error } = await supabase
.from('patients')
.update({ status: 'Arquivado', updated_at: new Date().toISOString() })
.eq('id', patientId)
return error ? { ok: false, error } : { ok: true }
}
async function reactivatePatient (patientId) {
const { error } = await supabase
.from('patients')
.update({ status: 'Ativo', updated_at: new Date().toISOString() })
.eq('id', patientId)
return error ? { ok: false, error } : { ok: true }
}
return { canDelete, deletePatient, checkActiveSchedule, deactivatePatient, archivePatient, reactivatePatient }
}
// ─── Helper puro — não precisa de instância do composable ───────────────────
export function getPatientAgendaPermissions (status) {
return {
canCreateSession: !['Inativo', 'Arquivado'].includes(status),
canReschedule: !['Inativo'].includes(status),
canEditPastSession: !['Arquivado'].includes(status),
canCreateRecurrence: !['Inativo', 'Arquivado'].includes(status),
}
}

View File

@@ -70,7 +70,7 @@ const calendarOptions = computed(() => {
// Header desativado (você controla no Toolbar)
headerToolbar: false,
// Visão produto: blocos com linhas suaves
// Visão "produto": blocos com linhas suaves
nowIndicator: true,
allDaySlot: false,
expandRows: true,
@@ -93,7 +93,7 @@ const calendarOptions = computed(() => {
hour12: false
},
// Horário verdadeiro de funcionamento (se você usar)
// Horário "verdadeiro" de funcionamento (se você usar)
businessHours: props.businessHours,
// Dados
@@ -183,7 +183,7 @@ onMounted(() => {
border-radius: 16px;
}
/* Deixa o calendário respirar dentro de cards/layouts */
/* Deixa o calendário "respirar" dentro de cards/layouts */
:deep(.fc){
font-size: 0.95rem;
}

View File

@@ -281,12 +281,13 @@ function buildFcOptions (ownerId) {
eventResize: (info) => emit('eventResize', info),
eventContent: (arg) => {
const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
@@ -305,8 +306,11 @@ function buildFcOptions (ownerId) {
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const statusBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: ''
return {
html: `<div class="ev-custom">
@@ -314,10 +318,25 @@ function buildFcOptions (ownerId) {
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${statusBadge}
${obsHtml}
</div>
</div>`
}
},
eventClassNames: (arg) => {
const classes = []
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
return classes
},
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important')
info.el.style.setProperty('border-color', bgColor, 'important')
}
}
}

View File

@@ -102,6 +102,21 @@
Este dia é folga na sua jornada. Você ainda pode salvar se necessário.
</Message>
<!-- Restrições de status do paciente -->
<Message v-if="isArchivedPastEdit" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-lock mr-1" />
<b>Paciente arquivado.</b> O histórico de sessões é somente leitura.
</Message>
<Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" />
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
</Message>
<Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" />
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
Novos agendamentos estão bloqueados.
</Message>
<!-- Alerta: solicitação pendente neste horário -->
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" class="mb-3" :closable="false">
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
@@ -182,7 +197,13 @@
/>
<div class="min-w-0 flex-1">
<div class="font-bold text-base truncate">{{ form.paciente_nome }}</div>
<div class="text-xs text-color-secondary">Paciente vinculado</div>
<div class="flex items-center gap-1.5 flex-wrap">
<span class="text-xs text-color-secondary">Paciente vinculado</span>
<span
v-if="form.paciente_status === 'Inativo' || form.paciente_status === 'Arquivado'"
style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;"
>{{ form.paciente_status === 'Arquivado' ? 'arquivado' : 'desativado' }}</span>
</div>
</div>
<div class="flex gap-1 shrink-0">
<Button v-if="!patientLocked" icon="pi pi-pencil" severity="secondary" outlined size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Trocar'" @click="openPacientePicker" />
@@ -265,10 +286,12 @@
<div class="field-card__body">
<SelectButton
v-model="form.status"
:options="statusOptions"
:options="statusOptionsFiltered"
optionLabel="label"
optionValue="value"
optionDisabled="disabled"
:allowEmpty="false"
:disabled="isArchivedPastEdit"
class="w-full status-select-btn"
/>
</div>
@@ -684,6 +707,7 @@
variant="filled"
rows="3"
autoResize
:disabled="isArchivedPastEdit"
/>
<label for="aed-observacoes-side">Observação</label>
</FloatLabel>
@@ -692,6 +716,11 @@
<!-- Opção de recorrência para sessão SEM série (criação ou avulsa) -->
<template v-if="!hasSerie">
<div class="side-card__title mb-2">Frequência</div>
<Message v-if="isSessionEvent && form.paciente_id && !agendaPerms.canCreateRecurrence" severity="warn" class="mb-3" :closable="false">
<i class="pi pi-ban mr-1" />
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
Criação de recorrências está bloqueada.
</Message>
<!-- Data de início (= form.dia) com botão Hoje -->
<div class="rec-startdate-row mb-3">
@@ -708,8 +737,12 @@
v-for="f in freqOpcoes"
:key="f.value"
class="freq-chip"
:class="{ 'freq-chip--active': recorrenciaType === f.value }"
@click="recorrenciaType = f.value"
:class="{
'freq-chip--active': recorrenciaType === f.value,
'opacity-40 cursor-not-allowed': f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence
}"
:disabled="f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence"
@click="(!agendaPerms.canCreateRecurrence && f.value !== 'avulsa') ? null : (recorrenciaType = f.value)"
>{{ f.label }}</button>
</div>
@@ -1101,6 +1134,7 @@ import { useServices } from '@/features/agenda/composables/useServices'
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans'
import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle'
function patientInitials (nome) {
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
@@ -1663,7 +1697,8 @@ const patients = ref([])
const filteredPatients = computed(() => {
const q = String(pacienteSearch.value || '').trim().toLowerCase()
const list = patients.value || []
// Somente pacientes Ativos podem ser selecionados para novos agendamentos
const list = (patients.value || []).filter(p => p.status === 'Ativo')
if (!q) return list
return list.filter(p => {
const nome = String(p.nome || '').toLowerCase()
@@ -2137,6 +2172,39 @@ const pillDeleteMenuItems = computed(() => {
})
function isPast (iso) { return iso ? new Date(iso) < new Date() : false }
// ── Permissões de agenda por status do paciente ───────────────────────────
const agendaPerms = computed(() => getPatientAgendaPermissions(form.value.paciente_status || ''))
// Sessão atual é futura? (para edição: usa inicio_em do evento original)
const isSessionFuture = computed(() => {
if (!isEdit.value) return true
const iso = props.eventRow?.inicio_em
return iso ? new Date(iso) > new Date() : true
})
// Arquivado editando sessão passada → somente leitura
const isArchivedPastEdit = computed(() =>
isEdit.value &&
form.value.paciente_status === 'Arquivado' &&
!isSessionFuture.value
)
// Inativo editando sessão futura → remarcar bloqueado
const isInativoFutureEdit = computed(() =>
isEdit.value &&
form.value.paciente_status === 'Inativo' &&
isSessionFuture.value
)
// StatusOptions com remarcar desabilitado para Inativo
const statusOptionsFiltered = computed(() => [
{ label: 'Agendado', value: 'agendado' },
{ label: 'Realizado', value: 'realizado' },
{ label: 'Faltou', value: 'faltou' },
{ label: 'Cancelado', value: 'cancelado' },
{ label: 'Remarcar', value: 'remarcar', disabled: isInativoFutureEdit.value },
])
function fmtWeekdayShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3) }
function fmtDayNum (iso) { return new Date(iso).getDate() }
function fmtMonthShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '') }
@@ -2411,6 +2479,18 @@ const canSave = computed(() => {
if (!form.value.commitment_id) return false
if (requiresPatient.value && !form.value.paciente_id) return false
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false
// ── Restrições por status do paciente ────────────────────
if (isSessionEvent.value && form.value.paciente_status) {
const perms = agendaPerms.value
// Criar sessão avulsa ou com recorrência: bloqueado para Inativo/Arquivado
if (!isEdit.value && !perms.canCreateSession) return false
// Criar recorrência: bloqueado para Inativo/Arquivado
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false
// Arquivado tentando salvar sessão passada: bloqueado
if (isArchivedPastEdit.value) return false
}
return true
})
@@ -2615,9 +2695,10 @@ function resetForm () {
id: r?.id || null,
owner_id: r?.owner_id || props.ownerId || '',
terapeuta_id: r?.terapeuta_id ?? null,
paciente_id: r?.paciente_id ?? null,
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
paciente_id: r?.paciente_id ?? null,
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
paciente_avatar: r?.paciente_avatar ?? '',
paciente_status: r?.paciente_status ?? '',
commitment_id: r?.determined_commitment_id ?? null,
titulo_custom: r?.titulo_custom || '',
status: r?.status || 'agendado',

View File

@@ -93,7 +93,7 @@ const emit = defineEmits(['refresh', 'collapse'])
padding-right: .25rem;
}
/* Melhor sensação de sessões */
/* Melhor sensação de "sessões" */
.slot-top, .slot-bottom{
display: flex;
flex-direction: column;

View File

@@ -113,7 +113,7 @@ const attentionSeverity = computed(() => {
<!-- Ações rápidas -->
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="text-xs" style="color: var(--text-color-secondary);">
Ações rápidas (criação sempre abre modal nada nasce direto).
Ações rápidas (criação sempre abre modal nada nasce "direto").
</div>
<div class="flex align-items-center gap-2">

View File

@@ -35,7 +35,7 @@ const BASE_SELECT = `
mirror_of_event_id, price,
insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id,
patients!agenda_eventos_patient_id_fkey (
id, nome_completo, avatar_url
id, nome_completo, avatar_url, status
),
determined_commitments!agenda_eventos_determined_commitment_fk (
id, bg_color, text_color
@@ -183,5 +183,6 @@ function flattenRow (r) {
delete out.patients
out.paciente_nome = patient?.nome_completo || out.paciente_nome || ''
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || ''
out.paciente_status = patient?.status || out.paciente_status || ''
return out
}

View File

@@ -164,6 +164,10 @@
@click="gotoResult(r)"
>
<div class="font-medium truncate">{{ r.titulo || 'Sem título' }}</div>
<span
v-if="r.patients?.status === 'Inativo' || r.patients?.status === 'Arquivado'"
class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5"
>{{ r.patients?.status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
<span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span>
@@ -304,6 +308,10 @@
<div class="font-semibold text-sm truncate">
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
</div>
<span
v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'"
class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5"
>{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
<!-- Linha 3: título (se paciente diferente de título) -->
<div
@@ -623,7 +631,18 @@ const timeModeOptions = [
{ label: 'Meu Horário', value: 'my' }
]
const mosaicMode = computed(() => timeMode.value === '24' ? 'full_24h' : 'work_hours')
const mosaicMode = ref('work_hours')
const mosaicModeOptions = [
{ label: 'Horas de Trabalho', value: 'work_hours' },
{ label: 'Grade Completa', value: 'full_24h' }
]
// Sincroniza mosaicMode com timeMode: '24h' força grade completa
watch(timeMode, (v) => {
if (v === '24') mosaicMode.value = 'full_24h'
else if (mosaicMode.value === 'full_24h') mosaicMode.value = 'work_hours'
})
function settingsFallbackStart () {
const s = settings.value
@@ -936,15 +955,26 @@ const baseRows = computed(() => {
})
if (!onlySessions.value) return refined
return refined.filter(r => {
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
return tipo === EVENTO_TIPO.SESSAO || r.masked === true
})
// Filtrar por patient_id — filtro por tipo não funciona pois o enum do banco
// usa 'sessao' para todos os compromissos não-bloqueio (Análise, Leitura, etc.)
return refined.filter(r => !!(r.patient_id || r.masked))
})
const allEvents = computed(() => {
// Mapa id → cores para injetar em ocorrências virtuais (que não têm o join determined_commitments)
const colorMap = new Map(
(commitmentOptionsNormalized.value || [])
.filter(c => c.id)
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
)
function withCommitmentColors (r) {
if (r.determined_commitments || !r.determined_commitment_id) return r
const colors = colorMap.get(r.determined_commitment_id)
return colors ? { ...r, determined_commitments: colors } : r
}
// eventos reais (sem ocorrências virtuais para evitar duplicatas)
const realRows = (baseRows.value || []).filter(r => !r.is_occurrence)
const realRows = (baseRows.value || []).filter(r => !r.is_occurrence).map(withCommitmentColors)
const base = mapAgendaEventosToCalendarEvents(realRows)
// ocorrências virtuais das séries
@@ -953,8 +983,8 @@ const allEvents = computed(() => {
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
const dc = r.determined_commitment_id
if (tipo === EVENTO_TIPO.SESSAO && dc && !isSessionCommitmentId(dc)) return maskPrivateRow(r)
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return null
return r
if (onlySessions.value && !(r.patient_id || r.masked)) return null
return withCommitmentColors(r)
}).filter(Boolean)
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
@@ -2059,7 +2089,7 @@ async function _reloadRange () {
// Expande recorrências para cada terapeuta no range
const allMerged = []
for (const ownId of ownerIds.value) {
const merged = await loadAndExpand(ownId, start, end, rows.value.filter(r => r.owner_id === ownId))
const merged = await loadAndExpand(ownId, start, end, rows.value.filter(r => r.owner_id === ownId), tenantId.value)
allMerged.push(...merged.filter(r => r.is_occurrence))
}
_occurrenceRows.value = allMerged
@@ -2536,7 +2566,7 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
width: 5px;
height: 5px;
border-radius: 50%;
background: greenyellow;
background: var(--primary-color, #6366f1);
}
/* Badge numérico no header */

View File

@@ -91,10 +91,10 @@
</div>
<!-- Aviso: fora da jornada -->
<div v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
<div ref="foraJornadaBannerRef" v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
<div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
<div class="font-semibold text-sm">Compromissos fora da jornada</div>
<Button label="Ver 24h" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="timeMode = '24'" />
</div>
</div>
@@ -185,12 +185,21 @@
</div>
<div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
<div class="flex items-center gap-1 mt-0.5">
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]"></span>
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
</div>
</div>
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
<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"
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)" />
</div>
</div>
</div>
@@ -199,6 +208,25 @@
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton(); agPanelOpen = false" />
</div>
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
<div
v-if="desativadoPatients.length"
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
@click="desativadoDialogOpen = true"
>
<div class="flex items-center gap-2 mb-1.5">
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
</span>
</div>
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
com sessões agendadas.
</p>
</div>
<!-- Pacientes -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -406,12 +434,21 @@
</div>
<div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
<div class="flex items-center gap-1 mt-0.5">
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]" title="Recorrente"></span>
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
</div>
</div>
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
<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"
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)" />
</div>
</div>
</div>
@@ -420,6 +457,25 @@
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
</div>
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
<div
v-if="desativadoPatients.length"
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
@click="desativadoDialogOpen = true"
>
<div class="flex items-center gap-2 mb-1.5">
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
</span>
</div>
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
com sessões agendadas.
</p>
</div>
<!-- Pacientes -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -549,7 +605,7 @@
<!-- Sem resultados -->
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
Nenhum resultado para <b>{{ searchTrim }}</b>"
Nenhum resultado para "<b>{{ searchTrim }}</b>"
<span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
</div>
@@ -579,6 +635,7 @@
<div class="font-semibold text-sm truncate">
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
</div>
<span v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
<!-- Linha 3: título (se paciente diferente de título) -->
<div
@@ -737,6 +794,140 @@
/>
</template>
</Dialog>
<!-- ═══════════════════════════════════════════════════════════
Dialog: Sessões Pendentes de Pacientes Desativados/Arquivados
═══════════════════════════════════════════════════════════ -->
<Dialog
v-model:visible="desativadoDialogOpen"
modal
:draggable="false"
:style="{ width: '1100px', maxWidth: '97vw', height: '85vh' }"
:pt="{ content: { style: 'padding:0; display:flex; flex-direction:column; height:100%; overflow:hidden;' }, header: { style: 'padding: 1rem 1.25rem 0.75rem' } }"
>
<template #header>
<div class="flex items-center gap-2 w-full">
<i class="pi pi-exclamation-triangle text-orange-500" />
<span class="font-semibold text-base">Sessões agendadas — pacientes desativados</span>
<span class="ml-1 inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full bg-orange-500 text-white text-xs font-bold">
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
</span>
<!-- Tabs pacientes -->
<div class="flex gap-1 ml-auto flex-wrap">
<button
v-for="p in desativadoPatients"
:key="p.id"
class="px-3 py-1 rounded-full text-xs font-semibold border transition-all duration-150"
:class="desativadoSelected?.id === p.id
? 'bg-orange-500 text-white border-orange-500'
: 'bg-transparent text-orange-600 border-orange-500/40 hover:bg-orange-500/10'"
@click="desativadoSelected = p"
>
{{ (p.nome_completo || '—').split(' ')[0] }}
<span class="ml-1 opacity-70">({{ p.sessions.length }})</span>
</button>
</div>
</div>
</template>
<!-- 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)]">
<!-- 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;">
{{ (desativadoSelected.nome_completo || '?').charAt(0).toUpperCase() }}
</div>
<div class="min-w-0">
<div class="font-semibold text-sm truncate">{{ desativadoSelected.nome_completo }}</div>
<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;">
{{ desativadoSelected.status === 'Arquivado' ? 'arquivado' : 'desativado' }}
</span>
</div>
<div class="ml-auto text-right flex-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>
</div>
</div>
<!-- Sessions list -->
<div class="flex-1 overflow-y-auto">
<div class="p-3 flex flex-col gap-2">
<div
v-for="s in desativadoSelected.sessions"
:key="s.id"
class="rounded-lg border p-2.5 cursor-pointer transition-all duration-150 group"
:class="desativadoFocused?.id === s.id
? 'border-orange-500 bg-orange-500/8 shadow-sm'
: 'border-[var(--surface-border)] bg-[var(--surface-card)] hover:border-orange-500/40 hover:bg-orange-500/5'"
@click="focusDesativadoSession(s)"
>
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="text-xs font-bold text-[var(--text-color)]">
{{ fmtDesativadoDate(s.inicio_em) }}
</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">
{{ fmtDesativadoTime(s.inicio_em) }} · {{ fmtDesativadoDur(s.inicio_em, s.fim_em) }}
</div>
<div v-if="s.titulo" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ s.titulo }}</div>
</div>
<Tag :value="s.modalidade || 'Presencial'" severity="secondary" class="text-[0.6rem] shrink-0" />
</div>
<div class="flex gap-1.5 mt-2">
<Button
label="Ver na agenda"
icon="pi pi-external-link"
size="small"
severity="warn"
outlined
class="flex-1 text-[0.65rem]"
@click.stop="openSessionInMainCalendar(s)"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Painel direito: mini FullCalendar -->
<div class="flex-1 min-w-0 min-h-[300px] lg:min-h-0 overflow-hidden flex flex-col">
<div class="px-4 py-2 border-b border-[var(--surface-border)] flex items-center gap-2">
<i class="pi pi-calendar text-[var(--text-color-secondary)] text-xs" />
<span class="text-xs text-[var(--text-color-secondary)]">Clique em uma sessão para abrir na agenda principal e cancelar ou remarcar</span>
</div>
<div class="flex-1 overflow-auto p-2">
<FullCalendar
v-if="desativadoDialogOpen && desativadoSelected"
ref="desativadoFcRef"
:options="desativadoFcOptions"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined @click="desativadoDialogOpen = false" />
</template>
</Dialog>
<!-- Menu contexto: Sessões Hoje -->
<Menu ref="todayEvMenuRef" :model="todayEvMenuItems" :popup="true" />
<!-- Dialog: Prontuário -->
<PatientProntuario
:key="selectedPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
/>
</template>
<script setup>
@@ -753,11 +944,13 @@ import Calendar from 'primevue/calendar'
import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue'
import { useSupportDebugStore } from '@/support/supportDebugStore'
@@ -810,7 +1003,7 @@ const {
const commitmentOptionsNormalized = computed(() => {
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
// prioridade pra Sessão" primeiro (native_key = session)
// prioridade pra "Sessão" primeiro (native_key = session)
const priority = new Map([
['session', 0],
['class', 1],
@@ -876,13 +1069,16 @@ onMounted(async () => {
if (tid) loadFeriados(tid)
})
// Carrega desativados assim que ownerId estiver disponível
watch(ownerId, (id) => { if (id) loadDesativados() }, { immediate: true })
// Range atual
const currentRange = ref({ start: null, end: null })
// -----------------------------
// Topbar state
// -----------------------------
const onlySessions = ref(true)
const onlySessions = ref(false)
const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my
const search = ref('')
@@ -954,6 +1150,52 @@ const headerMenuRef = ref(null)
const agPanelOpen = ref(false)
const blockMenuRef = ref(null)
// ── Prontuário ────────────────────────────────────────────────
const prontuarioOpen = ref(false)
const selectedPatient = ref(null)
function openProntuario (patientId, patientNome) {
if (!patientId) return
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
prontuarioOpen.value = true
}
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
// ── Menu de contexto: Sessões Hoje ────────────────────────────
const todayEvMenuRef = ref(null)
const _todayEvAtivo = ref(null)
const todayEvMenuItems = computed(() => [
{
label: 'Opções',
items: [
{
label: 'Ver prontuário',
icon: 'pi pi-file-edit',
disabled: !(_todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id),
command: () => {
const id = _todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id
const nome = _todayEvAtivo.value?.paciente_nome || _todayEvAtivo.value?.patient_name || ''
openProntuario(id, nome)
},
},
{
label: 'Abrir na agenda',
icon: 'pi pi-calendar',
command: () => {
if (_todayEvAtivo.value) onEventRowClick(_todayEvAtivo.value)
},
},
],
},
])
function openTodayEvMenu (event, ev) {
event.stopPropagation()
_todayEvAtivo.value = ev
todayEvMenuRef.value?.toggle(event)
}
// Bloqueio dialog
const bloqueioDialogOpen = ref(false)
const bloqueioMode = ref('horario')
@@ -1098,8 +1340,10 @@ const allRows = computed(() => [
const calendarRows = computed(() => {
return allRows.value.filter(r => {
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return false
// "Apenas Sessões" = eventos vinculados a paciente.
// Filtrar por tipo não funciona pois o banco usa 'sessao' para todos os compromissos
// que não são bloqueio — compromissos pessoais (Análise, Leitura, etc.) têm o mesmo tipo.
if (onlySessions.value && !(r.patient_id || r.paciente_id)) return false
return true
})
})
@@ -1172,7 +1416,8 @@ const searchResults = computed(() => {
? monthSearchRows.value
: (calendarRows.value || []).map(r => ({
...r,
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || ''
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || '',
paciente_status: r.paciente_status || r.extendedProps?.paciente_status || ''
}))
return source.filter(r => _matchRow(r, q))
})
@@ -1191,7 +1436,7 @@ async function loadMonthSearchRows () {
// mergeWithStoredSessions deduplicar sessões materializadas de séries.
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo)')
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
.eq('owner_id', uid)
.is('mirror_of_event_id', null)
.gte('inicio_em', startISO)
@@ -1199,7 +1444,7 @@ async function loadMonthSearchRows () {
.order('inicio_em', { ascending: true })
if (error) throw error
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '' }))
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '', paciente_status: r.patients?.status || '' }))
// 2. Ocorrências virtuais de recorrência (não existem em agenda_eventos).
// loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence
@@ -1230,10 +1475,27 @@ watch(currentDate, (newD, oldD) => {
})
const calendarEvents = computed(() => {
// Mapa id → {bg_color, text_color} dos commitments já carregados na página.
// Usado para injetar cores nas ocorrências virtuais, que não têm o join
// determined_commitments (useRecurrence faz select('*') sem join).
const colorMap = new Map(
(commitmentOptionsNormalized.value || [])
.filter(c => c.id)
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
)
// Injeta determined_commitments em qualquer row que ainda não tenha — resolve
// tanto ocorrências virtuais quanto eventos reais com join nulo.
function withCommitmentColors (r) {
if (r.determined_commitments || !r.determined_commitment_id) return r
const colors = colorMap.get(r.determined_commitment_id)
return colors ? { ...r, determined_commitments: colors } : r
}
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
// em cada grupo — as virtuais precisam do mesmo tratamento de cores
const realRows = calendarRows.value.filter(r => !r.is_occurrence)
const occRows = calendarRows.value.filter(r => r.is_occurrence)
const realRows = calendarRows.value.filter(r => !r.is_occurrence).map(withCommitmentColors)
const occRows = calendarRows.value.filter(r => r.is_occurrence).map(withCommitmentColors)
const base = mapAgendaEventosToCalendarEvents(realRows)
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
@@ -1387,11 +1649,12 @@ const fcOptions = computed(() => ({
eventContent: (arg) => {
const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || ''
const obs = ext.observacoes || ''
const title = arg.event.title || ''
const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
@@ -1410,8 +1673,11 @@ const fcOptions = computed(() => ({
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
const inativoBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
: ''
return {
html: `<div class="ev-custom">
@@ -1419,6 +1685,7 @@ const fcOptions = computed(() => ({
<div class="ev-body">
${timeHtml}
<div class="ev-title">${esc(title)}</div>
${inativoBadge}
${obsHtml}
</div>
</div>`
@@ -1442,12 +1709,47 @@ const fcOptions = computed(() => ({
const classes = []
if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session')
if (tipo === EVENTO_TIPO.BLOQUEIO) classes.push('evt-block')
// Quando o evento já tem cor do commitment, marca para que o CSS
// não sobrescreva com a cor primária padrão via !important
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
if (qn && hit) classes.push('evt-hit')
if (qn && !hit) classes.push('evt-dim')
return classes
},
eventDidMount: (info) => {
const bgColor = info.event.extendedProps?.commitment_bg_color
if (bgColor) {
info.el.style.setProperty('background-color', bgColor, 'important')
info.el.style.setProperty('border-color', bgColor, 'important')
}
// Marca o elemento com o id do evento para scroll+pulse posterior
info.el.dataset.eventId = info.event.id
}
}))
// ── Scroll + pulse no evento do FullCalendar ─────────────────
const foraJornadaBannerRef = ref(null)
async function scrollToAndPulseEvent (eventId) {
await nextTick()
// Aguarda o FC renderizar após possível mudança de view/data
await new Promise(r => setTimeout(r, 300))
// Pulsa o banner "fora da jornada" se estiver visível
const banner = foraJornadaBannerRef.value
if (banner) {
banner.classList.add('notif-card--highlight')
setTimeout(() => banner.classList.remove('notif-card--highlight'), 2000)
}
const el = document.querySelector(`[data-event-id="${eventId}"]`)
if (!el) return
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
el.classList.add('notif-card--highlight')
setTimeout(() => el.classList.remove('notif-card--highlight'), 2000)
}
// ── Resumo do dia (coluna direita) ────────────────────────────────────────────
const todayEvents = computed(() => {
@@ -1508,6 +1810,7 @@ function onEventRowClick (ev) {
if (ev.inicio_em) {
getApi()?.gotoDate?.(new Date(ev.inicio_em))
calendarView.value = 'day'
scrollToAndPulseEvent(ev.id)
}
}
@@ -1569,6 +1872,119 @@ function getApi () {
return fcRef.value?.getApi?.() || null
}
// ── Pacientes Desativados com sessões pendentes ────────────
const desativadoPatients = ref([])
const desativadoDialogOpen = ref(false)
const desativadoSelected = ref(null)
const desativadoFocused = ref(null)
const desativadoFcRef = ref(null)
async function loadDesativados () {
if (!ownerId.value) return
try {
const { data: pats, error: pErr } = await supabase
.from('patients')
.select('id, nome_completo, status')
.eq('owner_id', ownerId.value)
.in('status', ['Inativo', 'Arquivado'])
if (pErr) { console.warn('[loadDesativados] patients error:', pErr); desativadoPatients.value = []; return }
if (!pats?.length) { desativadoPatients.value = []; return }
const patIds = pats.map(p => p.id)
const sessQ = supabase
.from('agenda_eventos')
.select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id')
.in('patient_id', patIds)
.order('inicio_em', { ascending: true })
if (ownerId.value) sessQ.eq('owner_id', ownerId.value)
if (clinicTenantId.value) sessQ.eq('tenant_id', clinicTenantId.value)
const { data: sessions, error: sErr } = await sessQ
if (sErr) { console.warn('[loadDesativados] sessions error:', sErr) }
const byPat = new Map()
for (const s of (sessions || [])) {
if (!byPat.has(s.patient_id)) byPat.set(s.patient_id, [])
byPat.get(s.patient_id).push(s)
}
desativadoPatients.value = pats
.filter(p => byPat.has(p.id))
.map(p => ({ ...p, sessions: byPat.get(p.id) }))
if (desativadoPatients.value.length && !desativadoSelected.value) {
desativadoSelected.value = desativadoPatients.value[0]
}
} catch (e) { console.warn('[loadDesativados] erro:', e) }
}
const desativadoFcOptions = computed(() => {
const patient = desativadoSelected.value
if (!patient) return {}
const events = (patient.sessions || []).map(s => ({
id: s.id,
title: s.titulo || 'Sessão',
start: s.inicio_em,
end: s.fim_em,
backgroundColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
borderColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
textColor: '#fff',
extendedProps: { session: s }
}))
const firstDate = patient.sessions[0]?.inicio_em
return {
plugins: [listPlugin, dayGridPlugin, interactionPlugin],
locale: ptBrLocale,
initialView: 'listMonth',
initialDate: firstDate || new Date().toISOString(),
events,
headerToolbar: { left: 'prev,next today', center: 'title', right: 'listMonth,dayGridMonth' },
height: '100%',
noEventsText: 'Nenhuma sessão encontrada.',
eventClick: (info) => {
const s = info.event.extendedProps.session
openSessionInMainCalendar(s)
}
}
})
watch(desativadoSelected, () => {
desativadoFocused.value = null
})
function focusDesativadoSession (session) {
desativadoFocused.value = session
const api = desativadoFcRef.value?.getApi?.()
if (api) api.gotoDate(new Date(session.inicio_em))
}
function openSessionInMainCalendar (session) {
desativadoDialogOpen.value = false
const date = new Date(session.inicio_em)
currentDate.value = date
getApi()?.gotoDate?.(date)
// Muda para visão de dia para facilitar encontrar a sessão
const api = getApi()
if (api) api.changeView('timeGridDay', date)
}
// Helpers de formato para o painel de desativados
function fmtDesativadoDate (iso) {
if (!iso) return '—'
const d = new Date(iso)
return d.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })
}
function fmtDesativadoTime (iso) {
if (!iso) return '—'
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
}
function fmtDesativadoDur (start, end) {
if (!start || !end) return ''
const min = Math.round((new Date(end) - new Date(start)) / 60000)
return `${min}min`
}
watch(calendarView, async () => {
await nextTick()
getApi()?.changeView?.(fcViewName.value)
@@ -1947,6 +2363,7 @@ function onEventClick (info) {
paciente_id: ep.paciente_id ?? null,
paciente_nome: ep.paciente_nome ?? null,
paciente_avatar: ep.paciente_avatar ?? null,
paciente_status: ep.paciente_status ?? null,
tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO),
status: ep.status,
titulo: ev.title,
@@ -2847,11 +3264,16 @@ onMounted(async () => {
}
/* ── Cores dos eventos (global — aplicadas pelo FullCalendar) ── */
.fc-event.evt-session {
/* Cor primária padrão só quando o evento não tem cor personalizada do commitment */
.fc-event.evt-session:not(.evt-has-color) {
background-color: var(--p-primary-500, #6366f1) !important;
border-color: var(--p-primary-600, #4f46e5) !important;
color: #fff !important;
}
/* Cor de texto branca garantida para eventos com cor personalizada */
.fc-event.evt-session.evt-has-color {
color: #fff !important;
}
.fc-event.evt-block {
background-color: #ef4444 !important;
border-color: #dc2626 !important;

View File

@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
const { data, error } = await supabase
.from('agenda_eventos')
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url, status), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
.eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO)

View File

@@ -89,6 +89,7 @@ function _mapRow (r) {
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
paciente_nome: nomeP,
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
// campos
observacoes: r.observacoes ?? null,

View File

@@ -157,6 +157,18 @@
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
</div>
<!-- Arquivados -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="filters.status === 'Arquivado'
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
@click="setStatus('Arquivado')"
>
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
</div>
<!-- Último atendimento não clicável -->
<div
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
@@ -436,7 +448,7 @@
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
</template>
</Column>
@@ -481,13 +493,17 @@
</template>
</Column>
<Column :key="'col-acoes'" header="Ações" style="width: 20rem;" frozen alignFrozen="right">
<Column :key="'col-acoes'" header="Ações" style="width: 22rem;" frozen alignFrozen="right">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
<Button v-if="historySet.has(data.id)" :label="`Sessões × ${sessionCountMap.get(data.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
<PatientActionMenu
:patient="data"
:hasHistory="historySet.has(data.id)"
@updated="fetchAll"
/>
</div>
</template>
</Column>
@@ -542,7 +558,7 @@
</div>
<div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div>
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
<Tag :value="pat.status" :severity="statusSeverity(pat.status)" />
</div>
<!-- Grupos + Tags -->
@@ -553,10 +569,14 @@
<!-- Ações -->
<div class="mt-3 flex gap-2 justify-end flex-wrap">
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
<Button v-if="historySet.has(pat.id)" :label="`Sessões × ${sessionCountMap.get(pat.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
<PatientActionMenu
:patient="pat"
:hasHistory="historySet.has(pat.id)"
@updated="fetchAll"
/>
</div>
</div>
</div>
@@ -586,14 +606,85 @@
</TabPanel>
<TabPanel value="grupos">
<Card>
<template #content>
<div class="flex items-center justify-between gap-3">
<div class="text-color-secondary">Atalho para a página de Grupos.</div>
<Button label="Abrir Grupos" icon="pi pi-external-link" outlined @click="goGroups" />
<!-- Cabeçalho da view de grupos -->
<div class="flex items-center justify-between gap-3 mb-4">
<div class="flex items-center gap-2">
<i class="pi pi-sitemap text-[var(--primary-color,#6366f1)]" />
<span class="font-semibold text-[var(--text-color)]">Pacientes distribuídos por grupo</span>
<span
v-if="groupedPatientsView.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"
>{{ groupedPatientsView.length }}</span>
</div>
<Button label="Gerenciar grupos" icon="pi pi-external-link" severity="secondary" outlined size="small" @click="goGroups" />
</div>
<!-- Loading -->
<div v-if="loading" class="flex justify-center py-10">
<ProgressSpinner />
</div>
<!-- Empty -->
<div v-else-if="groupedPatientsView.length === 0" class="flex flex-col items-center justify-center gap-3 py-12 text-[var(--text-color-secondary)]">
<div class="w-14 h-14 rounded-xl bg-indigo-500/10 flex items-center justify-center">
<i class="pi pi-sitemap text-2xl text-indigo-500" />
</div>
<div class="font-semibold text-[var(--text-color)]">Nenhuma associação encontrada</div>
<div class="text-sm opacity-70 text-center max-w-xs">Associe pacientes a grupos no cadastro ou na listagem para visualizá-los aqui.</div>
<Button label="Gerenciar grupos" icon="pi pi-sitemap" outlined size="small" class="mt-1" @click="goGroups" />
</div>
<!-- Grid de grupos -->
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
<div
v-for="grp in groupedPatientsView"
:key="grp.id"
class="rounded-xl border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col shadow-sm hover:shadow-md transition-shadow duration-200"
>
<!-- Barra de cor do grupo -->
<div class="h-1.5 w-full" :style="grpColorStyle(grp.color)" />
<!-- Header do grupo -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)]">
<div
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-sm"
:style="grpColorStyle(grp.color)"
>
{{ (grp.name || '?')[0].toUpperCase() }}
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-[var(--text-color)] truncate text-sm">{{ grp.name }}</div>
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }}
</div>
</div>
<span
class="inline-flex items-center justify-center min-w-[26px] h-6 px-1.5 rounded-full text-white text-xs font-bold flex-shrink-0"
:style="grpColorStyle(grp.color)"
>{{ grp.patients.length }}</span>
</div>
</template>
</Card>
<!-- Chips de pacientes -->
<div class="p-3 flex flex-wrap gap-1.5 flex-1">
<button
v-for="p in grp.patients.slice(0, 12)"
:key="p.id"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border border-[var(--surface-border)] bg-[var(--surface-ground)] text-xs text-[var(--text-color)] hover:bg-[var(--primary-color,#6366f1)] hover:text-white hover:border-transparent cursor-pointer transition-all duration-150 font-medium group"
v-tooltip.top="p.nome_completo"
@click="goEdit(p)"
>
<span class="w-5 h-5 rounded-full bg-indigo-500/15 text-indigo-600 group-hover:bg-white/20 group-hover:text-white flex items-center justify-center text-[9px] font-bold flex-shrink-0 transition-colors">
{{ (p.nome_completo || '?').charAt(0).toUpperCase() }}
</span>
<span class="max-w-[120px] truncate">{{ (p.nome_completo || '—').split(' ').slice(0, 2).join(' ') }}</span>
</button>
<span
v-if="grp.patients.length > 12"
class="inline-flex items-center px-2.5 py-1 rounded-full border border-dashed border-[var(--surface-border)] text-xs text-[var(--text-color-secondary)] font-medium"
>+{{ grp.patients.length - 12 }} mais</span>
</div>
</div>
</div>
</TabPanel>
</TabPanels>
</Tabs>
@@ -696,6 +787,7 @@ import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue'
// ── Descontos por paciente ────────────────────────────────────────
const discountMap = ref({})
@@ -827,20 +919,24 @@ function setAllColumns () { selectedColumns.value = columnCatalogAll.map(c =
const sort = reactive({ field: 'created_at', order: -1 })
const kpis = reactive({ total: 0, active: 0, inactive: 0, latestLastAttended: '' })
const kpis = reactive({ total: 0, active: 0, inactive: 0, archived: 0, latestLastAttended: '' })
const filters = reactive({
status: 'Todos', search: '',
status: 'Ativo', search: '',
groupId: null, tagId: null,
createdFrom: null, createdTo: null
})
const statusOptions = [
{ label: 'Todos', value: 'Todos' },
{ label: 'Ativo', value: 'Ativo' },
{ label: 'Inativo', value: 'Inativo' }
{ label: 'Ativos', value: 'Ativo' },
{ label: 'Inativos', value: 'Inativo' },
{ label: 'Arquivados', value: 'Arquivado' },
{ label: 'Todos', value: 'Todos' }
]
const historySet = ref(new Set())
const sessionCountMap = ref(new Map())
const groupOptions = computed(() => (groups.value || []).map(g => ({ label: g.name, value: g.id })))
const tagOptions = computed(() => (tags.value || []).map(t => ({ label: t.name, value: t.id })))
@@ -964,7 +1060,7 @@ function onFilterChangedDebounced () {
function onFilterChanged () { updateKpis() }
function setStatus (s) { filters.status = s; onFilterChanged() }
function clearAllFilters () {
filters.status = 'Todos'; filters.search = ''; filters.groupId = null
filters.status = 'Ativo'; filters.search = ''; filters.groupId = null
filters.tagId = null; filters.createdFrom = null; filters.createdTo = null
onFilterChanged()
}
@@ -987,6 +1083,15 @@ function normalizeStatus (s) {
return v.charAt(0).toUpperCase() + v.slice(1)
}
function statusSeverity (s) {
if (s === 'Ativo') return 'success'
if (s === 'Inativo') return 'warn'
if (s === 'Arquivado') return 'secondary'
if (s === 'Alta') return 'info'
if (s === 'Encaminhado') return 'contrast'
return 'secondary'
}
function initials (name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—'
@@ -1196,44 +1301,68 @@ async function hydrateAssociationsSupabase () {
groups: groupsByPatient.get(p.id) || [],
tags: tagsByPatient.get(p.id) || []
}))
}
// ── Delete ────────────────────────────────────────────────
function confirmDeleteOne (row) {
const nome = row?.nome_completo || 'este paciente'
confirm.require({
header: 'Excluir paciente',
message: `Tem certeza que deseja excluir "${nome}"?`,
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger',
accept: () => removePatient(row)
})
}
// Calcula historySet — uma única query para todos os ids
const { data: evtCounts } = await supabase
.from('agenda_eventos')
.select('patient_id')
.in('patient_id', ids)
.not('patient_id', 'is', null)
.limit(1000)
async function removePatient (row) {
try {
await supabase.from('patient_group_patient').delete().eq('patient_id', row.id)
await supabase.from('patient_patient_tag').delete().eq('patient_id', row.id)
const { error } = await supabase.from('patients').delete().eq('id', row.id).eq('owner_id', uid.value)
if (error) throw error
patients.value = (patients.value || []).filter(p => p.id !== row.id)
updateKpis()
toast.add({ severity: 'success', summary: 'Ok', detail: 'Paciente excluído.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui excluir.', life: 3500 })
const tempSet = new Set()
const countMap = new Map()
for (const r of (evtCounts || [])) {
if (r.patient_id) {
tempSet.add(r.patient_id)
countMap.set(r.patient_id, (countMap.get(r.patient_id) || 0) + 1)
}
}
historySet.value = tempSet
sessionCountMap.value = countMap
}
// Delete movido para PatientActionMenu + usePatientLifecycle
// ── KPIs ──────────────────────────────────────────────────
function updateKpis () {
const all = patients.value || []
kpis.total = all.length
kpis.active = all.filter(p => p.status === 'Ativo').length
kpis.inactive = all.filter(p => p.status === 'Inativo').length
kpis.archived = all.filter(p => p.status === 'Arquivado').length
const dates = all.map(p => (p.last_attended_at || '').slice(0, 10)).filter(Boolean).sort()
kpis.latestLastAttended = dates.length ? dates[dates.length - 1] : ''
}
// ── Grupos view ───────────────────────────────────────────
const groupedPatientsView = computed(() => {
const all = patients.value || []
const grpMap = new Map()
for (const g of (groups.value || [])) {
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: g.color || g.cor, patients: [], isSystem: !!g.is_system })
}
const ungrouped = { id: '__none__', name: 'Sem grupo', color: null, patients: [], isSystem: false }
for (const p of all) {
const gs = p.groups || []
if (!gs.length) {
ungrouped.patients.push(p)
} else {
for (const g of gs) {
if (grpMap.has(g.id)) grpMap.get(g.id).patients.push(p)
}
}
}
const result = [...grpMap.values()].filter(g => g.patients.length > 0).sort((a, b) => b.patients.length - a.patients.length)
if (ungrouped.patients.length > 0) result.push(ungrouped)
return result
})
function grpColorStyle (color) {
if (!color) return { background: 'var(--surface-border)' }
return { background: color.startsWith('#') ? color : `#${color}` }
}
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) {
if (!row?.created_at) return false

File diff suppressed because it is too large Load Diff

View File

@@ -17,7 +17,7 @@ const { layoutState } = useLayout()
const menuStore = useMenuStore()
// ======================================================
// ✅ Blindagem anti-menu some
// ✅ Blindagem anti-"menu some"
// - se o menuStore.model piscar como [], mantém o último menu válido
// - evita sumiço ao entrar em /admin/clinic/features (reset momentâneo)
// ======================================================

View File

@@ -6,20 +6,17 @@ import Popover from 'primevue/popover'
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
import { useRoleGuard } from '@/composables/useRoleGuard'
const router = useRouter()
const pop = ref(null)
const props = defineProps({
variant: { type: String, default: 'sidebar' }
})
const router = useRouter()
const pop = ref(null)
// ------------------------------------------------------
// RBAC (Tenant): fonte da verdade para permissões por papel
// ------------------------------------------------------
const { role, canSee } = useRoleGuard()
// ------------------------------------------------------
// UI labels (nome/iniciais)
// ------------------------------------------------------
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
@@ -33,167 +30,181 @@ const label = computed(() => {
return name || sessionUser.value?.email || 'Conta'
})
/**
* sublabel:
* Prefere exibir o papel do TENANT (role do useRoleGuard),
* porque governa a UI dentro da clínica.
*/
const sublabel = computed(() => {
const r = role.value || sessionRole.value
if (!r) return 'Sessão'
// tenant roles
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
// portal/global roles
if (r === 'portal_user') return 'Portal'
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
if (r === 'portal_user' || r === 'patient') return 'Portal'
return r
})
// ------------------------------------------------------
// Popover helpers
// ------------------------------------------------------
function toggle (e) {
pop.value?.toggle(e)
}
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
function close () {
try { pop.value?.hide() } catch {}
}
function toggle (e) { pop.value?.toggle(e) }
function close () { try { pop.value?.hide() } catch {} }
// ------------------------------------------------------
// Navegação segura (resolve antes; fallback se não existir)
// ------------------------------------------------------
async function safePush (target, fallback) {
try {
const r = router.resolve(target)
if (r?.matched?.length) return await router.push(target)
} catch {}
if (fallback) {
try { return await router.push(fallback) } catch {}
}
if (fallback) { try { return await router.push(fallback) } catch {} }
return router.push('/')
}
// ------------------------------------------------------
// Actions
// ------------------------------------------------------
function goMyProfile () {
function goMyProfile () { close(); safePush({ name: 'account-profile' }, '/account/profile') }
function goSecurity () { close(); safePush({ name: 'account-security' }, '/account/security') }
function goSettings () {
close()
safePush({ name: 'account-profile' }, '/account/profile')
}
function goSettings () {
close()
// ✅ Configurações é RBAC (quem pode ver, vê)
if (canSee('settings.view')) {
return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
}
// ✅ quem não pode (ex.: paciente), manda pro portal correto
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings')
return safePush({ name: 'portal-sessoes' }, '/portal')
}
function goSecurity () {
close()
// ✅ Segurança é "Account": todos podem acessar
return safePush(
{ name: 'account-security' },
'/account/security'
)
}
async function signOut () {
close()
try {
await supabase.auth.signOut()
} catch {
// se falhar, ainda assim manda pro login
} finally {
router.push('/auth/login')
}
try { await supabase.auth.signOut() } catch {}
finally { router.push('/auth/login') }
}
defineExpose({ toggle })
</script>
<template>
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<button
type="button"
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition"
@click="toggle"
>
<!-- avatar -->
<img
v-if="sessionUser?.user_metadata?.avatar_url"
:src="sessionUser.user_metadata.avatar_url"
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
alt="avatar"
/>
<div
v-else
class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold"
<!-- SIDEBAR: trigger + popover -->
<template v-if="variant === 'sidebar'">
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
<button
type="button"
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150"
@click="toggle"
>
{{ initials }}
</div>
<!-- labels -->
<div class="min-w-0 flex-1 text-left">
<div class="truncate text-sm font-semibold text-[var(--text-color)]">
{{ label }}
<img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
<div v-else class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold">{{ initials }}</div>
<div class="min-w-0 flex-1 text-left">
<div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
<div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
</div>
<div class="truncate text-xs text-[var(--text-color-secondary)]">
{{ sublabel }}
</div>
</div>
<i class="pi pi-angle-up text-xs opacity-40" />
</button>
<i class="pi pi-angle-up text-xs opacity-70" />
</button>
<Popover ref="pop" appendTo="body">
<!-- conteúdo reutilizado via template inline -->
<template v-if="true">
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
</div>
<!-- Nav items -->
<div class="py-1.5">
<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="goMyProfile"
>
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<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="goSecurity"
>
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
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="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
</div>
<!-- Footer: Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150"
@click="signOut"
>
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
</div>
</div>
</template>
</Popover>
</div>
</template>
<!-- RAIL: o popover, trigger externo -->
<template v-else>
<Popover ref="pop" appendTo="body">
<div class="min-w-[220px] p-1">
<Button
v-if="canSee('settings.view')"
label="Configurações"
icon="pi pi-cog"
text
class="w-full justify-start"
@click="goSettings"
/>
<div class="w-[224px] overflow-hidden rounded-[inherit]">
<!-- Header -->
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
<div class="relative shrink-0">
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
</div>
<div class="min-w-0 flex flex-col gap-px">
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
</div>
</div>
<Button
label="Segurança"
icon="pi pi-shield"
text
class="w-full justify-start"
@click="goSecurity"
/>
<!-- Nav items -->
<div class="py-1.5">
<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="goMyProfile"
>
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Meu perfil
</button>
<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="goSecurity"
>
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Segurança
</button>
<button
v-if="canSee('settings.view')"
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="goSettings"
>
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Configurações
</button>
</div>
<Button
label="Meu Perfil"
icon="pi pi-user"
text
class="w-full justify-start"
@click="goMyProfile"
/>
<div class="my-1 border-t border-[var(--surface-border)]" />
<Button
label="Sair"
icon="pi pi-sign-out"
severity="danger"
text
class="w-full justify-start"
@click="signOut"
/>
<!-- Footer: Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150"
@click="signOut"
>
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
</div>
</div>
</Popover>
</div>
</template>
</template>
</template>
<style>
/* Zero padding nativo do PrimeVue — mínimo inevitável pois não há prop pra isso */
.p-popover-content { padding: 0 !important; }
</style>

View File

@@ -8,6 +8,7 @@ import Popover from 'primevue/popover'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
const { layoutState, isDesktop } = useLayout()
const router = useRouter()
@@ -15,6 +16,15 @@ const pop = ref(null)
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const menuBadges = useMenuBadges()
function menuBadgeLabel (item) {
const key = item?.badgeKey
if (!key) return null
const val = menuBadges[key]?.value || 0
if (!val) return null
return key === 'agendaHoje' ? `${val} hoje` : String(val)
}
const emit = defineEmits(['quick-create'])
@@ -102,7 +112,7 @@ const showProBadge = computed(() => {
try {
return !entitlementsStore.has(feature)
} catch {
// se der erro, não mostra (evita PRO fantasma)
// se der erro, não mostra (evita "PRO fantasma")
return false
}
})
@@ -221,6 +231,14 @@ async function irCadastroCompleto () {
PRO
</span>
<!-- Badge contador (agenda hoje / cadastros / agendamentos) -->
<span
v-if="menuBadgeLabel(item)"
class="ml-auto text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none"
>
{{ menuBadgeLabel(item) }}
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
</component>

View File

@@ -1,19 +1,15 @@
<!-- src/layout/AppRail.vue Mini icon rail (Layout 2) -->
<script setup>
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { sessionUser } from '@/app/session'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { sessionUser } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
const menuStore = useMenuStore()
const { layoutConfig, layoutState, isDesktop } = useLayout()
const router = useRouter()
const { layoutState } = useLayout()
// ── Seções do rail (derivadas do model) ─────────────────────
const railSections = computed(() => {
@@ -38,7 +34,6 @@ const initials = computed(() => {
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
return (a + b).toUpperCase()
})
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
// ── Início (fixo) ────────────────────────────────────────────
@@ -74,20 +69,9 @@ function isActiveSectionOrChild (section) {
})
}
// ── Popover do usuário (rodapé) ───────────────────────────────
const userPop = ref(null)
function toggleUserPop (e) { userPop.value?.toggle(e) }
function goTo (path) {
try { userPop.value?.hide() } catch {}
router.push(path)
}
async function signOut () {
try { userPop.value?.hide() } catch {}
try { await supabase.auth.signOut() } catch {}
router.push('/auth/login')
}
// ── Menu do usuário (rodapé) ─────────────────────────────────
const footerPanel = ref(null)
function toggleUserMenu (e) { footerPanel.value?.toggle(e) }
</script>
<template>
@@ -131,47 +115,26 @@ async function signOut () {
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações"
@click="goTo('/configuracoes')"
@click="$router.push('/configuracoes')"
>
<i class="pi pi-fw pi-cog" />
</button>
<!-- Avatar / user -->
<!-- Avatar trigger do menu de usuário -->
<button
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName"
@click="toggleUserPop"
@click="toggleUserMenu"
>
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</button>
</div>
<!-- Popover usuário -->
<Popover ref="userPop" appendTo="body">
<div class="min-w-[210px] p-1 flex flex-col gap-0.5">
<div class="flex items-center gap-2.5 px-2.5 py-2 pb-2.5">
<div class="w-9 h-9 rounded-[9px] overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center border border-[var(--surface-border)]">
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" />
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</div>
<div class="min-w-0">
<div class="text-[0.83rem] font-semibold text-[var(--text-color)] truncate">{{ userName }}</div>
<div class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">{{ sessionUser?.email }}</div>
</div>
</div>
<!-- Menu de usuário (popup via AppMenuFooterPanel) -->
<AppMenuFooterPanel ref="footerPanel" variant="rail" />
<div class="h-px bg-[var(--surface-border)] my-0.5" />
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
<div class="h-px bg-[var(--surface-border)] my-0.5" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
</div>
</Popover>
</aside>
</template>

View File

@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
const menuStore = useMenuStore()
const { layoutState } = useLayout()
const entitlements = useEntitlementsStore()
const menuBadges = useMenuBadges()
const router = useRouter()
const route = useRoute()
function menuBadgeLabel (item) {
const key = item?.badgeKey
if (!key) return null
const val = menuBadges[key]?.value || 0
if (!val) return null
return key === 'agendaHoje' ? `${val} hoje` : String(val)
}
// ── Seção ativa ──────────────────────────────────────────────
const currentSection = computed(() => {
const model = menuStore.model || []
@@ -372,6 +382,7 @@ async function goToResult (r) {
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ child.label }}</span>
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
</button>
</div>
@@ -388,6 +399,7 @@ async function goToResult (r) {
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ item.label }}</span>
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
</button>
</template>

View File

@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
const menuStore = useMenuStore()
const { layoutState, hideMobileMenu } = useLayout()
const entitlements = useEntitlementsStore()
const menuBadges = useMenuBadges()
const router = useRouter()
const route = useRoute()
function menuBadgeLabel (item) {
const key = item?.badgeKey
if (!key) return null
const val = menuBadges[key]?.value || 0
if (!val) return null
return key === 'agendaHoje' ? `${val} hoje` : String(val)
}
const sections = computed(() => {
const model = menuStore.model || []
return model
@@ -389,6 +399,7 @@ watch(() => route.path, () => hideMobileMenu())
<i v-if="child.icon" :class="child.icon" class="rs__item-icon" />
<span>{{ child.label }}</span>
<span v-if="isLocked(child)" class="rs__pro">PRO</span>
<span v-if="menuBadgeLabel(child)" class="rs__badge">{{ menuBadgeLabel(child) }}</span>
</button>
</template>
@@ -405,6 +416,7 @@ watch(() => route.path, () => hideMobileMenu())
<i v-if="item.icon" :class="item.icon" class="rs__item-icon" />
<span>{{ item.label }}</span>
<span v-if="isLocked(item)" class="rs__pro">PRO</span>
<span v-if="menuBadgeLabel(item)" class="rs__badge">{{ menuBadgeLabel(item) }}</span>
</button>
</template>
</div>
@@ -672,6 +684,15 @@ watch(() => route.path, () => hideMobileMenu())
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
}
.rs__badge {
font-size: 0.62rem;
font-weight: 700;
padding: 1px 6px;
border-radius: 999px;
background: var(--primary-color);
color: #fff;
line-height: 1;
}
/* ── Slide-in da esquerda ────────────────────────────────── */
.rs-slide-enter-active,

View File

@@ -11,7 +11,7 @@ let outsideClickListener = null
// ✅ rota mudou:
// - atualiza activePath sempre (desktop e mobile)
// - fecha menu SOMENTE no mobile (evita sumir no desktop / inconsistências)
// - fecha menu SOMENTE no mobile (evita "sumir" no desktop / inconsistências)
watch(
() => route.path,
(newPath) => {

View File

@@ -495,7 +495,7 @@ async function logout () {
}
/**
* ✅ Bootstrap entitlements (resolve menu não alterna sem depender do guard)
* ✅ Bootstrap entitlements (resolve "menu não alterna" sem depender do guard)
* - se tem tenant ativo => carrega tenant entitlements
* - senão => carrega user entitlements
*/

View File

@@ -44,7 +44,7 @@ const layoutState = reactive({
*
* Motivo: você aplica tema cedo (main.js / user_settings) e depois
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
* isDarkTheme pode ficar mentindo.
* isDarkTheme pode ficar "mentindo".
*/
let _syncedDarkFromDomOnce = false
function syncDarkFromDomOnce () {

View File

@@ -46,7 +46,7 @@ function resolveMenu (builder, ctx) {
}
}
// core menu anti-sumir
// core menu anti-"sumir"
function coreMenu () {
return [
{

View File

@@ -11,7 +11,8 @@ export default function adminMenu (ctx = {}) {
label: 'Agenda da Clínica',
icon: 'pi pi-fw pi-calendar',
to: { name: 'admin-agenda-clinica' },
feature: 'agenda.view'
feature: 'agenda.view',
badgeKey: 'agendaHoje'
},
// ✅ Compromissos determinísticos (tipos)
@@ -41,7 +42,7 @@ export default function adminMenu (ctx = {}) {
{ label: 'Grupos', icon: 'pi pi-fw pi-sitemap', to: { name: 'admin-pacientes-grupos' } },
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' } }
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' }
]
},
@@ -79,7 +80,8 @@ export default function adminMenu (ctx = {}) {
icon: 'pi pi-fw pi-inbox',
to: { name: 'admin-agendamentos-recebidos' },
feature: 'online_scheduling.manage',
proBadge: true
proBadge: true,
badgeKey: 'agendamentosRecebidos'
}
]
}

View File

@@ -11,7 +11,7 @@ export default [
{
label: 'Agenda',
items: [
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true },
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true, badgeKey: 'agendaHoje' },
{ label: 'Compromissos', icon: 'pi pi-fw pi-clock', to: '/therapist/agenda/compromissos', feature: 'agenda.view', proBadge: true }
]
},
@@ -23,7 +23,7 @@ export default [
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos' }
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
]
},
@@ -42,7 +42,8 @@ export default [
icon: 'pi pi-fw pi-inbox',
to: '/therapist/agendamentos-recebidos',
feature: 'online_scheduling.manage',
proBadge: true
proBadge: true,
badgeKey: 'agendamentosRecebidos'
}
]
},

View File

@@ -6,12 +6,12 @@
* - Entitlements (plano): usuário poderia acessar se tivesse feature → manda pro /upgrade
*
* Por que isso existe?
* - Evitar o bug clássico: pessoa sem permissão caiu no /upgrade
* - Evitar o bug clássico: "pessoa sem permissão caiu no /upgrade"
* - Padronizar o comportamento do app em um único lugar
* - Deixar claro: RBAC ≠ Plano
*
* Convenção recomendada:
* - RBAC (role): sempre é bloqueio (403) OU home do papel (UX mais suave)
* - RBAC (role): sempre é bloqueio (403) OU home do papel (UX mais "suave")
* - Plano (feature): sempre é upgrade (porque o usuário *poderia* ter acesso pagando)
*/
@@ -35,16 +35,16 @@ export function roleHomePath (role) {
/**
* RBAC (papel) → padrão: acesso negado (403).
*
* Se você preferir UX suave, pode mandar para a home do papel.
* Se você preferir UX "suave", pode mandar para a home do papel.
* Eu deixei as duas opções:
* - use403 = true → sempre /pages/access (recomendado para clareza)
* - use403 = false → home do papel (útil quando você quer auto-corrigir navegação)
* - use403 = false → home do papel (útil quando você quer "auto-corrigir" navegação)
*/
export function denyByRole ({ to, currentRole, use403 = true } = {}) {
// ✅ padrão forte: 403 (não é caso de upgrade)
if (use403) return { path: '/pages/access' }
// modo suave: manda pra home do papel
// modo "suave": manda pra home do papel
const fallback = roleHomePath(currentRole)
// evita loop: se já está no fallback, manda pra página de acesso negado

View File

@@ -635,7 +635,7 @@ export function applyGuards (router) {
}
}
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue por engano
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue "por engano"
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
_perfEnd()

View File

@@ -32,7 +32,7 @@ export async function setSlotBloqueado(ownerId, diaSemana, horaInicio, isBloquea
return true
}
// desbloquear: deletar (ou marcar ativo=false; aqui vou deletar por simplicidade)
// "desbloquear": deletar (ou marcar ativo=false; aqui vou deletar por simplicidade)
const { error } = await supabase
.from('agenda_slots_bloqueados_semanais')
.delete()

View File

@@ -0,0 +1,85 @@
-- ============================================================
-- CICLO DE VIDA DE PACIENTES — patient_lifecycle.sql
-- Rodar no Supabase SQL Editor (idempotente)
-- ============================================================
-- ------------------------------------------------------------
-- 1.1 Expandir CHECK de status para incluir 'Arquivado'
-- ------------------------------------------------------------
ALTER TABLE public.patients
DROP CONSTRAINT IF EXISTS patients_status_check;
ALTER TABLE public.patients
ADD CONSTRAINT patients_status_check
CHECK (status = ANY(ARRAY[
'Ativo'::text,
'Inativo'::text,
'Alta'::text,
'Encaminhado'::text,
'Arquivado'::text
]));
-- ------------------------------------------------------------
-- 1.2 can_delete_patient(uuid) → boolean
-- Retorna false se existir histórico clínico ou financeiro
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.can_delete_patient(p_patient_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
AS $$
SELECT NOT EXISTS (
SELECT 1 FROM public.agenda_eventos WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM public.recurrence_rules WHERE patient_id = p_patient_id
UNION ALL
SELECT 1 FROM public.billing_contracts WHERE patient_id = p_patient_id
);
$$;
GRANT EXECUTE ON FUNCTION public.can_delete_patient(uuid)
TO postgres, anon, authenticated, service_role;
-- ------------------------------------------------------------
-- 1.3 safe_delete_patient(uuid) → jsonb
-- Verifica histórico antes de deletar fisicamente
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION public.safe_delete_patient(p_patient_id uuid)
RETURNS jsonb
LANGUAGE plpgsql SECURITY DEFINER
AS $$
BEGIN
-- Bloqueia se houver histórico
IF NOT public.can_delete_patient(p_patient_id) THEN
RETURN jsonb_build_object(
'ok', false,
'error', 'has_history',
'message', 'Este paciente possui histórico clínico ou financeiro e não pode ser removido. Você pode desativar ou arquivar o paciente.'
);
END IF;
-- Verifica ownership via RLS (owner_id ou responsible_member_id)
IF NOT EXISTS (
SELECT 1 FROM public.patients
WHERE id = p_patient_id
AND (
owner_id = auth.uid()
OR responsible_member_id IN (
SELECT id FROM public.tenant_members WHERE user_id = auth.uid()
)
)
) THEN
RETURN jsonb_build_object(
'ok', false,
'error', 'forbidden',
'message', 'Sem permissão para excluir este paciente.'
);
END IF;
DELETE FROM public.patients WHERE id = p_patient_id;
RETURN jsonb_build_object('ok', true);
END;
$$;
GRANT EXECUTE ON FUNCTION public.safe_delete_patient(uuid)
TO postgres, anon, authenticated, service_role;

View File

@@ -48,7 +48,7 @@ export const surfaces = [
]
/**
* ✅ noir: primary vira surface (o bloco que você pediu pra ficar aqui)
* ✅ noir: primary "vira" surface (o bloco que você pediu pra ficar aqui)
*/
export const noirPrimaryFromSurface = {
50: '{surface.50}',
@@ -72,7 +72,7 @@ export function getSurfacePalette(surfaceName) {
}
/**
* ✅ Ponto único: Preset Extension baseado no layoutConfig atual
* ✅ Ponto único: "Preset Extension" baseado no layoutConfig atual
* Use assim: updatePreset(getPresetExt(layoutConfig))
*/
export function getPresetExt(layoutConfig) {

View File

@@ -55,7 +55,7 @@ function goDashboard () {
</p>
</div>
<!-- selo minimalista -->
<!-- "selo" minimalista -->
<div
class="shrink-0 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
>
@@ -95,7 +95,7 @@ function goDashboard () {
/>
</div>
<!-- rodapé noir discreto -->
<!-- rodapé "noir" discreto -->
<div class="mt-6 text-xs text-[var(--text-color-secondary)] opacity-80">
Se isso estiver acontecendo com frequência, pode ser um problema de rota ou permissão.
</div>

View File

@@ -24,7 +24,7 @@ const applyingPreset = ref(false)
// evita cliques enquanto o contexto inicial ainda tá montando
const booting = ref(true)
// guarda features que o plano bloqueou (pra não ficar clicando e errando)
// guarda features que o plano bloqueou (pra não ficar "clicando e errando")
const planDenied = ref(new Set())
const tenantId = computed(() =>
@@ -110,7 +110,7 @@ function requestMenuRefresh () {
async function afterFeaturesChanged () {
if (!tenantId.value) return
// ✅ refresh suave (evita pisca vazio)
// ✅ refresh suave (evita "pisca vazio")
await tf.fetchForTenant(tenantId.value, { force: false })
// ✅ nunca navegar/replace aqui
@@ -292,7 +292,7 @@ watch(
clearPlanDenied()
try {
// ✅ não force no mount para evitar pisca
// ✅ não force no mount para evitar "pisca"
await tf.fetchForTenant(id, { force: false })
// ✅ reset só quando estiver estável (debounced)
@@ -327,246 +327,246 @@ watch(
<Toast />
<!-- Sentinel -->
<div class=h-px />
<div class="h-px" />
<!-- Hero sticky -->
<div
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10 />
<div class=absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10 />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
</div>
<div class=relative z-10 flex flex-col gap-2>
<div class=flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Tipos de Clínica</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>
<div class="relative z-10 flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Tipos de Clínica</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).
</div>
</div>
<div class=shrink-0 flex items-center gap-2>
<div class="shrink-0 flex items-center gap-2">
<Button
label=Recarregar
icon=pi pi-refresh
severity=secondary
label="Recarregar"
icon="pi pi-refresh"
severity="secondary"
outlined
:loading=loading
:disabled=applyingPreset || !!savingKey
@click=reload
:loading="loading"
:disabled="applyingPreset || !!savingKey"
@click="reload"
/>
</div>
</div>
<div class=flex flex-wrap items-center gap-2>
<span class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]>
<i class=pi pi-building />
Tenant: <b class=font-mono>{{ tenantId || '' }}</b>
<div class="flex flex-wrap items-center gap-2">
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-building" />
Tenant: <b class="font-mono">{{ tenantId || '<EFBFBD>"' }}</b>
</span>
<span class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]>
<i class=pi pi-user />
Role: <b>{{ role || '' }}</b>
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
<i class="pi pi-user" />
Role: <b>{{ role || '<EFBFBD>"' }}</b>
</span>
<span
v-if=!tenantReady
class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70
v-if="!tenantReady"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
>
<i class=pi pi-spin pi-spinner />
<i class="pi pi-spin pi-spinner" />
Carregando contexto
</span>
<span
v-else-if=loading
class=inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70
v-else-if="loading"
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
>
<i class=pi pi-spin pi-spinner />
<i class="pi pi-spin pi-spinner" />
Atualizando módulos
</span>
</div>
</div>
</div>
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Banner: somente leitura -->
<div
v-if=!isOwner && tenantReady
class=flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]
v-if="!isOwner && tenantReady"
class="flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]"
>
<i class=pi pi-lock text-amber-400 shrink-0 />
<span class=text-[1rem] text-[var(--text-color)] opacity-90>
<i class="pi pi-lock text-amber-400 shrink-0" />
<span class="text-[1rem] text-[var(--text-color)] opacity-90">
Você está visualizando as configurações da clínica em <b>modo somente leitura</b>.
Apenas o administrador pode ativar ou desativar módulos.
</span>
</div>
<!-- Presets -->
<div class=grid grid-cols-1 md:grid-cols-3 gap-3>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class=flex items-start justify-between gap-3>
<div class=min-w-0>
<div class=text-[1rem] font-semibold text-[var(--text-color)]>Preset: Coworking</div>
<div class=mt-1 text-[1rem] text-[var(--text-color-secondary)]>
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Coworking</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Para aluguel de salas: sem pacientes, com salas.
</div>
</div>
<Button
size=small
label=Aplicar
severity=secondary
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('coworking')
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('coworking')"
/>
</div>
</div>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class=flex items-start justify-between gap-3>
<div class=min-w-0>
<div class=text-[1rem] font-semibold text-[var(--text-color)]>Preset: Clínica com recepção</div>
<div class=mt-1 text-[1rem] text-[var(--text-color-secondary)]>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica com recepção</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Para secretária gerenciar agenda (pacientes opcional).
</div>
</div>
<Button
size=small
label=Aplicar
severity=secondary
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('reception')
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('reception')"
/>
</div>
</div>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class=flex items-start justify-between gap-3>
<div class=min-w-0>
<div class=text-[1rem] font-semibold text-[var(--text-color)]>Preset: Clínica completa</div>
<div class=mt-1 text-[1rem] text-[var(--text-color-secondary)]>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica completa</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
Pacientes + recepção + salas (se quiser).
</div>
</div>
<Button
size=small
label=Aplicar
severity=secondary
size="small"
label="Aplicar"
severity="secondary"
outlined
:loading=applyingPreset
:disabled=!isOwner || !tenantReady || loading || !!savingKey
@click=applyPreset('full')
:loading="applyingPreset"
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
@click="applyPreset('full')"
/>
</div>
</div>
</div>
<!-- Modules -->
<div class=grid grid-cols-1 lg:grid-cols-2 gap-3>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title=Pacientes
desc=Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist).
icon=pi pi-users
:enabled=isOn('patients')
:loading=savingKey === 'patients'
:disabled=isLocked('patients')
@toggle=toggle('patients')
title="Pacientes"
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
icon="pi pi-users"
:enabled="isOn('patients')"
:loading="savingKey === 'patients'"
:disabled="isLocked('patients')"
@toggle="toggle('patients')"
/>
<div
v-if=planDenied.has('patients')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
v-if="planDenied.has('patients')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class=pi pi-lock mr-2 />
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Quando desligado:
<ul class=mt-2 list-disc pl-5 space-y-1>
<li>Menu Pacientes some.</li>
<li>Rotas com <span class=font-mono>meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>Menu "Pacientes" some.</li>
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra .</li>
<li>RLS bloqueia acesso direto no banco.</li>
</ul>
</div>
</div>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title=Recepção / Secretária
desc=Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente).
icon=pi pi-briefcase
:enabled=isOn('shared_reception')
:loading=savingKey === 'shared_reception'
:disabled=isLocked('shared_reception')
@toggle=toggle('shared_reception')
title="Recepção / Secretária"
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
icon="pi pi-briefcase"
:enabled="isOn('shared_reception')"
:loading="savingKey === 'shared_reception'"
:disabled="isLocked('shared_reception')"
@toggle="toggle('shared_reception')"
/>
<div
v-if=planDenied.has('shared_reception')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
v-if="planDenied.has('shared_reception')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class=pi pi-lock mr-2 />
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
Observação: este módulo é produto (UX + permissões). A base aqui é o toggle.
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Observação: este módulo é "produto" (UX + permissões). A base aqui é o toggle.
Depois a gente cria:
<ul class=mt-2 list-disc pl-5 space-y-1>
<li>role <span class=font-mono>secretary</span> em <span class=font-mono>tenant_members</span></li>
<ul class="mt-2 list-disc pl-5 space-y-1">
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
<li>policies e telas para a secretária</li>
<li>nível de visibilidade do paciente na agenda</li>
</ul>
</div>
</div>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title=Salas / Coworking
desc=Habilita cadastro e reserva de salas/recursos no agendamento.
icon=pi pi-building
:enabled=isOn('rooms')
:loading=savingKey === 'rooms'
:disabled=isLocked('rooms')
@toggle=toggle('rooms')
title="Salas / Coworking"
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
icon="pi pi-building"
:enabled="isOn('rooms')"
:loading="savingKey === 'rooms'"
:disabled="isLocked('rooms')"
@toggle="toggle('rooms')"
/>
<div
v-if=planDenied.has('rooms')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
v-if="planDenied.has('rooms')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class=pi pi-lock mr-2 />
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
</div>
</div>
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
<ModuleRow
title=Link externo de cadastro
desc=Libera fluxo público de intake/cadastro externo para a clínica.
icon=pi pi-link
:enabled=isOn('intake_public')
:loading=savingKey === 'intake_public'
:disabled=isLocked('intake_public')
@toggle=toggle('intake_public')
title="Link externo de cadastro"
desc="Libera fluxo público de intake/cadastro externo para a clínica."
icon="pi pi-link"
:enabled="isOn('intake_public')"
:loading="savingKey === 'intake_public'"
:disabled="isLocked('intake_public')"
@toggle="toggle('intake_public')"
/>
<div
v-if=planDenied.has('intake_public')
class=mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90
v-if="planDenied.has('intake_public')"
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
>
<i class=pi pi-lock mr-2 />
<i class="pi pi-lock mr-2" />
Este módulo foi bloqueado pelo plano atual do tenant.
</div>
<Divider class=my-4 />
<div class=text-[1rem] text-[var(--text-color-secondary)] leading-relaxed>
<Divider class="my-4" />
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
Você tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
</div>
</div>

View File

@@ -183,7 +183,7 @@ function friendlyError (err) {
function safeRpcError (rpcError) {
const raw = (rpcError?.message || '').toString().trim()
// Por padrão: mensagem amigável. Se quiser ver a real, coloque em debugDetails.
// Por padrão: mensagem amigável. Se quiser ver a "real", coloque em debugDetails.
const friendly = friendlyError(rpcError)
return { friendly, raw }
}
@@ -241,7 +241,7 @@ async function acceptInvite (token) {
const { friendly, raw } = safeRpcError(error)
state.error = friendly
// Se você quiser ver a mensagem crua para debug, descomente a linha abaixo:
// Se você quiser ver a mensagem "crua" para debug, descomente a linha abaixo:
// state.debugDetails = raw
// Opcional: toast discreto

View File

@@ -2,7 +2,7 @@
<div class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100">
<Toast />
<!-- Backdrop conceitual -->
<!-- "Backdrop" conceitual -->
<div class="pointer-events-none fixed inset-0 opacity-30">
<div class="absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-emerald-400 blur-3xl" />
<div class="absolute top-40 left-16 h-56 w-56 rounded-full bg-indigo-400 blur-3xl" />
@@ -1233,7 +1233,7 @@ function validate () {
// Progress (conceitual e útil)
// ------------------------------------------------------
const progressPct = computed(() => {
// contagem simples e honesta: dá sensação de avanço sem gamificar demais
// contagem simples e honesta: dá sensação de avanço sem "gamificar demais"
const checks = [
!!cleanStr(form.nome_completo),
!!digitsOnly(form.telefone),

View File

@@ -64,7 +64,7 @@
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl leading-relaxed">
Centralize a rotina clínica em um lugar : pacientes, sessões, lembretes e indicadores.
O objetivo não é burocratizar: é deixar o consultório respirável.
O objetivo não é "burocratizar": é deixar o consultório respirável.
</p>
<div class="mt-6 flex flex-col sm:flex-row gap-2">
@@ -101,7 +101,7 @@
</div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
A diferença entre ter uma agenda e ter um sistema mora nos detalhes.
"A diferença entre ter uma agenda e ter um sistema mora nos detalhes."
</div>
</div>
@@ -322,7 +322,7 @@
<div>
<div class="font-semibold">3) Acompanhar</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
Financeiro e indicadores acompanham o movimento. Menos cadê?, mais previsibilidade.
Financeiro e indicadores acompanham o movimento. Menos "cadê?", mais previsibilidade.
</div>
<div class="mt-2">
<Tag severity="secondary" value="Recebimentos" />

View File

@@ -21,7 +21,7 @@ const email = ref('')
const password = ref('')
const loading = ref(false)
// validação simples (sem viajar)
// validação simples (sem "viajar")
const emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()))
const passwordOk = computed(() => String(password.value || '').length >= 6)
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value)

View File

@@ -156,7 +156,7 @@ function isEnabled (planId, featureId) {
/**
* ✅ Toggle agora NÃO salva no banco.
* Apenas altera o estado local (links) e marca como pendente.
* Apenas altera o estado local (links) e marca como "pendente".
*/
function toggleLocal (planId, featureId, nextValue) {
if (loading.value || saving.value) return

View File

@@ -433,91 +433,91 @@ onBeforeUnmount(() => {
<ConfirmDialog />
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<div ref="heroSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref=heroEl
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10 />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
</div>
<div class=relative z-10 flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Planos e preços</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Catálogo de planos do SaaS.</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Planos e preços</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Catálogo de planos do SaaS.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton
v-model=targetFilter
:options=targetFilterOptions
optionLabel=label
optionValue=value
size=small
v-model="targetFilter"
:options="targetFilterOptions"
optionLabel="label"
optionValue="value"
size="small"
/>
<Button label=Atualizar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving @click=fetchAll />
<Button label=Adicionar plano icon=pi pi-plus size=small :disabled=saving @click=openCreate />
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class=flex xl:hidden>
<div class="flex xl:hidden">
<Button
label=Ações
icon=pi pi-ellipsis-v
severity=warn
size=small
aria-haspopup=true
aria-controls=plans_hero_menu
@click=(e) => heroMenuRef.toggle(e)
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="plans_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref=heroMenuRef id=plans_hero_menu :model=heroMenuItems :popup=true />
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<DataTable :value=filteredRows dataKey=id :loading=loading stripedRows responsiveLayout=scroll>
<Column field=name header=Nome sortable style=min-width: 14rem />
<Column field=key header=Key sortable />
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
<Column field="key" header="Key" sortable />
<Column field=target header=Público sortable style=width: 10rem>
<template #body={ data }>
<span class=font-medium>{{ formatTargetLabel(data.target) }}</span>
<Column field="target" header="Público" sortable style="width: 10rem">
<template #body="{ data }">
<span class="font-medium">{{ formatTargetLabel(data.target) }}</span>
</template>
</Column>
<Column header=Mensal sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header="Mensal" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header=Anual sortable style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header="Anual" sortable style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column v-if=hasCreatedAt field=created_at header=Criado em sortable />
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
<Column header=Ações style=width: 12rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined @click=openEdit(data) />
<Column header="Ações" style="width: 12rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
<Button
icon=pi pi-trash
severity=danger
icon="pi pi-trash"
severity="danger"
outlined
:disabled=isDeleteLockedRow(data)
:title=isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'
@click=askDelete(data)
:disabled="isDeleteLockedRow(data)"
:title="isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'"
@click="askDelete(data)"
/>
</div>
</template>
@@ -526,137 +526,137 @@ onBeforeUnmount(() => {
</div>
<Dialog
v-model:visible=showDlg
v-model:visible="showDlg"
modal
:draggable=false
:header=isEdit ? 'Editar plano' : 'Novo plano'
:style={ width: '620px' }
:draggable="false"
:header="isEdit ? 'Editar plano' : 'Novo plano'"
:style="{ width: '620px' }"
>
<div class=flex flex-col gap-4>
<div class="flex flex-col gap-4">
<div>
<label class=block mb-2>Público do plano</label>
<label class="block mb-2">Público do plano</label>
<SelectButton
v-model=form.target
:options=targetOptions
optionLabel=label
optionValue=value
class=w-full
:disabled=isTargetLocked || saving
v-model="form.target"
:options="targetOptions"
optionLabel="label"
optionValue="value"
class="w-full"
:disabled="isTargetLocked || saving"
/>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Planos existentes não mudam de público. Isso evita inconsistência no catálogo.
</div>
</div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-tag />
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-tag" />
<InputText
v-model=form.key
id=plan_key
class=w-full pr-10
variant=filled
placeholder=ex.: clinic_pro
:disabled=(isCorePlanEditing || saving)
@blur=form.key = slugifyKey(form.key)
v-model="form.key"
id="plan_key"
class="w-full pr-10"
variant="filled"
placeholder="ex.: clinic_pro"
:disabled="(isCorePlanEditing || saving)"
@blur="form.key = slugifyKey(form.key)"
/>
</IconField>
<label for=plan_key>Key</label>
<label for="plan_key">Key</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
<div class="text-[1rem] text-[var(--text-color-secondary)] -mt-3">
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
</div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-bookmark />
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-bookmark" />
<InputText
v-model=form.name
id=plan_name
class=w-full pr-10
variant=filled
placeholder=ex.: Clínica PRO
:disabled=saving
v-model="form.name"
id="plan_name"
class="w-full pr-10"
variant="filled"
placeholder="ex.: Clínica PRO"
:disabled="saving"
/>
</IconField>
<label for=plan_name>Nome</label>
<label for="plan_name">Nome</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] -mt-3>
<div class="text-[1rem] text-[var(--text-color-secondary)] -mt-3">
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
</div>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-money-bill />
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-money-bill" />
<InputNumber
v-model=form.price_monthly
inputId=price_monthly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 49,90
:disabled=saving
v-model="form.price_monthly"
inputId="price_monthly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 49,90"
:disabled="saving"
/>
</IconField>
<label for=price_monthly>Preço mensal (R$)</label>
<label for="price_monthly">Preço mensal (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Deixe vazio para "sem preço definido".</div>
</div>
<div>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-calendar />
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-calendar" />
<InputNumber
v-model=form.price_yearly
inputId=price_yearly
class=w-full
inputClass=w-full pr-10
variant=filled
mode=decimal
:minFractionDigits=2
:maxFractionDigits=2
placeholder=ex.: 490,00
:disabled=saving
v-model="form.price_yearly"
inputId="price_yearly"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
mode="decimal"
:minFractionDigits="2"
:maxFractionDigits="2"
placeholder="ex.: 490,00"
:disabled="saving"
/>
</IconField>
<label for=price_yearly>Preço anual (R$)</label>
<label for="price_yearly">Preço anual (R$)</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Deixe vazio para sem preço definido.</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Deixe vazio para "sem preço definido".</div>
</div>
</div>
<!-- max_supervisees: para planos de supervisor -->
<div v-if=form.target === 'supervisor'>
<FloatLabel variant=on class=w-full>
<IconField class=w-full>
<InputIcon class=pi pi-users />
<div v-if="form.target === 'supervisor'">
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-users" />
<InputNumber
v-model=form.max_supervisees
inputId=max_supervisees
class=w-full
inputClass=w-full pr-10
variant=filled
:useGrouping=false
:min=1
placeholder=ex.: 3
:disabled=saving
v-model="form.max_supervisees"
inputId="max_supervisees"
class="w-full"
inputClass="w-full pr-10"
variant="filled"
:useGrouping="false"
:min="1"
placeholder="ex.: 3"
:disabled="saving"
/>
</IconField>
<label for=max_supervisees>Limite de supervisionados</label>
<label for="max_supervisees">Limite de supervisionados</label>
</FloatLabel>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
</div>
</div>
<template #footer>
<Button label=Cancelar severity=secondary outlined @click=showDlg = false :disabled=saving />
<Button :label=isEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=saving @click=save />
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
</template>

View File

@@ -48,7 +48,7 @@ const targetOptions = [
const previewPricePolicy = ref('hide') // 'hide' | 'consult'
const previewPolicyOptions = [
{ label: 'Ocultar sem preço', value: 'hide' },
{ label: 'Mostrar Sob consulta', value: 'consult' }
{ label: 'Mostrar "Sob consulta"', value: 'consult' }
]
function normalizeTarget (row) {
@@ -450,148 +450,148 @@ onBeforeUnmount(() => {
<ConfirmDialog />
<!-- Sentinel -->
<div ref=heroSentinelRef class=h-px />
<div ref="heroSentinelRef" class="h-px" />
<!-- Hero sticky -->
<div
ref=heroEl
class=sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5
:style={ top: 'var(--layout-sticky-top, 56px)' }
ref="heroEl"
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<div class=absolute inset-0 pointer-events-none overflow-hidden aria-hidden=true>
<div class=absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10 />
<div class=absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10 />
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
</div>
<div class=relative z-10 flex items-center justify-between gap-3 flex-wrap>
<div class=min-w-0>
<div class=text-[1rem] font-bold tracking-tight text-[var(--text-color)]>Vitrine de Planos</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-0.5>Configure como os planos aparecem na página pública.</div>
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
<div class="min-w-0">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Vitrine de Planos</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure como os planos aparecem na página pública.</div>
</div>
<!-- Ações desktop ( 1200px) -->
<div class=hidden xl:flex items-center gap-2 flex-wrap>
<SelectButton v-model=targetFilter :options=targetOptions optionLabel=label optionValue=value size=small :disabled=loading || saving || bulletSaving />
<Button label=Recarregar icon=pi pi-refresh severity=secondary outlined size=small :loading=loading :disabled=saving || bulletSaving @click=fetchAll />
<div class="hidden xl:flex items-center gap-2 flex-wrap">
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
</div>
<!-- Ações mobile (< 1200px) -->
<div class=flex xl:hidden>
<div class="flex xl:hidden">
<Button
label=Ações
icon=pi pi-ellipsis-v
severity=warn
size=small
aria-haspopup=true
aria-controls=showcase_hero_menu
@click=(e) => heroMenuRef.toggle(e)
label="Ações"
icon="pi pi-ellipsis-v"
severity="warn"
size="small"
aria-haspopup="true"
aria-controls="showcase_hero_menu"
@click="(e) => heroMenuRef.toggle(e)"
/>
<Menu ref=heroMenuRef id=showcase_hero_menu :model=heroMenuItems :popup=true />
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
</div>
</div>
</div>
<!-- content -->
<div class=px-3 md:px-4 pb-8 flex flex-col gap-4>
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
<!-- Search -->
<div>
<FloatLabel variant=on class=w-full md:w-80>
<IconField class=w-full>
<InputIcon class=pi pi-search />
<InputText v-model=q id=plans_public_search class=w-full pr-10 variant=filled :disabled=loading || saving || bulletSaving />
<FloatLabel variant="on" class="w-full md:w-80">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" :disabled="loading || saving || bulletSaving" />
</IconField>
<label for=plans_public_search>Buscar plano</label>
<label for="plans_public_search">Buscar plano</label>
</FloatLabel>
</div>
<!-- Popover global (reutilizado) -->
<Popover ref=bulletsPop>
<div class=w-[340px] max-w-[80vw]>
<div class=text-[1rem] font-semibold mb-2>{{ popPlanTitle }}</div>
<Popover ref="bulletsPop">
<div class="w-[340px] max-w-[80vw]">
<div class="text-[1rem] font-semibold mb-2">{{ popPlanTitle }}</div>
<div v-if=!popBullets?.length class=text-[1rem] text-[var(--text-color-secondary)]>
<div v-if="!popBullets?.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhum benefício configurado.
</div>
<ul v-else class=m-0 pl-4 space-y-2>
<li v-for=b in popBullets :key=b.id class=text-[1rem] leading-snug>
<span :class=b.highlight ? 'font-semibold' : ''>
<ul v-else class="m-0 pl-4 space-y-2">
<li v-for="b in popBullets" :key="b.id" class="text-[1rem] leading-snug">
<span :class="b.highlight ? 'font-semibold' : ''">
{{ b.text }}
</span>
<div v-if=b.highlight class=inline ml-2 text-[1rem] text-[var(--text-color-secondary)]>(destaque)</div>
<div v-if="b.highlight" class="inline ml-2 text-[1rem] text-[var(--text-color-secondary)]">(destaque)</div>
</li>
</ul>
</div>
</Popover>
<DataTable :value=tableRows dataKey=plan_id :loading=loading stripedRows responsiveLayout=scroll>
<Column header=Plano style=min-width: 18rem>
<template #body={ data }>
<div class=flex flex-col>
<span class=font-semibold>{{ data.public_name || data.plan_name || data.plan_key }}</span>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
<DataTable :value="tableRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
<Column header="Plano" style="min-width: 18rem">
<template #body="{ data }">
<div class="flex flex-col">
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
{{ data.plan_key }} {{ data.plan_name || '—' }}
</div>
</div>
</template>
</Column>
<Column header=Público style=width: 10rem>
<template #body={ data }>
<Tag :value=targetLabel(normalizeTarget(data)) :severity=targetSeverity(normalizeTarget(data)) rounded />
<Column header="Público" style="width: 10rem">
<template #body="{ data }">
<Tag :value="targetLabel(normalizeTarget(data))" :severity="targetSeverity(normalizeTarget(data))" rounded />
</template>
</Column>
<Column header=Mensal style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.monthly_cents) }}</span>
<Column header="Mensal" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
</template>
</Column>
<Column header=Anual style=width: 12rem>
<template #body={ data }>
<span class=font-medium>{{ formatBRLFromCents(data.yearly_cents) }}</span>
<Column header="Anual" style="width: 12rem">
<template #body="{ data }">
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
</template>
</Column>
<Column field=badge header=Badge style=min-width: 12rem />
<Column field="badge" header="Badge" style="min-width: 12rem" />
<Column header=Visível style=width: 8rem>
<template #body={ data }>
<Column header="Visível" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header=Destaque style=width: 9rem>
<template #body={ data }>
<Column header="Destaque" style="width: 9rem">
<template #body="{ data }">
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column field=sort_order header=Ordem style=width: 8rem />
<Column field="sort_order" header="Ordem" style="width: 8rem" />
<Column header=Ações style=width: 14rem>
<template #body={ data }>
<div class=flex gap-2 justify-end>
<Column header="Ações" style="width: 14rem">
<template #body="{ data }">
<div class="flex gap-2 justify-end">
<Button
severity=secondary
severity="secondary"
outlined
size=small
:disabled=loading || saving || bulletSaving
@click=(e) => openBulletsPopover(e, data)
size="small"
:disabled="loading || saving || bulletSaving"
@click="(e) => openBulletsPopover(e, data)"
>
<i class=pi pi-list mr-2 />
<span class=font-medium>{{ data.bullets?.length || 0 }}</span>
<i class="pi pi-list mr-2" />
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
</Button>
<Button
icon=pi pi-pencil
severity=secondary
icon="pi pi-pencil"
severity="secondary"
outlined
size=small
:disabled=loading || saving || bulletSaving
@click=openEdit(data)
size="small"
:disabled="loading || saving || bulletSaving"
@click="openEdit(data)"
/>
</div>
</template>
@@ -599,53 +599,53 @@ onBeforeUnmount(() => {
</DataTable>
<!-- PREVIEW PÚBLICO (conceitual) -->
<div class=rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden>
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
<!-- Hero -->
<div class=relative p-6 md:p-10>
<div class=absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)] />
<div class=relative>
<div class=flex flex-col md:flex-row md:items-end md:justify-between gap-6>
<div class=max-w-2xl>
<div class=flex items-center gap-2 mb-3 flex-wrap>
<div class="relative p-6 md:p-10">
<div class="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)]" />
<div class="relative">
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
<div class="max-w-2xl">
<div class="flex items-center gap-2 mb-3 flex-wrap">
<Tag
:value=targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`
:severity=targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
rounded
/>
<div class=text-[1rem] text-[var(--text-color-secondary)]>
<div class="text-[1rem] text-[var(--text-color-secondary)]">
Ajuste nomes, descrições, badges e benefícios e veja o resultado aqui.
</div>
</div>
<div class=text-3xl md:text-5xl font-semibold leading-tight>
<div class="text-3xl md:text-5xl font-semibold leading-tight">
Um plano não é preço.<br />
É promessa organizada.
</div>
<div class=text-[var(--text-color-secondary)] mt-3>
<div class="text-[var(--text-color-secondary)] mt-3">
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
Clareza, contraste e uma hierarquia que guia o olhar sem ruído.
</div>
</div>
<div class=flex flex-col items-start md:items-end gap-4>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Cobrança</div>
<div class="flex flex-col items-start md:items-end gap-4">
<div class="flex flex-col gap-2">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Cobrança</div>
<SelectButton
v-model=billingInterval
:options=intervalOptions
optionLabel=label
optionValue=value
v-model="billingInterval"
:options="intervalOptions"
optionLabel="label"
optionValue="value"
/>
</div>
<div class=flex flex-col gap-2>
<div class=text-[1rem] text-[var(--text-color-secondary)]>Planos sem preço</div>
<div class="flex flex-col gap-2">
<div class="text-[1rem] text-[var(--text-color-secondary)]">Planos sem preço</div>
<SelectButton
v-model=previewPricePolicy
:options=previewPolicyOptions
optionLabel=label
optionValue=value
v-model="previewPricePolicy"
:options="previewPolicyOptions"
optionLabel="label"
optionValue="value"
/>
</div>
</div>
@@ -654,101 +654,101 @@ onBeforeUnmount(() => {
</div>
<!-- Cards -->
<div class=p-6 md:p-10 pt-0>
<div v-if=!previewPlans.length class=text-[1rem] text-[var(--text-color-secondary)]>
<div class="p-6 md:p-10 pt-0">
<div v-if="!previewPlans.length" class="text-[1rem] text-[var(--text-color-secondary)]">
Nenhum plano visível para este filtro.
</div>
<div v-else class=mt-6 grid grid-cols-1 md:grid-cols-3 gap-6>
<div v-else class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div
v-for=p in previewPlans
:key=p.plan_id
:class=[
v-for="p in previewPlans"
:key="p.plan_id"
:class="[
'relative rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
'shadow-sm transition-transform',
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
]
]"
>
<div class=h-2 w-full opacity-50 bg-[var(--surface-100)] />
<div class="h-2 w-full opacity-50 bg-[var(--surface-100)]" />
<div class=p-6>
<div class=flex items-center justify-between gap-3>
<div class=flex items-center gap-2 flex-wrap>
<Tag :value=targetLabel(normalizeTarget(p)) :severity=targetSeverity(normalizeTarget(p)) rounded />
<div class="p-6">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-wrap">
<Tag :value="targetLabel(normalizeTarget(p))" :severity="targetSeverity(normalizeTarget(p))" rounded />
<Tag
v-if=p.badge || p.is_featured
:value=p.badge || 'Destaque'
:severity=p.is_featured ? 'success' : 'secondary'
v-if="p.badge || p.is_featured"
:value="p.badge || 'Destaque'"
:severity="p.is_featured ? 'success' : 'secondary'"
rounded
/>
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)]>{{ p.plan_key }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.plan_key }}</div>
</div>
<div class=mt-4>
<template v-if=priceDisplayForPreview(p).kind === 'paid'>
<div class=text-4xl font-semibold leading-none>
<div class="mt-4">
<template v-if="priceDisplayForPreview(p).kind === 'paid'">
<div class="text-4xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ priceDisplayForPreview(p).sub }}
</div>
</template>
<template v-else-if=priceDisplayForPreview(p).kind === 'free'>
<div class=text-4xl font-semibold leading-none>
<template v-else-if="priceDisplayForPreview(p).kind === 'free'">
<div class="text-4xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
</div>
</template>
<template v-else>
<div class=text-2xl font-semibold leading-none>
<div class="text-2xl font-semibold leading-none">
{{ priceDisplayForPreview(p).main }}
</div>
<div class=text-[1rem] text-[var(--text-color-secondary)] mt-1>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
Fale com a equipe para montar o plano ideal.
</div>
</template>
</div>
<div class=text-[var(--text-color-secondary)] mt-3 min-h-[44px]>
<div class="text-[var(--text-color-secondary)] mt-3 min-h-[44px]">
{{ p.public_description || '—' }}
</div>
<Button
class=mt-5 w-full
:label=p.is_featured ? 'Começar agora' : 'Selecionar plano'
:severity=p.is_featured ? 'success' : 'secondary'
:outlined=!p.is_featured
class="mt-5 w-full"
:label="p.is_featured ? 'Começar agora' : 'Selecionar plano'"
:severity="p.is_featured ? 'success' : 'secondary'"
:outlined="!p.is_featured"
/>
<div class=mt-6>
<div class=border-t border-dashed border-[var(--surface-border)] />
<div class="mt-6">
<div class="border-t border-dashed border-[var(--surface-border)]" />
</div>
<ul v-if=p.bullets?.length class=mt-4 space-y-2>
<li v-for=b in p.bullets :key=b.id class=flex items-start gap-2>
<i class=pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]></i>
<span :class=['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']>
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
<i class="pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]"></i>
<span :class="['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']">
{{ b.text }}
</span>
</li>
</ul>
<div v-else class=mt-4 text-[1rem] text-[var(--text-color-secondary)]>
<div v-else class="mt-4 text-[1rem] text-[var(--text-color-secondary)]">
Nenhum benefício configurado.
</div>
</div>
</div>
</div>
<div v-if=previewPricePolicy === 'hide' class=mt-6 text-[1rem] text-[var(--text-color-secondary)]>
<div v-if="previewPricePolicy === 'hide'" class="mt-6 text-[1rem] text-[var(--text-color-secondary)]">
Observação: planos sem preço não aparecem no preview (política atual).
Para exibir como Sob consulta, mude acima.
Para exibir como "Sob consulta", mude acima.
</div>
</div>
</div>
@@ -757,90 +757,90 @@ onBeforeUnmount(() => {
<!-- Dialog principal ( sem drag: removemos draggable) -->
<Dialog
v-model:visible=showDlg
v-model:visible="showDlg"
modal
header=Editar vitrine
:style={ width: '820px' }
:closable=!saving
:dismissableMask=!saving
:draggable=false
header="Editar vitrine"
:style="{ width: '820px' }"
:closable="!saving"
:dismissableMask="!saving"
:draggable="false"
>
<div class=grid grid-cols-1 md:grid-cols-2 gap-6>
<div class=flex flex-col gap-4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="flex flex-col gap-4">
<!-- Nome público (FloatLabel + Icon) -->
<FloatLabel variant=on>
<FloatLabel variant="on">
<IconField>
<InputIcon class=pi pi-tag />
<InputIcon class="pi pi-tag" />
<InputText
id=pp-public-name
v-model.trim=form.public_name
class=w-full
variant=filled
:disabled=saving
autocomplete=off
id="pp-public-name"
v-model.trim="form.public_name"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
autofocus
@keydown.enter.prevent=save
@keydown.enter.prevent="save"
/>
</IconField>
<label for=pp-public-name>Nome público *</label>
<label for="pp-public-name">Nome público *</label>
</FloatLabel>
<!-- Descrição pública -->
<FloatLabel variant=on>
<FloatLabel variant="on">
<IconField>
<InputIcon class=pi pi-align-left />
<InputIcon class="pi pi-align-left" />
<Textarea
id=pp-public-desc
v-model.trim=form.public_description
class=w-full
rows=3
id="pp-public-desc"
v-model.trim="form.public_description"
class="w-full"
rows="3"
autoResize
:disabled=saving
:disabled="saving"
/>
</IconField>
<label for=pp-public-desc>Descrição pública</label>
<label for="pp-public-desc">Descrição pública</label>
</FloatLabel>
<!-- Badge -->
<FloatLabel variant=on>
<FloatLabel variant="on">
<IconField>
<InputIcon class=pi pi-bookmark />
<InputIcon class="pi pi-bookmark" />
<InputText
id=pp-badge
v-model.trim=form.badge
class=w-full
variant=filled
:disabled=saving
autocomplete=off
@keydown.enter.prevent=save
id="pp-badge"
v-model.trim="form.badge"
class="w-full"
variant="filled"
:disabled="saving"
autocomplete="off"
@keydown.enter.prevent="save"
/>
</IconField>
<label for=pp-badge>Badge (opcional)</label>
<label for="pp-badge">Badge (opcional)</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Ordem -->
<FloatLabel variant=on>
<FloatLabel variant="on">
<IconField>
<InputIcon class=pi pi-sort-amount-up-alt />
<InputIcon class="pi pi-sort-amount-up-alt" />
<InputNumber
id=pp-sort
v-model=form.sort_order
class=w-full
inputClass=w-full
:disabled=saving
id="pp-sort"
v-model="form.sort_order"
class="w-full"
inputClass="w-full"
:disabled="saving"
/>
</IconField>
<label for=pp-sort>Ordem</label>
<label for="pp-sort">Ordem</label>
</FloatLabel>
<div class=flex flex-col gap-3 pt-2>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_visible :binary=true :disabled=saving />
<div class="flex flex-col gap-3 pt-2">
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_visible" :binary="true" :disabled="saving" />
<label>Visível no público</label>
</div>
<div class=flex items-center gap-2>
<Checkbox v-model=form.is_featured :binary=true :disabled=saving />
<div class="flex items-center gap-2">
<Checkbox v-model="form.is_featured" :binary="true" :disabled="saving" />
<label>Destaque</label>
</div>
</div>
@@ -849,24 +849,24 @@ onBeforeUnmount(() => {
<!-- bullets -->
<div>
<div class=flex items-center justify-between mb-3>
<div class=font-semibold>Benefícios (bullets)</div>
<Button label=Adicionar icon=pi pi-plus size=small :disabled=saving || bulletSaving @click=openBulletCreate />
<div class="flex items-center justify-between mb-3">
<div class="font-semibold">Benefícios (bullets)</div>
<Button label="Adicionar" icon="pi pi-plus" size="small" :disabled="saving || bulletSaving" @click="openBulletCreate" />
</div>
<DataTable :value=bullets dataKey=id stripedRows responsiveLayout=scroll>
<Column field=text header=Texto />
<Column field=sort_order header=Ordem style=width: 7rem />
<Column header=Destaque style=width: 8rem>
<template #body={ data }>
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
<Column field="text" header="Texto" />
<Column field="sort_order" header="Ordem" style="width: 7rem" />
<Column header="Destaque" style="width: 8rem">
<template #body="{ data }">
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
</template>
</Column>
<Column header=Ações style=width: 9rem>
<template #body={ data }>
<div class=flex gap-2>
<Button icon=pi pi-pencil severity=secondary outlined size=small :disabled=saving || bulletSaving @click=openBulletEdit(data) />
<Button icon=pi pi-trash severity=danger outlined size=small :disabled=saving || bulletSaving @click=askDeleteBullet(data) />
<Column header="Ações" style="width: 9rem">
<template #body="{ data }">
<div class="flex gap-2">
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" :disabled="saving || bulletSaving" @click="openBulletEdit(data)" />
<Button icon="pi pi-trash" severity="danger" outlined size="small" :disabled="saving || bulletSaving" @click="askDeleteBullet(data)" />
</div>
</template>
</Column>
@@ -875,62 +875,62 @@ onBeforeUnmount(() => {
</div>
<template #footer>
<Button label=Cancelar severity=secondary outlined :disabled=saving @click=showDlg = false />
<Button label=Salvar icon=pi pi-check :loading=saving @click=save />
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
</template>
</Dialog>
<!-- Dialog bullet ( sem drag + inputs padronizados) -->
<Dialog
v-model:visible=showBulletDlg
v-model:visible="showBulletDlg"
modal
:header=bulletIsEdit ? 'Editar benefício' : 'Novo benefício'
:style={ width: '560px' }
:closable=!bulletSaving
:dismissableMask=!bulletSaving
:draggable=false
:header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'"
:style="{ width: '560px' }"
:closable="!bulletSaving"
:dismissableMask="!bulletSaving"
:draggable="false"
>
<div class=flex flex-col gap-4>
<FloatLabel variant=on>
<div class="flex flex-col gap-4">
<FloatLabel variant="on">
<IconField>
<InputIcon class=pi pi-list />
<InputIcon class="pi pi-list" />
<Textarea
id=pp-bullet-text
v-model.trim=bulletForm.text
class=w-full
rows=3
id="pp-bullet-text"
v-model.trim="bulletForm.text"
class="w-full"
rows="3"
autoResize
:disabled=bulletSaving
:disabled="bulletSaving"
/>
</IconField>
<label for=pp-bullet-text>Texto *</label>
<label for="pp-bullet-text">Texto *</label>
</FloatLabel>
<div class=grid grid-cols-1 md:grid-cols-2 gap-4>
<FloatLabel variant=on>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<FloatLabel variant="on">
<IconField>
<InputIcon class=pi pi-sort-numeric-up />
<InputIcon class="pi pi-sort-numeric-up" />
<InputNumber
id=pp-bullet-order
v-model=bulletForm.sort_order
class=w-full
inputClass=w-full
:disabled=bulletSaving
id="pp-bullet-order"
v-model="bulletForm.sort_order"
class="w-full"
inputClass="w-full"
:disabled="bulletSaving"
/>
</IconField>
<label for=pp-bullet-order>Ordem</label>
<label for="pp-bullet-order">Ordem</label>
</FloatLabel>
<div class=flex items-center gap-2 pt-7>
<Checkbox v-model=bulletForm.highlight :binary=true :disabled=bulletSaving />
<div class="flex items-center gap-2 pt-7">
<Checkbox v-model="bulletForm.highlight" :binary="true" :disabled="bulletSaving" />
<label>Destaque</label>
</div>
</div>
</div>
<template #footer>
<Button label=Cancelar severity=secondary outlined :disabled=bulletSaving @click=showBulletDlg = false />
<Button :label=bulletIsEdit ? 'Salvar' : 'Criar' icon=pi pi-check :loading=bulletSaving @click=saveBullet />
<Button label="Cancelar" severity="secondary" outlined :disabled="bulletSaving" @click="showBulletDlg = false" />
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
</template>
</Dialog>
</template>

View File

@@ -35,7 +35,7 @@
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther,
}"
@click="cell.day && (selectedDay = cell.day)"
@click="cell.day && onCalDayClick($event, cell.day)"
>
<span>{{ cell.day }}</span>
<span
@@ -60,15 +60,17 @@
<div
v-for="ev in eventosDoDia"
:key="ev.id"
class="flex items-center gap-1.5 px-1.5 py-1.5 rounded-md bg-[var(--surface-ground,#f8fafc)] border-l-[3px]"
class="flex items-center gap-1.5 px-1.5 py-1.5 rounded-md bg-[var(--surface-ground,#f8fafc)] border-l-[3px] cursor-pointer hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100"
:style="ev.bgColor ? { borderLeftColor: ev.bgColor } : {}"
:class="{
'border-l-sky-400': ev.tipo === 'reuniao',
'border-l-green-400': ev.status === 'realizado',
'border-l-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && ev.status !== 'realizado',
'border-l-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
'border-l-green-400': !ev.bgColor && ev.status === 'realizado',
'border-l-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado',
}"
@click="openEvMenu($event, ev)"
>
<div class="flex flex-col items-end min-w-[38px]">
<span class="text-[1rem] font-bold text-[var(--text-color)]">{{ ev.hora }}</span>
<span class="text-[0.7rem] font-bold text-[var(--text-color)]">{{ ev.hora }}</span>
<span class="text-xs text-[var(--text-color-secondary)]">{{ ev.dur }}</span>
</div>
<div class="flex-1 min-w-0">
@@ -93,7 +95,7 @@
<span class="ml-auto bg-[var(--primary-color,#6366f1)] text-white rounded-full px-1.5 text-xs font-bold">{{ recorrencias.length }}</span>
</div>
<div class="flex flex-col gap-1.5 max-h-[170px] overflow-y-auto">
<div v-for="r in recorrencias" :key="r.id" class="flex items-center gap-2 py-0.5">
<div v-for="r in recorrencias" :key="r.id" class="flex items-center gap-2 py-0.5 cursor-pointer hover:bg-[var(--surface-hover,#f1f5f9)] rounded-md px-1 transition-colors duration-100" @click="openRecMenu($event, r)">
<div class="w-[26px] h-[26px] rounded-full flex items-center justify-center text-[0.58rem] font-bold text-white flex-shrink-0" :style="{ background: r.color }">{{ r.initials }}</div>
<div class="flex-1 min-w-0">
<span class="block text-xs font-semibold">{{ r.nome }}</span>
@@ -130,7 +132,7 @@
</div>
<div class="min-w-0">
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">{{ saudacao }} <span class="text-[var(--primary-color,#6366f1)]">👋</span></div>
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">{{ resumoHoje }}</div>
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">{{ resumoHoje }}</div>
</div>
</div>
<!-- Controles (desktop e mobile mesmo conteúdo, sempre visível) -->
@@ -163,7 +165,7 @@
'text-[var(--text-color)]': !s.cls,
}"
>{{ s.value }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
</div>
</div>
</div>
@@ -204,11 +206,11 @@
v-for="ev in timelineEvents"
:key="ev.id"
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
:style="ev.style"
:style="{ ...ev.style, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
:class="{
'bg-sky-400': ev.tipo === 'reuniao',
'bg-green-500': ev.status === 'realizado',
'bg-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && ev.status !== 'realizado',
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado',
}"
:title="ev.tooltip"
>
@@ -235,7 +237,7 @@
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-red-500/10 text-red-500"><i class="pi pi-inbox" /></div>
<div class="flex-1">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Agendador Online</span>
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Agendador Online</span>
<span class="block text-xs text-[var(--text-color-secondary)]">Solicitações do portal externo</span>
</div>
<span v-if="solicitacoesPendentes > 0" class="rounded-full px-1.5 py-px text-xs font-bold bg-red-50 text-red-500 border border-red-300">{{ solicitacoesPendentes }}</span>
@@ -266,7 +268,7 @@
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-sky-500/10 text-sky-500"><i class="pi pi-user-plus" /></div>
<div class="flex-1">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Cadastros Externos</span>
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Cadastros Externos</span>
<span class="block text-xs text-[var(--text-color-secondary)]">Pacientes aguardando triagem</span>
</div>
<span v-if="cadastrosPendentes > 0" class="rounded-full px-1.5 py-px text-xs font-bold bg-blue-50 text-blue-500 border border-blue-200">{{ cadastrosPendentes }}</span>
@@ -275,7 +277,7 @@
<div v-for="c in cadastros" :key="c.id" class="flex items-center gap-2">
<div class="w-[26px] h-[26px] rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 text-white text-[0.58rem] font-bold flex items-center justify-center flex-shrink-0">{{ c.initials }}</div>
<div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold">{{ c.nome }}</span>
<span class="block text-[0.7rem] font-semibold">{{ c.nome }}</span>
<span class="block text-xs text-[var(--text-color-secondary)]">{{ c.detalhe }}</span>
</div>
<button
@@ -299,7 +301,7 @@
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-amber-500/10 text-amber-500"><i class="pi pi-refresh" /></div>
<div class="flex-1">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Recorrências</span>
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Recorrências</span>
<span class="block text-xs text-[var(--text-color-secondary)]">Atenção necessária</span>
</div>
<span v-if="recAlerta.length" class="rounded-full px-1.5 py-px text-xs font-bold bg-amber-50 text-amber-500 border border-amber-200">{{ recAlerta.length }}</span>
@@ -307,7 +309,7 @@
<div class="flex-1 flex flex-col gap-1.5 px-3.5 py-1.5 min-h-[72px]">
<div v-for="r in recAlerta" :key="r.id" class="flex items-center gap-2.5 py-1">
<div class="flex-1">
<span class="block text-[1rem] font-semibold">{{ r.nome }}</span>
<span class="block text-[0.7rem] font-semibold">{{ r.nome }}</span>
<span
class="block text-xs font-semibold mt-0.5"
:class="{
@@ -342,7 +344,7 @@
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-chart-pie" /></div>
<div class="flex-1">
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Radar da Semana</span>
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Radar da Semana</span>
<span class="block text-xs text-[var(--text-color-secondary)]">Presença, faltas e reposições</span>
</div>
</div>
@@ -418,13 +420,49 @@
</section>
</main>
<!-- Menus de contexto (fora do aside para evitar visibility:hidden) -->
<Menu ref="calDayMenuRef" :model="calDayMenuItems" :popup="true" />
<Menu ref="evMenuRef" :model="evMenuItems" :popup="true" />
<Menu ref="recMenuRef" :model="recMenuItems" :popup="true" />
<!-- Dialog: Novo Compromisso (aberto pelo menu de contexto do mini calendário) -->
<AgendaEventDialog
v-if="agendaDialogOpen"
v-model="agendaDialogOpen"
:eventRow="agendaDialogEventRow"
:initialStartISO="agendaDialogStartISO"
:initialEndISO="agendaDialogEndISO"
:ownerId="ownerId"
:tenantId="clinicTenantId"
:commitmentOptions="commitmentOptionsNormalized"
newPatientRoute="/therapist/patients/cadastro"
@save="onAgendaDialogSave"
@delete="() => { agendaDialogOpen = false; load() }"
/>
<!-- Dialog: Prontuário do paciente -->
<PatientProntuario
:key="selectedPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="selectedPatient"
@close="closeProntuario"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Menu from 'primevue/menu'
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
const dashHeroSentinelRef = ref(null)
const heroStuck = ref(false)
@@ -449,6 +487,241 @@ const saudacao = computed(() => {
})
const tenantStore = useTenantStore()
const router = useRouter()
const toast = useToast()
const { create: createEvento, update: updateEvento } = useAgendaEvents()
// ── Prontuário ────────────────────────────────────────────────
const prontuarioOpen = ref(false)
const selectedPatient = ref(null)
function openProntuario (patientId, patientNome) {
if (!patientId) return
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
prontuarioOpen.value = true
}
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
// ── Tipos de compromisso (para o dialog) ─────────────────────
const clinicTenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || null)
const { rows: determinedCommitments, load: loadCommitments } = useDeterminedCommitments(clinicTenantId)
const COMMITMENT_PRIORITY = new Map([
['session', 0], ['class', 1], ['study', 2],
['reading', 3], ['supervision', 4], ['content_creation', 5],
])
const commitmentOptionsNormalized = computed(() => {
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
return [...list]
.filter(i => i?.id && i?.active !== false)
.sort((a, b) => {
const pa = COMMITMENT_PRIORITY.get(a.native_key) ?? 99
const pb = COMMITMENT_PRIORITY.get(b.native_key) ?? 99
if (pa !== pb) return pa - pb
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR')
})
.map(i => ({
id: i.id,
tenant_id: i.tenant_id ?? null,
created_by: i.created_by ?? null,
name: String(i.name || '').trim() || 'Sem nome',
description: i.description || '',
native_key: i.native_key || null,
is_native: !!i.is_native,
is_locked: !!i.is_locked,
active: i.active !== false,
bg_color: i.bg_color || null,
text_color: i.text_color || null,
fields: Array.isArray(i.determined_commitment_fields)
? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
: [],
}))
})
// ── Mini calendário: menu de contexto ────────────────────────
const calDayMenuRef = ref(null)
const calDayMenuItems = computed(() => [
{
label: 'Opções do dia',
items: [
{
label: 'Novo Compromisso',
icon: 'pi pi-plus-circle',
command: () => openNovoCompromisso(),
},
{
label: 'Ver dia na agenda',
icon: 'pi pi-calendar',
command: () => verDiaNaAgenda(),
},
],
},
])
function onCalDayClick (event, day) {
selectedDay.value = day
calDayMenuRef.value?.toggle(event)
}
function verDiaNaAgenda () {
const d = String(selectedDay.value).padStart(2, '0')
const m = String(mesAtual + 1).padStart(2, '0')
router.push(`/therapist/agenda?date=${anoAtual}-${m}-${d}`)
}
// ── Menu de contexto: Eventos do dia ─────────────────────────
const evMenuRef = ref(null)
const _evAtivo = ref(null) // evento clicado
const evMenuItems = computed(() => [
{
label: 'Opções',
items: [
{
label: 'Ver prontuário',
icon: 'pi pi-file-edit',
disabled: !_evAtivo.value?.patientId,
command: () => openProntuario(_evAtivo.value?.patientId, _evAtivo.value?.nome),
},
{
label: 'Ver na agenda',
icon: 'pi pi-calendar',
command: () => {
if (!_evAtivo.value?.inicioISO) return
const d = new Date(_evAtivo.value.inicioISO)
const ds = d.toISOString().slice(0, 10)
router.push(`/therapist/agenda?date=${ds}`)
},
},
],
},
])
function openEvMenu (event, ev) {
_evAtivo.value = ev
evMenuRef.value?.toggle(event)
}
// ── Menu de contexto: Recorrências ativas ────────────────────
const recMenuRef = ref(null)
const _recAtivo = ref(null) // recorrência clicada
const recMenuItems = computed(() => [
{
label: 'Opções',
items: [
{
label: 'Ver prontuário',
icon: 'pi pi-file-edit',
disabled: !_recAtivo.value?.patientId,
command: () => openProntuario(_recAtivo.value?.patientId, _recAtivo.value?.nome),
},
{
label: 'Ver na agenda',
icon: 'pi pi-calendar',
command: () => router.push('/therapist/agenda'),
},
],
},
])
function openRecMenu (event, r) {
_recAtivo.value = r
recMenuRef.value?.toggle(event)
}
// ── Dialog: Novo Compromisso ──────────────────────────────────
const agendaDialogOpen = ref(false)
const agendaDialogEventRow = ref(null)
const agendaDialogStartISO = ref('')
const agendaDialogEndISO = ref('')
function openNovoCompromisso () {
if (!ownerId.value) return
const durMin = 50
const now = new Date()
const base = new Date(anoAtual, mesAtual, selectedDay.value, now.getHours(), now.getMinutes(), 0, 0)
agendaDialogEventRow.value = {
owner_id: ownerId.value,
tipo: 'sessao',
status: 'agendado',
titulo: null,
observacoes: null,
}
agendaDialogStartISO.value = base.toISOString()
agendaDialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString()
agendaDialogOpen.value = true
}
function _isUuid (v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
}
function _pickDbFields (obj) {
const allowed = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes',
'inicio_em', 'fim_em', 'visibility_scope',
'determined_commitment_id', 'titulo_custom', 'extra_fields',
'recurrence_id', 'recurrence_date',
'price', 'insurance_plan_id', 'insurance_guide_number',
'insurance_value', 'insurance_plan_service_id',
]
const out = {}
for (const k of allowed) { if (obj[k] !== undefined) out[k] = obj[k] }
return out
}
async function onAgendaDialogSave (arg) {
try {
const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload')
const payload = isWrapped ? arg.payload : arg
const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null)
const normalized = { ...(payload || {}) }
if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value
const tid = clinicTenantId.value
if (!tid) throw new Error('tenant_id não encontrado.')
normalized.tenant_id = tid
if (!normalized.visibility_scope) normalized.visibility_scope = 'public'
if (!normalized.status) normalized.status = 'agendado'
if (!normalized.tipo) normalized.tipo = 'sessao'
if (!String(normalized.titulo || '').trim()) normalized.titulo = normalized.tipo === 'bloqueio' ? 'Ocupado' : 'Sessão'
if (!_isUuid(normalized.paciente_id)) normalized.paciente_id = null
if (normalized.determined_commitment_id && !_isUuid(normalized.determined_commitment_id)) normalized.determined_commitment_id = null
const dbPayload = _pickDbFields(normalized)
if (id) {
await updateEvento(id, dbPayload)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 })
} else {
await createEvento(dbPayload)
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
}
agendaDialogOpen.value = false
await load()
} catch (e) {
const msg = String(e?.message || '')
const isOverlap =
e?.code === '23P01' ||
msg.includes('agenda_eventos_sem_sobreposicao') ||
msg.includes('exclusion constraint')
if (isOverlap) {
toast.add({ severity: 'warn', summary: 'Conflito de horário', detail: 'Já existe um compromisso neste horário.', life: 4000 })
} else {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: msg || 'Tente novamente.', life: 4000 })
}
}
}
const ownerId = ref(null)
const eventosDoMes = ref([])
@@ -506,18 +779,33 @@ const STATUS_ICON = {
agendado: 'pi pi-clock',
}
const commitmentColorMap = computed(() =>
new Map(
commitmentOptionsNormalized.value
.filter(c => c.id && c.bg_color)
.map(c => [c.id, { bg_color: c.bg_color, text_color: c.text_color }])
)
)
function buildEventoItem (ev) {
const inicio = new Date(ev.inicio_em)
const fim = ev.fim_em ? new Date(ev.fim_em) : null
const durMin = fim ? Math.round((fim - inicio) / 60000) : 50
const h = inicio.getHours().toString().padStart(2, '0')
const m = inicio.getMinutes().toString().padStart(2, '0')
const joinColor = ev.determined_commitments
const mapColor = ev.determined_commitment_id ? commitmentColorMap.value.get(ev.determined_commitment_id) : null
const bgColor = joinColor?.bg_color ? `#${joinColor.bg_color}` : mapColor?.bg_color ? `#${mapColor.bg_color}` : null
const txtColor = joinColor?.text_color ? `#${joinColor.text_color}` : mapColor?.text_color ? `#${mapColor.text_color}` : null
return {
id: ev.id, hora: `${h}:${m}`, dur: `${durMin}min`,
nome: ev.patients?.nome_completo || ev.titulo || ev.titulo_custom || '—',
modalidade: ev.modalidade || 'Presencial', recorrente: !!ev.recurrence_id,
status: ev.status || 'agendado', statusIcon: STATUS_ICON[ev.status] || 'pi pi-clock',
tipo: ev.tipo || 'sessao',
patientId: ev.patient_id || null,
inicioISO: ev.inicio_em || null,
bgColor, txtColor,
}
}
@@ -576,40 +864,62 @@ const recorrencias = computed(() =>
const diaLabel = weekdays.map(d => DIAS_PT[d]).join(', ')
const hora = r.start_time ? String(r.start_time).slice(0, 5) : ''
const proxLabel = nextOccurrenceLabel(r)
return { id: r.id, nome: nomeAb, freq: `${freq} · ${diaLabel}${hora ? ' ' + hora : ''}`, proxLabel, proxHoje: proxLabel === 'Hoje', color: hashColor(r.patient_id || r.id), initials: initials(nome) }
return { id: r.id, nome: nomeAb, freq: `${freq} · ${diaLabel}${hora ? ' ' + hora : ''}`, proxLabel, proxHoje: proxLabel === 'Hoje', color: hashColor(r.patient_id || r.id), initials: initials(nome), patientId: r.patient_id || null }
})
)
const eventosHoje = computed(() =>
eventosDoMes.value.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d.getDate() === hoje && d.getMonth() === mesAtual && d.getFullYear() === anoAtual })
)
// ── Derivados de eventosDoMes — single pass ───────────────────
// Um único computed varre o array uma vez e extrai tudo,
// evitando N loops separados que re-executam a cada reatividade.
const _statsDoMes = computed(() => {
const now = agora.value
const semIni = new Date(now); semIni.setDate(now.getDate() - now.getDay()); semIni.setHours(0, 0, 0, 0)
const semFim = new Date(semIni); semFim.setDate(semIni.getDate() + 6); semFim.setHours(23, 59, 59, 999)
const daqui30 = new Date(now); daqui30.setDate(now.getDate() + 30)
const eventosSemana = computed(() => {
const now = agora.value, ini = new Date(now)
ini.setDate(now.getDate() - now.getDay()); ini.setHours(0, 0, 0, 0)
const fim = new Date(ini); fim.setDate(ini.getDate() + 6); fim.setHours(23, 59, 59, 999)
return eventosDoMes.value.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d >= ini && d <= fim })
let hojeCnt = 0, semanaCnt = 0, realizadosCnt = 0, encerradosCnt = 0
const hojeLista = [], timelineLista = []
const diasSemanaMap = [[], [], [], [], [], [], []]
for (const ev of eventosDoMes.value) {
if (!ev.inicio_em) continue
const d = new Date(ev.inicio_em)
const dDay = d.getDate(), dMon = d.getMonth(), dYear = d.getFullYear()
const isHoje = dDay === hoje && dMon === mesAtual && dYear === anoAtual
if (isHoje) { hojeCnt++; hojeLista.push(ev); timelineLista.push(ev) }
if (d >= semIni && d <= semFim) {
semanaCnt++
diasSemanaMap[d.getDay()].push(ev)
}
if (d < now && ['realizado','faltou','cancelado'].includes(ev.status)) {
encerradosCnt++
if (ev.status === 'realizado') realizadosCnt++
}
}
const taxaPresenca = encerradosCnt > 0 ? Math.round((realizadosCnt / encerradosCnt) * 100) : null
return { hojeCnt, semanaCnt, taxaPresenca, hojeLista, timelineLista, diasSemanaMap }
})
const taxaPresenca = computed(() => {
const encerrados = eventosDoMes.value.filter(ev => ev.inicio_em && new Date(ev.inicio_em) < new Date() && ['realizado','faltou','cancelado'].includes(ev.status))
if (!encerrados.length) return null
return Math.round((encerrados.filter(ev => ev.status === 'realizado').length / encerrados.length) * 100)
})
const eventosHoje = computed(() => _statsDoMes.value.hojeLista)
const eventosSemana = computed(() => ({ length: _statsDoMes.value.semanaCnt }))
const taxaPresenca = computed(() => _statsDoMes.value.taxaPresenca)
const quickStats = computed(() => {
const pendentes = _solicitacoesBruto.value.length + _cadastrosBruto.value.length
const pct = taxaPresenca.value
return [
{ value: String(eventosHoje.value.length), label: 'Hoje', cls: '' },
{ value: String(pendentes), label: 'Pendentes', cls: pendentes > 0 ? 'qs-urgente' : '' },
{ value: String(eventosSemana.value.length), label: 'Semana', cls: '' },
{ value: pct !== null ? `${pct}%` : '—', label: 'Presença', cls: pct !== null && pct >= 85 ? 'qs-ok' : '' },
{ value: String(_statsDoMes.value.hojeCnt), label: 'Hoje', cls: '' },
{ value: String(pendentes), label: 'Pendentes', cls: pendentes > 0 ? 'qs-urgente' : '' },
{ value: String(_statsDoMes.value.semanaCnt), label: 'Semana', cls: '' },
{ value: pct !== null ? `${pct}%` : '—', label: 'Presença', cls: pct !== null && pct >= 85 ? 'qs-ok' : '' },
]
})
const resumoHoje = computed(() => {
const sessoes = eventosHoje.value.filter(ev => ev.tipo !== 'bloqueio').length
const sessoes = _statsDoMes.value.hojeLista.filter(ev => ev.tipo !== 'bloqueio').length
const sols = _solicitacoesBruto.value.length
const parts = []
if (sessoes === 1) parts.push('1 sessão hoje')
@@ -648,11 +958,11 @@ const cadastros = computed(() =>
const cadastrosPendentes = computed(() => cadastros.value.length)
const recAlerta = computed(() => {
const daqui30 = new Date(); daqui30.setDate(daqui30.getDate() + 30)
const now = new Date(), daqui30 = new Date(); daqui30.setDate(now.getDate() + 30)
const alerts = []
for (const r of regraRecorrencias.value) {
const nome = (r._patientNome || '—').split(' ').slice(0, 2).join(' ')
if (r.end_date) { const ed = new Date(r.end_date + 'T00:00:00'); if (ed >= new Date() && ed <= daqui30) alerts.push({ id: r.id + '_end', nome, motivo: 'Encerramento próximo', tipo: 'feriado' }) }
if (r.end_date) { const ed = new Date(r.end_date + 'T00:00:00'); if (ed >= now && ed <= daqui30) alerts.push({ id: r.id + '_end', nome, motivo: 'Encerramento próximo', tipo: 'feriado' }) }
if (r.max_occurrences && r._sessionsCount !== undefined) {
const pct = (r._sessionsCount / r.max_occurrences) * 100
if (pct > 75) alerts.push({ id: r.id + '_limit', nome, motivo: 'Limite próximo', tipo: 'limite', sessoesUsadas: r._sessionsCount, totalSessoes: r.max_occurrences, progresso: Math.round(pct) })
@@ -662,12 +972,14 @@ const recAlerta = computed(() => {
})
const radarSemana = computed(() => {
const now = agora.value, dow = now.getDay(), iniSem = new Date(now)
iniSem.setDate(now.getDate() - dow); iniSem.setHours(0, 0, 0, 0)
const diasMap = _statsDoMes.value.diasSemanaMap
const dow = agora.value.getDay()
return DIAS_PT.map((dia, i) => {
const dayDate = new Date(iniSem); dayDate.setDate(iniSem.getDate() + i)
const evs = eventosDoMes.value.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d.getDate() === dayDate.getDate() && d.getMonth() === dayDate.getMonth() && d.getFullYear() === dayDate.getFullYear() })
const total = evs.length, presentes = evs.filter(ev => ev.status === 'realizado').length, faltas = evs.filter(ev => ev.status === 'faltou').length, reposicao = evs.filter(ev => ['reposicao','reposição'].includes(ev.status)).length
const evs = diasMap[i]
const total = evs.length
const presentes = evs.filter(ev => ev.status === 'realizado').length
const faltas = evs.filter(ev => ev.status === 'faltou').length
const reposicao = evs.filter(ev => ['reposicao','reposição'].includes(ev.status)).length
let status = 'ok'
if (faltas > 0 && faltas >= presentes) status = 'falta'
else if (reposicao > 0 && reposicao > presentes) status = 'repo'
@@ -691,15 +1003,16 @@ const hoursRange = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
const TL_START = 7, TL_END = 20, TL_SPAN = TL_END - TL_START
function toPercent (h, m) { return ((h + m / 60 - TL_START) / TL_SPAN) * 100 }
const timelineEvents = computed(() =>
eventosDoMes.value
.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d.getDate() === hoje && d.getMonth() === mesAtual && d.getFullYear() === anoAtual })
_statsDoMes.value.timelineLista
.slice()
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
.map(ev => {
const item = buildEventoItem(ev)
const [hh, mm] = item.hora.split(':').map(Number)
const durMin = parseInt(item.dur) || 50
return { id: item.id, label: item.nome.split(' ')[0], tipo: item.tipo, status: item.status, tooltip: `${item.hora} · ${item.nome} · ${item.modalidade}`, badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '', style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' } }
return { id: item.id, label: item.nome.split(' ')[0], tipo: item.tipo, status: item.status, tooltip: `${item.hora} · ${item.nome} · ${item.modalidade}`, badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '', bgColor: item.bgColor, txtColor: item.txtColor, style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' } }
})
)
@@ -714,11 +1027,12 @@ async function load () {
if (!ownerId.value) return
await tenantStore.ensureLoaded()
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null
await loadCommitments()
const mesInicio = new Date(anoAtual, mesAtual, 1, 0, 0, 0, 0).toISOString()
const mesFim = new Date(anoAtual, mesAtual + 1, 0, 23, 59, 59, 999).toISOString()
try {
const [eventosRes, recRes, solRes, cadRes] = await Promise.all([
(() => { let q = supabase.from('agenda_eventos').select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, recurrence_id, patients(nome_completo)').eq('owner_id', ownerId.value).gte('inicio_em', mesInicio).lte('inicio_em', mesFim).order('inicio_em', { ascending: true }); if (tid) q = q.eq('tenant_id', tid); return q })(),
(() => { let q = supabase.from('agenda_eventos').select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, recurrence_id, determined_commitment_id, patients(nome_completo), determined_commitments(bg_color, text_color)').eq('owner_id', ownerId.value).gte('inicio_em', mesInicio).lte('inicio_em', mesFim).order('inicio_em', { ascending: true }); if (tid) q = q.eq('tenant_id', tid); return q })(),
(() => { let q = supabase.from('recurrence_rules').select('id, patient_id, type, interval, weekdays, start_date, end_date, max_occurrences, start_time').eq('owner_id', ownerId.value).eq('status', 'ativo').order('start_date', { ascending: false }); if (tid) q = q.eq('tenant_id', tid); return q })(),
supabase.from('agendador_solicitacoes').select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada').eq('owner_id', ownerId.value).eq('status', 'pendente').order('created_at', { ascending: false }).limit(10),
supabase.from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10),
@@ -780,15 +1094,4 @@ onMounted(async () => {
0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); }
50% { box-shadow: 0 0 0 4px rgba(239,68,68,0); }
}
/* Highlight pulse (acionado externamente via classe JS) */
@keyframes highlight-pulse {
0% { box-shadow: 0 0 0 0 rgba(99,102,241,0.7), 0 0 0 0 rgba(99,102,241,0.4); }
40% { box-shadow: 0 0 0 8px rgba(99,102,241,0.3), 0 0 0 16px rgba(99,102,241,0.1); }
100% { box-shadow: 0 0 0 0 rgba(99,102,241,0), 0 0 0 0 rgba(99,102,241,0); }
}
.notif-card--highlight {
animation: highlight-pulse 1s ease-out 3;
border-color: rgba(99,102,241,0.6) !important;
}
</style>