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(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 /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
"Bash(find:*)", "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 -- PostgreSQL database dump
-- --
\restrict WhNBUHGPb7r3TzvGfUbgAGypOAZRhELU6FHGPvMhYkVWhF2Y5HPG9HrKQluVdLN \restrict exm15ajuo5LlVoZOAon82WdOxbqbyivLILLlrvWu0yn6dCEmYCyZgXRS28Q2h1h
-- Dumped from database version 17.6 -- Dumped from database version 17.6
-- Dumped by pg_dump version 17.6 -- Dumped by pg_dump version 17.6
@@ -2886,6 +2886,105 @@ $$;
ALTER FUNCTION public.my_tenants() OWNER TO supabase_admin; 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 -- 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; 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 -- 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; 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 -- 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); 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 -- 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); 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 -- 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); 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 -- 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(); 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 -- 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; 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 -- 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())); 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 -- 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()); 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 -- 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())); 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 -- 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())))); 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 -- 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; 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 -- 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; 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 -- 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; 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 -- 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; 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 -- 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 -- 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/core": "^6.1.20",
"@fullcalendar/daygrid": "^6.1.20", "@fullcalendar/daygrid": "^6.1.20",
"@fullcalendar/interaction": "^6.1.20", "@fullcalendar/interaction": "^6.1.20",
"@fullcalendar/list": "^6.1.20",
"@fullcalendar/resource": "^6.1.20", "@fullcalendar/resource": "^6.1.20",
"@fullcalendar/resource-timegrid": "^6.1.20", "@fullcalendar/resource-timegrid": "^6.1.20",
"@fullcalendar/timegrid": "^6.1.20", "@fullcalendar/timegrid": "^6.1.20",
@@ -24,6 +25,7 @@
"primevue": "^4.5.4", "primevue": "^4.5.4",
"quill": "^2.0.3", "quill": "^2.0.3",
"tailwindcss-primeui": "^0.6.0", "tailwindcss-primeui": "^0.6.0",
"v-offline": "^3.5.1",
"vue": "^3.4.34", "vue": "^3.4.34",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
@@ -596,6 +598,14 @@
"@fullcalendar/core": "~6.1.20" "@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": { "node_modules/@fullcalendar/premium-common": {
"version": "6.1.20", "version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
@@ -3945,6 +3955,11 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/pinia": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
@@ -4746,6 +4761,18 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "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": { "node_modules/vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -5826,6 +5853,12 @@
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==", "integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
"requires": {} "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": { "@fullcalendar/premium-common": {
"version": "6.1.20", "version": "6.1.20",
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz", "resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
@@ -7991,6 +8024,11 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true "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": { "pinia": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
@@ -8521,6 +8559,14 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "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": { "vite": {
"version": "5.4.21", "version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",

View File

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

View File

@@ -4,9 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore' import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import { fetchDocsForPath } from '@/composables/useAjuda' import { fetchDocsForPath } from '@/composables/useAjuda'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import AppOfflineOverlay from '@/components/AppOfflineOverlay.vue'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const tenantStore = useTenantStore() const tenantStore = useTenantStore()
@@ -177,4 +179,7 @@ watch(
<Teleport to="body"> <Teleport to="body">
<AjudaDrawer /> <AjudaDrawer />
</Teleport> </Teleport>
<!-- Overlay de sem conexão -->
<AppOfflineOverlay />
</template> </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 'primeicons/primeicons.css';
@use '@/assets/layout/layout.scss'; @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>
<div class="text-xs text-surface-500"> <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>
</div> </div>
@@ -136,7 +136,7 @@ import { supabase } from '@/lib/supabase/client'
const { canSee } = useRoleGuard() 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. * Usada para geração rápida de dados fictícios.
*/ */
const PSICANALISE_PENSADORES = Object.freeze([ const PSICANALISE_PENSADORES = Object.freeze([

View File

@@ -134,7 +134,7 @@ onMounted(load)
</div> </div>
<div v-else> <div v-else>
<!-- Resumo tipo cards --> <!-- Resumo tipo "cards" -->
<div class="grid grid-cols-12 gap-3 mb-4"> <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="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> <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="flex items-center justify-between">
<div class="text-900 font-medium">Jornada do dia</div> <div class="text-900 font-medium">Jornada do dia</div>
<div class="text-600 text-sm"> <div class="text-600 text-sm">
(Isso vem das suas janelas semanais) (Isso vem das suas "janelas semanais")
</div> </div>
</div> </div>

View File

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

View File

@@ -122,7 +122,7 @@
<div class="text-gray-900 mb-2 text-3xl font-semibold">Joséphine Miller</div> <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> <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"> <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> </p>
<img src="/demo/images/landing/peak-logo.svg" class="mt-6" alt="Company logo" /> <img src="/demo/images/landing/peak-logo.svg" class="mt-6" alt="Company logo" />
</div> </div>

View File

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

View File

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

View File

@@ -281,12 +281,13 @@ function buildFcOptions (ownerId) {
eventResize: (info) => emit('eventResize', info), eventResize: (info) => emit('eventResize', info),
eventContent: (arg) => { eventContent: (arg) => {
const ext = arg.event.extendedProps || {} const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || '' const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || '' const nome = ext.paciente_nome || ''
const obs = ext.observacoes || '' const obs = ext.observacoes || ''
const title = arg.event.title || '' const title = arg.event.title || ''
const timeText = arg.timeText || '' const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
const esc = (s) => String(s ?? '') const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;')
@@ -305,8 +306,11 @@ function buildFcOptions (ownerId) {
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: '' : ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '' const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</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 { return {
html: `<div class="ev-custom"> html: `<div class="ev-custom">
@@ -314,10 +318,25 @@ function buildFcOptions (ownerId) {
<div class="ev-body"> <div class="ev-body">
${timeHtml} ${timeHtml}
<div class="ev-title">${esc(title)}</div> <div class="ev-title">${esc(title)}</div>
${statusBadge}
${obsHtml} ${obsHtml}
</div> </div>
</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. Este dia é folga na sua jornada. Você ainda pode salvar se necessário.
</Message> </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 --> <!-- Alerta: solicitação pendente neste horário -->
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" class="mb-3" :closable="false"> <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"> <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="min-w-0 flex-1">
<div class="font-bold text-base truncate">{{ form.paciente_nome }}</div> <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>
<div class="flex gap-1 shrink-0"> <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" /> <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"> <div class="field-card__body">
<SelectButton <SelectButton
v-model="form.status" v-model="form.status"
:options="statusOptions" :options="statusOptionsFiltered"
optionLabel="label" optionLabel="label"
optionValue="value" optionValue="value"
optionDisabled="disabled"
:allowEmpty="false" :allowEmpty="false"
:disabled="isArchivedPastEdit"
class="w-full status-select-btn" class="w-full status-select-btn"
/> />
</div> </div>
@@ -684,6 +707,7 @@
variant="filled" variant="filled"
rows="3" rows="3"
autoResize autoResize
:disabled="isArchivedPastEdit"
/> />
<label for="aed-observacoes-side">Observação</label> <label for="aed-observacoes-side">Observação</label>
</FloatLabel> </FloatLabel>
@@ -692,6 +716,11 @@
<!-- Opção de recorrência para sessão SEM série (criação ou avulsa) --> <!-- Opção de recorrência para sessão SEM série (criação ou avulsa) -->
<template v-if="!hasSerie"> <template v-if="!hasSerie">
<div class="side-card__title mb-2">Frequência</div> <div class="side-card__title mb-2">Frequência</div>
<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 --> <!-- Data de início (= form.dia) com botão Hoje -->
<div class="rec-startdate-row mb-3"> <div class="rec-startdate-row mb-3">
@@ -708,8 +737,12 @@
v-for="f in freqOpcoes" v-for="f in freqOpcoes"
:key="f.value" :key="f.value"
class="freq-chip" class="freq-chip"
:class="{ 'freq-chip--active': recorrenciaType === f.value }" :class="{
@click="recorrenciaType = f.value" '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> >{{ f.label }}</button>
</div> </div>
@@ -1101,6 +1134,7 @@ import { useServices } from '@/features/agenda/composables/useServices'
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices' import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts' import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans' import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans'
import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle'
function patientInitials (nome) { function patientInitials (nome) {
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean) const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
@@ -1663,7 +1697,8 @@ const patients = ref([])
const filteredPatients = computed(() => { const filteredPatients = computed(() => {
const q = String(pacienteSearch.value || '').trim().toLowerCase() 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 if (!q) return list
return list.filter(p => { return list.filter(p => {
const nome = String(p.nome || '').toLowerCase() const nome = String(p.nome || '').toLowerCase()
@@ -2137,6 +2172,39 @@ const pillDeleteMenuItems = computed(() => {
}) })
function isPast (iso) { return iso ? new Date(iso) < new Date() : false } 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 fmtWeekdayShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3) }
function fmtDayNum (iso) { return new Date(iso).getDate() } function fmtDayNum (iso) { return new Date(iso).getDate() }
function fmtMonthShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '') } 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 (!form.value.commitment_id) return false
if (requiresPatient.value && !form.value.paciente_id) return false if (requiresPatient.value && !form.value.paciente_id) return false
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) 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 return true
}) })
@@ -2615,9 +2695,10 @@ function resetForm () {
id: r?.id || null, id: r?.id || null,
owner_id: r?.owner_id || props.ownerId || '', owner_id: r?.owner_id || props.ownerId || '',
terapeuta_id: r?.terapeuta_id ?? null, terapeuta_id: r?.terapeuta_id ?? null,
paciente_id: r?.paciente_id ?? null, paciente_id: r?.paciente_id ?? null,
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '', paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
paciente_avatar: r?.paciente_avatar ?? '', paciente_avatar: r?.paciente_avatar ?? '',
paciente_status: r?.paciente_status ?? '',
commitment_id: r?.determined_commitment_id ?? null, commitment_id: r?.determined_commitment_id ?? null,
titulo_custom: r?.titulo_custom || '', titulo_custom: r?.titulo_custom || '',
status: r?.status || 'agendado', status: r?.status || 'agendado',

View File

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

View File

@@ -113,7 +113,7 @@ const attentionSeverity = computed(() => {
<!-- Ações rápidas --> <!-- Ações rápidas -->
<div class="flex align-items-center justify-content-between gap-2 flex-wrap"> <div class="flex align-items-center justify-content-between gap-2 flex-wrap">
<div class="text-xs" style="color: var(--text-color-secondary);"> <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>
<div class="flex align-items-center gap-2"> <div class="flex align-items-center gap-2">

View File

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

View File

@@ -164,6 +164,10 @@
@click="gotoResult(r)" @click="gotoResult(r)"
> >
<div class="font-medium truncate">{{ r.titulo || 'Sem título' }}</div> <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"> <div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
<span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span> <span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span>
@@ -304,6 +308,10 @@
<div class="font-semibold text-sm truncate"> <div class="font-semibold text-sm truncate">
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }} {{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
</div> </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) --> <!-- Linha 3: título (se paciente diferente de título) -->
<div <div
@@ -623,7 +631,18 @@ const timeModeOptions = [
{ label: 'Meu Horário', value: 'my' } { 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 () { function settingsFallbackStart () {
const s = settings.value const s = settings.value
@@ -936,15 +955,26 @@ const baseRows = computed(() => {
}) })
if (!onlySessions.value) return refined if (!onlySessions.value) return refined
return refined.filter(r => { // Filtrar por patient_id — filtro por tipo não funciona pois o enum do banco
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO) // usa 'sessao' para todos os compromissos não-bloqueio (Análise, Leitura, etc.)
return tipo === EVENTO_TIPO.SESSAO || r.masked === true return refined.filter(r => !!(r.patient_id || r.masked))
})
}) })
const allEvents = computed(() => { 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) // 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) const base = mapAgendaEventosToCalendarEvents(realRows)
// ocorrências virtuais das séries // ocorrências virtuais das séries
@@ -953,8 +983,8 @@ const allEvents = computed(() => {
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO) const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
const dc = r.determined_commitment_id const dc = r.determined_commitment_id
if (tipo === EVENTO_TIPO.SESSAO && dc && !isSessionCommitmentId(dc)) return maskPrivateRow(r) if (tipo === EVENTO_TIPO.SESSAO && dc && !isSessionCommitmentId(dc)) return maskPrivateRow(r)
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return null if (onlySessions.value && !(r.patient_id || r.masked)) return null
return r return withCommitmentColors(r)
}).filter(Boolean) }).filter(Boolean)
const occEvents = mapAgendaEventosToCalendarEvents(occRows) const occEvents = mapAgendaEventosToCalendarEvents(occRows)
@@ -2059,7 +2089,7 @@ async function _reloadRange () {
// Expande recorrências para cada terapeuta no range // Expande recorrências para cada terapeuta no range
const allMerged = [] const allMerged = []
for (const ownId of ownerIds.value) { 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)) allMerged.push(...merged.filter(r => r.is_occurrence))
} }
_occurrenceRows.value = allMerged _occurrenceRows.value = allMerged
@@ -2536,7 +2566,7 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
width: 5px; width: 5px;
height: 5px; height: 5px;
border-radius: 50%; border-radius: 50%;
background: greenyellow; background: var(--primary-color, #6366f1);
} }
/* Badge numérico no header */ /* Badge numérico no header */

View File

@@ -91,10 +91,10 @@
</div> </div>
<!-- Aviso: fora da jornada --> <!-- 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"> <div class="flex items-center gap-3">
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" /> <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'" /> <Button label="Ver 24h" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="timeMode = '24'" />
</div> </div>
</div> </div>
@@ -185,12 +185,21 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span> <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 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.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>
</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> </div>
</div> </div>
@@ -199,6 +208,25 @@
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton(); agPanelOpen = false" /> <Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton(); agPanelOpen = false" />
</div> </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 --> <!-- Pacientes -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3"> <div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -406,12 +434,21 @@
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span> <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 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.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>
</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> </div>
</div> </div>
@@ -420,6 +457,25 @@
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" /> <Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
</div> </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 --> <!-- Pacientes -->
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3"> <div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
@@ -549,7 +605,7 @@
<!-- Sem resultados --> <!-- Sem resultados -->
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm"> <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>. <span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
</div> </div>
@@ -579,6 +635,7 @@
<div class="font-semibold text-sm truncate"> <div class="font-semibold text-sm truncate">
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }} {{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
</div> </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) --> <!-- Linha 3: título (se paciente diferente de título) -->
<div <div
@@ -737,6 +794,140 @@
/> />
</template> </template>
</Dialog> </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> </template>
<script setup> <script setup>
@@ -753,11 +944,13 @@ import Calendar from 'primevue/calendar'
import FullCalendar from '@fullcalendar/vue3' import FullCalendar from '@fullcalendar/vue3'
import timeGridPlugin from '@fullcalendar/timegrid' import timeGridPlugin from '@fullcalendar/timegrid'
import dayGridPlugin from '@fullcalendar/daygrid' import dayGridPlugin from '@fullcalendar/daygrid'
import listPlugin from '@fullcalendar/list'
import interactionPlugin from '@fullcalendar/interaction' import interactionPlugin from '@fullcalendar/interaction'
import ptBrLocale from '@fullcalendar/core/locales/pt-br' import ptBrLocale from '@fullcalendar/core/locales/pt-br'
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue' import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.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 ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue'
import { useSupportDebugStore } from '@/support/supportDebugStore' import { useSupportDebugStore } from '@/support/supportDebugStore'
@@ -810,7 +1003,7 @@ const {
const commitmentOptionsNormalized = computed(() => { const commitmentOptionsNormalized = computed(() => {
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : [] 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([ const priority = new Map([
['session', 0], ['session', 0],
['class', 1], ['class', 1],
@@ -876,13 +1069,16 @@ onMounted(async () => {
if (tid) loadFeriados(tid) if (tid) loadFeriados(tid)
}) })
// Carrega desativados assim que ownerId estiver disponível
watch(ownerId, (id) => { if (id) loadDesativados() }, { immediate: true })
// Range atual // Range atual
const currentRange = ref({ start: null, end: null }) const currentRange = ref({ start: null, end: null })
// ----------------------------- // -----------------------------
// Topbar state // Topbar state
// ----------------------------- // -----------------------------
const onlySessions = ref(true) const onlySessions = ref(false)
const calendarView = ref('day') // day | week | month const calendarView = ref('day') // day | week | month
const timeMode = ref('my') // 24 | 12 | my const timeMode = ref('my') // 24 | 12 | my
const search = ref('') const search = ref('')
@@ -954,6 +1150,52 @@ const headerMenuRef = ref(null)
const agPanelOpen = ref(false) const agPanelOpen = ref(false)
const blockMenuRef = ref(null) 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 // Bloqueio dialog
const bloqueioDialogOpen = ref(false) const bloqueioDialogOpen = ref(false)
const bloqueioMode = ref('horario') const bloqueioMode = ref('horario')
@@ -1098,8 +1340,10 @@ const allRows = computed(() => [
const calendarRows = computed(() => { const calendarRows = computed(() => {
return allRows.value.filter(r => { return allRows.value.filter(r => {
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO) // "Apenas Sessões" = eventos vinculados a paciente.
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return false // 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 return true
}) })
}) })
@@ -1172,7 +1416,8 @@ const searchResults = computed(() => {
? monthSearchRows.value ? monthSearchRows.value
: (calendarRows.value || []).map(r => ({ : (calendarRows.value || []).map(r => ({
...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)) return source.filter(r => _matchRow(r, q))
}) })
@@ -1191,7 +1436,7 @@ async function loadMonthSearchRows () {
// mergeWithStoredSessions deduplicar sessões materializadas de séries. // mergeWithStoredSessions deduplicar sessões materializadas de séries.
const { data, error } = await supabase const { data, error } = await supabase
.from('agenda_eventos') .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) .eq('owner_id', uid)
.is('mirror_of_event_id', null) .is('mirror_of_event_id', null)
.gte('inicio_em', startISO) .gte('inicio_em', startISO)
@@ -1199,7 +1444,7 @@ async function loadMonthSearchRows () {
.order('inicio_em', { ascending: true }) .order('inicio_em', { ascending: true })
if (error) throw error 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). // 2. Ocorrências virtuais de recorrência (não existem em agenda_eventos).
// loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence // loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence
@@ -1230,10 +1475,27 @@ watch(currentDate, (newD, oldD) => {
}) })
const calendarEvents = computed(() => { 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 // separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
// em cada grupo — as virtuais precisam do mesmo tratamento de cores // em cada grupo — as virtuais precisam do mesmo tratamento de cores
const realRows = 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) const occRows = calendarRows.value.filter(r => r.is_occurrence).map(withCommitmentColors)
const base = mapAgendaEventosToCalendarEvents(realRows) const base = mapAgendaEventosToCalendarEvents(realRows)
const occEvents = mapAgendaEventosToCalendarEvents(occRows) const occEvents = mapAgendaEventosToCalendarEvents(occRows)
@@ -1387,11 +1649,12 @@ const fcOptions = computed(() => ({
eventContent: (arg) => { eventContent: (arg) => {
const ext = arg.event.extendedProps || {} const ext = arg.event.extendedProps || {}
const avatarUrl = ext.paciente_avatar || '' const avatarUrl = ext.paciente_avatar || ''
const nome = ext.paciente_nome || '' const nome = ext.paciente_nome || ''
const obs = ext.observacoes || '' const obs = ext.observacoes || ''
const title = arg.event.title || '' const title = arg.event.title || ''
const timeText = arg.timeText || '' const timeText = arg.timeText || ''
const pacienteStatus = ext.paciente_status || ''
const esc = (s) => String(s ?? '') const esc = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;') .replace(/&/g, '&amp;').replace(/</g, '&lt;')
@@ -1410,8 +1673,11 @@ const fcOptions = computed(() => ({
? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>` ? `<div class="ev-avatar ev-avatar-initials">${esc(initials(nome))}</div>`
: '' : ''
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : '' const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</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 { return {
html: `<div class="ev-custom"> html: `<div class="ev-custom">
@@ -1419,6 +1685,7 @@ const fcOptions = computed(() => ({
<div class="ev-body"> <div class="ev-body">
${timeHtml} ${timeHtml}
<div class="ev-title">${esc(title)}</div> <div class="ev-title">${esc(title)}</div>
${inativoBadge}
${obsHtml} ${obsHtml}
</div> </div>
</div>` </div>`
@@ -1442,12 +1709,47 @@ const fcOptions = computed(() => ({
const classes = [] const classes = []
if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session') if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session')
if (tipo === EVENTO_TIPO.BLOQUEIO) classes.push('evt-block') 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-hit')
if (qn && !hit) classes.push('evt-dim') if (qn && !hit) classes.push('evt-dim')
return classes 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) ──────────────────────────────────────────── // ── Resumo do dia (coluna direita) ────────────────────────────────────────────
const todayEvents = computed(() => { const todayEvents = computed(() => {
@@ -1508,6 +1810,7 @@ function onEventRowClick (ev) {
if (ev.inicio_em) { if (ev.inicio_em) {
getApi()?.gotoDate?.(new Date(ev.inicio_em)) getApi()?.gotoDate?.(new Date(ev.inicio_em))
calendarView.value = 'day' calendarView.value = 'day'
scrollToAndPulseEvent(ev.id)
} }
} }
@@ -1569,6 +1872,119 @@ function getApi () {
return fcRef.value?.getApi?.() || null 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 () => { watch(calendarView, async () => {
await nextTick() await nextTick()
getApi()?.changeView?.(fcViewName.value) getApi()?.changeView?.(fcViewName.value)
@@ -1947,6 +2363,7 @@ function onEventClick (info) {
paciente_id: ep.paciente_id ?? null, paciente_id: ep.paciente_id ?? null,
paciente_nome: ep.paciente_nome ?? null, paciente_nome: ep.paciente_nome ?? null,
paciente_avatar: ep.paciente_avatar ?? null, paciente_avatar: ep.paciente_avatar ?? null,
paciente_status: ep.paciente_status ?? null,
tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO), tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO),
status: ep.status, status: ep.status,
titulo: ev.title, titulo: ev.title,
@@ -2847,11 +3264,16 @@ onMounted(async () => {
} }
/* ── Cores dos eventos (global — aplicadas pelo FullCalendar) ── */ /* ── 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; background-color: var(--p-primary-500, #6366f1) !important;
border-color: var(--p-primary-600, #4f46e5) !important; border-color: var(--p-primary-600, #4f46e5) !important;
color: #fff !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 { .fc-event.evt-block {
background-color: #ef4444 !important; background-color: #ef4444 !important;
border-color: #dc2626 !important; border-color: #dc2626 !important;

View File

@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
const { data, error } = await supabase const { data, error } = await supabase
.from('agenda_eventos') .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) .eq('tenant_id', tenantId)
.in('owner_id', safeOwnerIds) .in('owner_id', safeOwnerIds)
.gte('inicio_em', startISO) .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_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
paciente_nome: nomeP, paciente_nome: nomeP,
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null, paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
// campos // campos
observacoes: r.observacoes ?? null, 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 class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
</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 --> <!-- Último atendimento não clicável -->
<div <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" 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;"> <Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
<template #body="{ data }"> <template #body="{ data }">
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" /> <Tag :value="data.status" :severity="statusSeverity(data.status)" />
</template> </template>
</Column> </Column>
@@ -481,13 +493,17 @@
</template> </template>
</Column> </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 }"> <template #body="{ data }">
<div class="flex gap-2 justify-end"> <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 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-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)" /> <PatientActionMenu
:patient="data"
:hasHistory="historySet.has(data.id)"
@updated="fetchAll"
/>
</div> </div>
</template> </template>
</Column> </Column>
@@ -542,7 +558,7 @@
</div> </div>
<div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div> <div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div> </div>
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" /> <Tag :value="pat.status" :severity="statusSeverity(pat.status)" />
</div> </div>
<!-- Grupos + Tags --> <!-- Grupos + Tags -->
@@ -553,10 +569,14 @@
<!-- Ações --> <!-- Ações -->
<div class="mt-3 flex gap-2 justify-end flex-wrap"> <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 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-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> </div>
</div> </div>
@@ -586,14 +606,85 @@
</TabPanel> </TabPanel>
<TabPanel value="grupos"> <TabPanel value="grupos">
<Card> <!-- Cabeçalho da view de grupos -->
<template #content> <div class="flex items-center justify-between gap-3 mb-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-center gap-2">
<div class="text-color-secondary">Atalho para a página de Grupos.</div> <i class="pi pi-sitemap text-[var(--primary-color,#6366f1)]" />
<Button label="Abrir Grupos" icon="pi pi-external-link" outlined @click="goGroups" /> <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> </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> </TabPanel>
</TabPanels> </TabPanels>
</Tabs> </Tabs>
@@ -696,6 +787,7 @@ import ProgressSpinner from 'primevue/progressspinner'
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue' import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue' import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue'
// ── Descontos por paciente ──────────────────────────────────────── // ── Descontos por paciente ────────────────────────────────────────
const discountMap = ref({}) const discountMap = ref({})
@@ -827,20 +919,24 @@ function setAllColumns () { selectedColumns.value = columnCatalogAll.map(c =
const sort = reactive({ field: 'created_at', order: -1 }) 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({ const filters = reactive({
status: 'Todos', search: '', status: 'Ativo', search: '',
groupId: null, tagId: null, groupId: null, tagId: null,
createdFrom: null, createdTo: null createdFrom: null, createdTo: null
}) })
const statusOptions = [ const statusOptions = [
{ label: 'Todos', value: 'Todos' }, { label: 'Ativos', value: 'Ativo' },
{ label: 'Ativo', value: 'Ativo' }, { label: 'Inativos', value: 'Inativo' },
{ label: 'Inativo', 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 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 }))) const tagOptions = computed(() => (tags.value || []).map(t => ({ label: t.name, value: t.id })))
@@ -964,7 +1060,7 @@ function onFilterChangedDebounced () {
function onFilterChanged () { updateKpis() } function onFilterChanged () { updateKpis() }
function setStatus (s) { filters.status = s; onFilterChanged() } function setStatus (s) { filters.status = s; onFilterChanged() }
function clearAllFilters () { 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 filters.tagId = null; filters.createdFrom = null; filters.createdTo = null
onFilterChanged() onFilterChanged()
} }
@@ -987,6 +1083,15 @@ function normalizeStatus (s) {
return v.charAt(0).toUpperCase() + v.slice(1) 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) { function initials (name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean) const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
if (!parts.length) return '—' if (!parts.length) return '—'
@@ -1196,44 +1301,68 @@ async function hydrateAssociationsSupabase () {
groups: groupsByPatient.get(p.id) || [], groups: groupsByPatient.get(p.id) || [],
tags: tagsByPatient.get(p.id) || [] tags: tagsByPatient.get(p.id) || []
})) }))
}
// ── Delete ──────────────────────────────────────────────── // Calcula historySet — uma única query para todos os ids
function confirmDeleteOne (row) { const { data: evtCounts } = await supabase
const nome = row?.nome_completo || 'este paciente' .from('agenda_eventos')
confirm.require({ .select('patient_id')
header: 'Excluir paciente', .in('patient_id', ids)
message: `Tem certeza que deseja excluir "${nome}"?`, .not('patient_id', 'is', null)
icon: 'pi pi-exclamation-triangle', .limit(1000)
acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger',
accept: () => removePatient(row)
})
}
async function removePatient (row) { const tempSet = new Set()
try { const countMap = new Map()
await supabase.from('patient_group_patient').delete().eq('patient_id', row.id) for (const r of (evtCounts || [])) {
await supabase.from('patient_patient_tag').delete().eq('patient_id', row.id) if (r.patient_id) {
const { error } = await supabase.from('patients').delete().eq('id', row.id).eq('owner_id', uid.value) tempSet.add(r.patient_id)
if (error) throw error countMap.set(r.patient_id, (countMap.get(r.patient_id) || 0) + 1)
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 })
} }
historySet.value = tempSet
sessionCountMap.value = countMap
} }
// Delete movido para PatientActionMenu + usePatientLifecycle
// ── KPIs ────────────────────────────────────────────────── // ── KPIs ──────────────────────────────────────────────────
function updateKpis () { function updateKpis () {
const all = patients.value || [] const all = patients.value || []
kpis.total = all.length kpis.total = all.length
kpis.active = all.filter(p => p.status === 'Ativo').length kpis.active = all.filter(p => p.status === 'Ativo').length
kpis.inactive = all.filter(p => p.status === 'Inativo').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() const dates = all.map(p => (p.last_attended_at || '').slice(0, 10)).filter(Boolean).sort()
kpis.latestLastAttended = dates.length ? dates[dates.length - 1] : '' 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 const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
function isRecent (row) { function isRecent (row) {
if (!row?.created_at) return false 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() const menuStore = useMenuStore()
// ====================================================== // ======================================================
// ✅ Blindagem anti-menu some // ✅ Blindagem anti-"menu some"
// - se o menuStore.model piscar como [], mantém o último menu válido // - 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) // - 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 { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client' import { supabase } from '@/lib/supabase/client'
import { useRoleGuard } from '@/composables/useRoleGuard' import { useRoleGuard } from '@/composables/useRoleGuard'
const router = useRouter() const props = defineProps({
const pop = ref(null) 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() const { role, canSee } = useRoleGuard()
// ------------------------------------------------------
// UI labels (nome/iniciais)
// ------------------------------------------------------
const initials = computed(() => { const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || '' const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean) const parts = String(name).trim().split(/\s+/).filter(Boolean)
@@ -33,167 +30,181 @@ const label = computed(() => {
return name || sessionUser.value?.email || 'Conta' 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 sublabel = computed(() => {
const r = role.value || sessionRole.value const r = role.value || sessionRole.value
if (!r) return 'Sessão' if (!r) return 'Sessão'
// tenant roles
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador' if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta' if (r === 'therapist') return 'Terapeuta'
if (r === 'portal_user' || r === 'patient') return 'Portal'
// portal/global roles
if (r === 'portal_user') return 'Portal'
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
return r return r
}) })
// ------------------------------------------------------ const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
// Popover helpers
// ------------------------------------------------------
function toggle (e) {
pop.value?.toggle(e)
}
function close () { function toggle (e) { pop.value?.toggle(e) }
try { pop.value?.hide() } catch {} function close () { try { pop.value?.hide() } catch {} }
}
// ------------------------------------------------------
// Navegação segura (resolve antes; fallback se não existir)
// ------------------------------------------------------
async function safePush (target, fallback) { async function safePush (target, fallback) {
try { try {
const r = router.resolve(target) const r = router.resolve(target)
if (r?.matched?.length) return await router.push(target) if (r?.matched?.length) return await router.push(target)
} catch {} } catch {}
if (fallback) { try { return await router.push(fallback) } catch {} }
if (fallback) {
try { return await router.push(fallback) } catch {}
}
return router.push('/') return router.push('/')
} }
// ------------------------------------------------------ function goMyProfile () { close(); safePush({ name: 'account-profile' }, '/account/profile') }
// Actions function goSecurity () { close(); safePush({ name: 'account-security' }, '/account/security') }
// ------------------------------------------------------ function goSettings () {
function goMyProfile () {
close() close()
safePush({ name: 'account-profile' }, '/account/profile') if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings')
}
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
return safePush({ name: 'portal-sessoes' }, '/portal') 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 () { async function signOut () {
close() close()
try { try { await supabase.auth.signOut() } catch {}
await supabase.auth.signOut() finally { router.push('/auth/login') }
} catch {
// se falhar, ainda assim manda pro login
} finally {
router.push('/auth/login')
}
} }
defineExpose({ toggle })
</script> </script>
<template> <template>
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]"> <!-- SIDEBAR: trigger + popover -->
<button <template v-if="variant === 'sidebar'">
type="button" <div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition" <button
@click="toggle" type="button"
> class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150"
<!-- avatar --> @click="toggle"
<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"
> >
{{ initials }} <img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
</div> <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">
<!-- labels --> <div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
<div class="min-w-0 flex-1 text-left"> <div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
<div class="truncate text-sm font-semibold text-[var(--text-color)]">
{{ label }}
</div> </div>
<div class="truncate text-xs text-[var(--text-color-secondary)]"> <i class="pi pi-angle-up text-xs opacity-40" />
{{ sublabel }} </button>
</div>
</div>
<i class="pi pi-angle-up text-xs opacity-70" /> <Popover ref="pop" appendTo="body">
</button> <!-- 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"> <Popover ref="pop" appendTo="body">
<div class="min-w-[220px] p-1"> <div class="w-[224px] overflow-hidden rounded-[inherit]">
<Button <!-- Header -->
v-if="canSee('settings.view')" <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)]">
label="Configurações" <div class="relative shrink-0">
icon="pi pi-cog" <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" />
text <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>
class="w-full justify-start" <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)]" />
@click="goSettings" </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 <!-- Nav items -->
label="Segurança" <div class="py-1.5">
icon="pi pi-shield" <button
text 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"
class="w-full justify-start" @click="goMyProfile"
@click="goSecurity" >
/> <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 <!-- Footer: Sair -->
label="Meu Perfil" <div class="border-t border-[var(--surface-border)] py-1.5">
icon="pi pi-user" <button
text 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"
class="w-full justify-start" @click="signOut"
@click="goMyProfile" >
/> <i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
<div class="my-1 border-t border-[var(--surface-border)]" /> </button>
</div>
<Button
label="Sair"
icon="pi pi-sign-out"
severity="danger"
text
class="w-full justify-start"
@click="signOut"
/>
</div> </div>
</Popover> </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 { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
const { layoutState, isDesktop } = useLayout() const { layoutState, isDesktop } = useLayout()
const router = useRouter() const router = useRouter()
@@ -15,6 +16,15 @@ const pop = ref(null)
const tenantStore = useTenantStore() const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore() 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']) const emit = defineEmits(['quick-create'])
@@ -102,7 +112,7 @@ const showProBadge = computed(() => {
try { try {
return !entitlementsStore.has(feature) return !entitlementsStore.has(feature)
} catch { } catch {
// se der erro, não mostra (evita PRO fantasma) // se der erro, não mostra (evita "PRO fantasma")
return false return false
} }
}) })
@@ -221,6 +231,14 @@ async function irCadastroCompleto () {
PRO PRO
</span> </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" /> <i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
</component> </component>

View File

@@ -1,19 +1,15 @@
<!-- src/layout/AppRail.vue Mini icon rail (Layout 2) --> <!-- src/layout/AppRail.vue Mini icon rail (Layout 2) -->
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover' import { useMenuStore } from '@/stores/menuStore'
import Button from 'primevue/button' import { useLayout } from './composables/layout'
import { sessionUser } from '@/app/session'
import { useMenuStore } from '@/stores/menuStore' import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import { useLayout } from './composables/layout'
import { sessionUser } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
const menuStore = useMenuStore() const menuStore = useMenuStore()
const { layoutConfig, layoutState, isDesktop } = useLayout() const { layoutState } = useLayout()
const router = useRouter()
// ── Seções do rail (derivadas do model) ───────────────────── // ── Seções do rail (derivadas do model) ─────────────────────
const railSections = computed(() => { const railSections = computed(() => {
@@ -38,7 +34,6 @@ const initials = computed(() => {
const b = parts.length > 1 ? parts[parts.length - 1][0] : '' const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
return (a + b).toUpperCase() return (a + b).toUpperCase()
}) })
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta') const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
// ── Início (fixo) ──────────────────────────────────────────── // ── Início (fixo) ────────────────────────────────────────────
@@ -74,20 +69,9 @@ function isActiveSectionOrChild (section) {
}) })
} }
// ── Popover do usuário (rodapé) ─────────────────────────────── // ── Menu do usuário (rodapé) ─────────────────────────────────
const userPop = ref(null) const footerPanel = ref(null)
function toggleUserPop (e) { userPop.value?.toggle(e) } function toggleUserMenu (e) { footerPanel.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')
}
</script> </script>
<template> <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" 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 }" v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
aria-label="Configurações" aria-label="Configurações"
@click="goTo('/configuracoes')" @click="$router.push('/configuracoes')"
> >
<i class="pi pi-fw pi-cog" /> <i class="pi pi-fw pi-cog" />
</button> </button>
<!-- Avatar / user --> <!-- Avatar trigger do menu de usuário -->
<button <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)]" 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 }" v-tooltip.right="{ value: userName, showDelay: 0 }"
:aria-label="userName" :aria-label="userName"
@click="toggleUserPop" @click="toggleUserMenu"
> >
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" /> <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> <span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
</button> </button>
</div> </div>
<!-- Popover usuário --> <!-- Menu de usuário (popup via AppMenuFooterPanel) -->
<Popover ref="userPop" appendTo="body"> <AppMenuFooterPanel ref="footerPanel" variant="rail" />
<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>
<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> </aside>
</template> </template>

View File

@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore' import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout' import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
const menuStore = useMenuStore() const menuStore = useMenuStore()
const { layoutState } = useLayout() const { layoutState } = useLayout()
const entitlements = useEntitlementsStore() const entitlements = useEntitlementsStore()
const menuBadges = useMenuBadges()
const router = useRouter() const router = useRouter()
const route = useRoute() 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 ────────────────────────────────────────────── // ── Seção ativa ──────────────────────────────────────────────
const currentSection = computed(() => { const currentSection = computed(() => {
const model = menuStore.model || [] 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" /> <i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ child.label }}</span> <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="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> </button>
</div> </div>
@@ -388,6 +399,7 @@ async function goToResult (r) {
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" /> <i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
<span class="flex-1">{{ item.label }}</span> <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="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> </button>
</template> </template>

View File

@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore' import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout' import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore' import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuBadges } from '@/composables/useMenuBadges'
const menuStore = useMenuStore() const menuStore = useMenuStore()
const { layoutState, hideMobileMenu } = useLayout() const { layoutState, hideMobileMenu } = useLayout()
const entitlements = useEntitlementsStore() const entitlements = useEntitlementsStore()
const menuBadges = useMenuBadges()
const router = useRouter() const router = useRouter()
const route = useRoute() 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 sections = computed(() => {
const model = menuStore.model || [] const model = menuStore.model || []
return model return model
@@ -389,6 +399,7 @@ watch(() => route.path, () => hideMobileMenu())
<i v-if="child.icon" :class="child.icon" class="rs__item-icon" /> <i v-if="child.icon" :class="child.icon" class="rs__item-icon" />
<span>{{ child.label }}</span> <span>{{ child.label }}</span>
<span v-if="isLocked(child)" class="rs__pro">PRO</span> <span v-if="isLocked(child)" class="rs__pro">PRO</span>
<span v-if="menuBadgeLabel(child)" class="rs__badge">{{ menuBadgeLabel(child) }}</span>
</button> </button>
</template> </template>
@@ -405,6 +416,7 @@ watch(() => route.path, () => hideMobileMenu())
<i v-if="item.icon" :class="item.icon" class="rs__item-icon" /> <i v-if="item.icon" :class="item.icon" class="rs__item-icon" />
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
<span v-if="isLocked(item)" class="rs__pro">PRO</span> <span v-if="isLocked(item)" class="rs__pro">PRO</span>
<span v-if="menuBadgeLabel(item)" class="rs__badge">{{ menuBadgeLabel(item) }}</span>
</button> </button>
</template> </template>
</div> </div>
@@ -672,6 +684,15 @@ watch(() => route.path, () => hideMobileMenu())
border: 1px solid var(--surface-border); border: 1px solid var(--surface-border);
color: var(--text-color-secondary); 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 ────────────────────────────────── */ /* ── Slide-in da esquerda ────────────────────────────────── */
.rs-slide-enter-active, .rs-slide-enter-active,

View File

@@ -11,7 +11,7 @@ let outsideClickListener = null
// ✅ rota mudou: // ✅ rota mudou:
// - atualiza activePath sempre (desktop e mobile) // - 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( watch(
() => route.path, () => route.path,
(newPath) => { (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 * - se tem tenant ativo => carrega tenant entitlements
* - senão => carrega user 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 * Motivo: você aplica tema cedo (main.js / user_settings) e depois
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar, * usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
* isDarkTheme pode ficar mentindo. * isDarkTheme pode ficar "mentindo".
*/ */
let _syncedDarkFromDomOnce = false let _syncedDarkFromDomOnce = false
function syncDarkFromDomOnce () { function syncDarkFromDomOnce () {

View File

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

View File

@@ -11,7 +11,8 @@ export default function adminMenu (ctx = {}) {
label: 'Agenda da Clínica', label: 'Agenda da Clínica',
icon: 'pi pi-fw pi-calendar', icon: 'pi pi-fw pi-calendar',
to: { name: 'admin-agenda-clinica' }, to: { name: 'admin-agenda-clinica' },
feature: 'agenda.view' feature: 'agenda.view',
badgeKey: 'agendaHoje'
}, },
// ✅ Compromissos determinísticos (tipos) // ✅ 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: '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: '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: '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', icon: 'pi pi-fw pi-inbox',
to: { name: 'admin-agendamentos-recebidos' }, to: { name: 'admin-agendamentos-recebidos' },
feature: 'online_scheduling.manage', feature: 'online_scheduling.manage',
proBadge: true proBadge: true,
badgeKey: 'agendamentosRecebidos'
} }
] ]
} }

View File

@@ -11,7 +11,7 @@ export default [
{ {
label: 'Agenda', label: 'Agenda',
items: [ 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 } { 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: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' }, { 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: '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', icon: 'pi pi-fw pi-inbox',
to: '/therapist/agendamentos-recebidos', to: '/therapist/agendamentos-recebidos',
feature: 'online_scheduling.manage', 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 * - Entitlements (plano): usuário poderia acessar se tivesse feature → manda pro /upgrade
* *
* Por que isso existe? * 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 * - Padronizar o comportamento do app em um único lugar
* - Deixar claro: RBAC ≠ Plano * - Deixar claro: RBAC ≠ Plano
* *
* Convenção recomendada: * 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) * - 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). * 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: * Eu deixei as duas opções:
* - use403 = true → sempre /pages/access (recomendado para clareza) * - 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 } = {}) { export function denyByRole ({ to, currentRole, use403 = true } = {}) {
// ✅ padrão forte: 403 (não é caso de upgrade) // ✅ padrão forte: 403 (não é caso de upgrade)
if (use403) return { path: '/pages/access' } if (use403) return { path: '/pages/access' }
// modo suave: manda pra home do papel // modo "suave": manda pra home do papel
const fallback = roleHomePath(currentRole) const fallback = roleHomePath(currentRole)
// evita loop: se já está no fallback, manda pra página de acesso negado // 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)) { if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
sessionStorage.setItem('redirect_after_login', to.fullPath) sessionStorage.setItem('redirect_after_login', to.fullPath)
_perfEnd() _perfEnd()

View File

@@ -32,7 +32,7 @@ export async function setSlotBloqueado(ownerId, diaSemana, horaInicio, isBloquea
return true 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 const { error } = await supabase
.from('agenda_slots_bloqueados_semanais') .from('agenda_slots_bloqueados_semanais')
.delete() .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 = { export const noirPrimaryFromSurface = {
50: '{surface.50}', 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)) * Use assim: updatePreset(getPresetExt(layoutConfig))
*/ */
export function getPresetExt(layoutConfig) { export function getPresetExt(layoutConfig) {

View File

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

View File

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

View File

@@ -183,7 +183,7 @@ function friendlyError (err) {
function safeRpcError (rpcError) { function safeRpcError (rpcError) {
const raw = (rpcError?.message || '').toString().trim() 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) const friendly = friendlyError(rpcError)
return { friendly, raw } return { friendly, raw }
} }
@@ -241,7 +241,7 @@ async function acceptInvite (token) {
const { friendly, raw } = safeRpcError(error) const { friendly, raw } = safeRpcError(error)
state.error = friendly 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 // state.debugDetails = raw
// Opcional: toast discreto // 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"> <div class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100">
<Toast /> <Toast />
<!-- Backdrop conceitual --> <!-- "Backdrop" conceitual -->
<div class="pointer-events-none fixed inset-0 opacity-30"> <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-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" /> <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) // Progress (conceitual e útil)
// ------------------------------------------------------ // ------------------------------------------------------
const progressPct = computed(() => { 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 = [ const checks = [
!!cleanStr(form.nome_completo), !!cleanStr(form.nome_completo),
!!digitsOnly(form.telefone), !!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"> <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. 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> </p>
<div class="mt-6 flex flex-col sm:flex-row gap-2"> <div class="mt-6 flex flex-col sm:flex-row gap-2">
@@ -101,7 +101,7 @@
</div> </div>
<div class="mt-6 text-xs text-[var(--text-color-secondary)]"> <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>
</div> </div>
@@ -322,7 +322,7 @@
<div> <div>
<div class="font-semibold">3) Acompanhar</div> <div class="font-semibold">3) Acompanhar</div>
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed"> <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>
<div class="mt-2"> <div class="mt-2">
<Tag severity="secondary" value="Recebimentos" /> <Tag severity="secondary" value="Recebimentos" />

View File

@@ -21,7 +21,7 @@ const email = ref('')
const password = ref('') const password = ref('')
const loading = ref(false) 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 emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()))
const passwordOk = computed(() => String(password.value || '').length >= 6) const passwordOk = computed(() => String(password.value || '').length >= 6)
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value) 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. * ✅ 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) { function toggleLocal (planId, featureId, nextValue) {
if (loading.value || saving.value) return if (loading.value || saving.value) return

View File

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

View File

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

View File

@@ -35,7 +35,7 @@
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday, '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, '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>{{ cell.day }}</span>
<span <span
@@ -60,15 +60,17 @@
<div <div
v-for="ev in eventosDoDia" v-for="ev in eventosDoDia"
:key="ev.id" :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="{ :class="{
'border-l-sky-400': ev.tipo === 'reuniao', 'border-l-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
'border-l-green-400': ev.status === 'realizado', 'border-l-green-400': !ev.bgColor && ev.status === 'realizado',
'border-l-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && 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]"> <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> <span class="text-xs text-[var(--text-color-secondary)]">{{ ev.dur }}</span>
</div> </div>
<div class="flex-1 min-w-0"> <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> <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>
<div class="flex flex-col gap-1.5 max-h-[170px] overflow-y-auto"> <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="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"> <div class="flex-1 min-w-0">
<span class="block text-xs font-semibold">{{ r.nome }}</span> <span class="block text-xs font-semibold">{{ r.nome }}</span>
@@ -130,7 +132,7 @@
</div> </div>
<div class="min-w-0"> <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-[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>
</div> </div>
<!-- Controles (desktop e mobile mesmo conteúdo, sempre visível) --> <!-- Controles (desktop e mobile mesmo conteúdo, sempre visível) -->
@@ -163,7 +165,7 @@
'text-[var(--text-color)]': !s.cls, 'text-[var(--text-color)]': !s.cls,
}" }"
>{{ s.value }}</div> >{{ 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> </div>
</div> </div>
@@ -204,11 +206,11 @@
v-for="ev in timelineEvents" v-for="ev in timelineEvents"
:key="ev.id" :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" 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="{ :class="{
'bg-sky-400': ev.tipo === 'reuniao', 'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
'bg-green-500': ev.status === 'realizado', 'bg-green-500': !ev.bgColor && ev.status === 'realizado',
'bg-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && ev.status !== 'realizado', 'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado',
}" }"
:title="ev.tooltip" :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="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="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"> <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> <span class="block text-xs text-[var(--text-color-secondary)]">Solicitações do portal externo</span>
</div> </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> <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="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="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"> <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> <span class="block text-xs text-[var(--text-color-secondary)]">Pacientes aguardando triagem</span>
</div> </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> <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 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="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"> <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> <span class="block text-xs text-[var(--text-color-secondary)]">{{ c.detalhe }}</span>
</div> </div>
<button <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="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="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"> <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> <span class="block text-xs text-[var(--text-color-secondary)]">Atenção necessária</span>
</div> </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> <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 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 v-for="r in recAlerta" :key="r.id" class="flex items-center gap-2.5 py-1">
<div class="flex-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 <span
class="block text-xs font-semibold mt-0.5" class="block text-xs font-semibold mt-0.5"
:class="{ :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="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="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"> <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> <span class="block text-xs text-[var(--text-color-secondary)]">Presença, faltas e reposições</span>
</div> </div>
</div> </div>
@@ -418,13 +420,49 @@
</section> </section>
</main> </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> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onBeforeUnmount } from 'vue' 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 { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore' 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 dashHeroSentinelRef = ref(null)
const heroStuck = ref(false) const heroStuck = ref(false)
@@ -449,6 +487,241 @@ const saudacao = computed(() => {
}) })
const tenantStore = useTenantStore() 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 ownerId = ref(null)
const eventosDoMes = ref([]) const eventosDoMes = ref([])
@@ -506,18 +779,33 @@ const STATUS_ICON = {
agendado: 'pi pi-clock', 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) { function buildEventoItem (ev) {
const inicio = new Date(ev.inicio_em) const inicio = new Date(ev.inicio_em)
const fim = ev.fim_em ? new Date(ev.fim_em) : null const fim = ev.fim_em ? new Date(ev.fim_em) : null
const durMin = fim ? Math.round((fim - inicio) / 60000) : 50 const durMin = fim ? Math.round((fim - inicio) / 60000) : 50
const h = inicio.getHours().toString().padStart(2, '0') const h = inicio.getHours().toString().padStart(2, '0')
const m = inicio.getMinutes().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 { return {
id: ev.id, hora: `${h}:${m}`, dur: `${durMin}min`, id: ev.id, hora: `${h}:${m}`, dur: `${durMin}min`,
nome: ev.patients?.nome_completo || ev.titulo || ev.titulo_custom || '—', nome: ev.patients?.nome_completo || ev.titulo || ev.titulo_custom || '—',
modalidade: ev.modalidade || 'Presencial', recorrente: !!ev.recurrence_id, modalidade: ev.modalidade || 'Presencial', recorrente: !!ev.recurrence_id,
status: ev.status || 'agendado', statusIcon: STATUS_ICON[ev.status] || 'pi pi-clock', status: ev.status || 'agendado', statusIcon: STATUS_ICON[ev.status] || 'pi pi-clock',
tipo: ev.tipo || 'sessao', 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 diaLabel = weekdays.map(d => DIAS_PT[d]).join(', ')
const hora = r.start_time ? String(r.start_time).slice(0, 5) : '' const hora = r.start_time ? String(r.start_time).slice(0, 5) : ''
const proxLabel = nextOccurrenceLabel(r) 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(() => // ── Derivados de eventosDoMes — single pass ───────────────────
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 }) // 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(() => { let hojeCnt = 0, semanaCnt = 0, realizadosCnt = 0, encerradosCnt = 0
const now = agora.value, ini = new Date(now) const hojeLista = [], timelineLista = []
ini.setDate(now.getDate() - now.getDay()); ini.setHours(0, 0, 0, 0) const diasSemanaMap = [[], [], [], [], [], [], []]
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 }) 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 eventosHoje = computed(() => _statsDoMes.value.hojeLista)
const encerrados = eventosDoMes.value.filter(ev => ev.inicio_em && new Date(ev.inicio_em) < new Date() && ['realizado','faltou','cancelado'].includes(ev.status)) const eventosSemana = computed(() => ({ length: _statsDoMes.value.semanaCnt }))
if (!encerrados.length) return null const taxaPresenca = computed(() => _statsDoMes.value.taxaPresenca)
return Math.round((encerrados.filter(ev => ev.status === 'realizado').length / encerrados.length) * 100)
})
const quickStats = computed(() => { const quickStats = computed(() => {
const pendentes = _solicitacoesBruto.value.length + _cadastrosBruto.value.length const pendentes = _solicitacoesBruto.value.length + _cadastrosBruto.value.length
const pct = taxaPresenca.value const pct = taxaPresenca.value
return [ return [
{ value: String(eventosHoje.value.length), label: 'Hoje', cls: '' }, { value: String(_statsDoMes.value.hojeCnt), label: 'Hoje', cls: '' },
{ value: String(pendentes), label: 'Pendentes', cls: pendentes > 0 ? 'qs-urgente' : '' }, { value: String(pendentes), label: 'Pendentes', cls: pendentes > 0 ? 'qs-urgente' : '' },
{ value: String(eventosSemana.value.length), label: 'Semana', cls: '' }, { value: String(_statsDoMes.value.semanaCnt), label: 'Semana', cls: '' },
{ value: pct !== null ? `${pct}%` : '—', label: 'Presença', cls: pct !== null && pct >= 85 ? 'qs-ok' : '' }, { value: pct !== null ? `${pct}%` : '—', label: 'Presença', cls: pct !== null && pct >= 85 ? 'qs-ok' : '' },
] ]
}) })
const resumoHoje = computed(() => { 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 sols = _solicitacoesBruto.value.length
const parts = [] const parts = []
if (sessoes === 1) parts.push('1 sessão hoje') if (sessoes === 1) parts.push('1 sessão hoje')
@@ -648,11 +958,11 @@ const cadastros = computed(() =>
const cadastrosPendentes = computed(() => cadastros.value.length) const cadastrosPendentes = computed(() => cadastros.value.length)
const recAlerta = computed(() => { 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 = [] const alerts = []
for (const r of regraRecorrencias.value) { for (const r of regraRecorrencias.value) {
const nome = (r._patientNome || '—').split(' ').slice(0, 2).join(' ') 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) { if (r.max_occurrences && r._sessionsCount !== undefined) {
const pct = (r._sessionsCount / r.max_occurrences) * 100 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) }) 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 radarSemana = computed(() => {
const now = agora.value, dow = now.getDay(), iniSem = new Date(now) const diasMap = _statsDoMes.value.diasSemanaMap
iniSem.setDate(now.getDate() - dow); iniSem.setHours(0, 0, 0, 0) const dow = agora.value.getDay()
return DIAS_PT.map((dia, i) => { return DIAS_PT.map((dia, i) => {
const dayDate = new Date(iniSem); dayDate.setDate(iniSem.getDate() + i) const evs = diasMap[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
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 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' let status = 'ok'
if (faltas > 0 && faltas >= presentes) status = 'falta' if (faltas > 0 && faltas >= presentes) status = 'falta'
else if (reposicao > 0 && reposicao > presentes) status = 'repo' 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 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 } function toPercent (h, m) { return ((h + m / 60 - TL_START) / TL_SPAN) * 100 }
const timelineEvents = computed(() => const timelineEvents = computed(() =>
eventosDoMes.value _statsDoMes.value.timelineLista
.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 }) .slice()
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em)) .sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
.map(ev => { .map(ev => {
const item = buildEventoItem(ev) const item = buildEventoItem(ev)
const [hh, mm] = item.hora.split(':').map(Number) const [hh, mm] = item.hora.split(':').map(Number)
const durMin = parseInt(item.dur) || 50 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 if (!ownerId.value) return
await tenantStore.ensureLoaded() await tenantStore.ensureLoaded()
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null const tid = tenantStore.activeTenantId || tenantStore.tenantId || null
await loadCommitments()
const mesInicio = new Date(anoAtual, mesAtual, 1, 0, 0, 0, 0).toISOString() 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() const mesFim = new Date(anoAtual, mesAtual + 1, 0, 23, 59, 59, 999).toISOString()
try { try {
const [eventosRes, recRes, solRes, cadRes] = await Promise.all([ 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 })(), (() => { 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('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), 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); } 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); } 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> </style>