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:
@@ -18,7 +18,9 @@
|
||||
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
|
||||
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
|
||||
"Bash(find:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(ls:*)",
|
||||
"Bash(npx vite:*)",
|
||||
"Bash(powershell -Command \"$content = [System.IO.File]::ReadAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', [System.Text.Encoding]::UTF8\\); $content = $content -replace [char]0x201C, ''\"\"'' -replace [char]0x201D, ''\"\"''; [System.IO.File]::WriteAllText\\(''src/views/pages/clinic/clinic/ClinicFeaturesPage.vue'', $content, [System.Text.Encoding]::UTF8\\)\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict WhNBUHGPb7r3TzvGfUbgAGypOAZRhELU6FHGPvMhYkVWhF2Y5HPG9HrKQluVdLN
|
||||
\restrict exm15ajuo5LlVoZOAon82WdOxbqbyivLILLlrvWu0yn6dCEmYCyZgXRS28Q2h1h
|
||||
|
||||
-- Dumped from database version 17.6
|
||||
-- Dumped by pg_dump version 17.6
|
||||
@@ -2886,6 +2886,105 @@ $$;
|
||||
|
||||
ALTER FUNCTION public.my_tenants() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notify_on_intake(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notify_on_intake() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
BEGIN
|
||||
IF NEW.status = 'new' THEN
|
||||
INSERT INTO public.notifications (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
type,
|
||||
ref_id,
|
||||
ref_table,
|
||||
payload
|
||||
)
|
||||
VALUES (
|
||||
NEW.owner_id,
|
||||
NEW.tenant_id,
|
||||
'new_patient',
|
||||
NEW.id,
|
||||
'patient_intake_requests',
|
||||
jsonb_build_object(
|
||||
'title', 'Novo cadastro externo',
|
||||
'detail', COALESCE(NEW.nome_completo, 'Paciente'),
|
||||
'deeplink', '/therapist/patients/cadastro/recebidos',
|
||||
'avatar_initials', upper(left(COALESCE(NEW.nome_completo, '?'), 2))
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notify_on_intake() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notify_on_scheduling(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notify_on_scheduling() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$ BEGIN IF NEW.status = 'pendente' THEN
|
||||
INSERT INTO public.notifications ( owner_id, tenant_id, type, ref_id, ref_table, payload ) VALUES (
|
||||
NEW.owner_id, NEW.tenant_id,
|
||||
'new_scheduling', NEW.id, 'agendador_solicitacoes', jsonb_build_object( 'title', 'Nova solicitação de agendamento', 'detail', COALESCE(NEW.paciente_nome, 'Paciente') || ' ' || COALESCE(NEW.paciente_sobrenome, '') || ' — ' || COALESCE(NEW.tipo, ''), 'deeplink', '/therapist/agendamentos-recebidos', 'avatar_initials', upper(left(COALESCE(NEW.paciente_nome, '?'), 1) || left(COALESCE(NEW.paciente_sobrenome, ''), 1)) ) ); END IF; RETURN NEW; END; $$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notify_on_scheduling() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notify_on_session_status(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE FUNCTION public.notify_on_session_status() RETURNS trigger
|
||||
LANGUAGE plpgsql SECURITY DEFINER
|
||||
AS $$
|
||||
DECLARE
|
||||
v_nome text;
|
||||
BEGIN
|
||||
IF NEW.status IN ('faltou', 'cancelado') AND OLD.status IS DISTINCT FROM NEW.status THEN
|
||||
-- tenta buscar nome do paciente
|
||||
SELECT nome_completo
|
||||
INTO v_nome
|
||||
FROM public.patients
|
||||
WHERE id = NEW.patient_id
|
||||
LIMIT 1;
|
||||
|
||||
INSERT INTO public.notifications (
|
||||
owner_id,
|
||||
tenant_id,
|
||||
type,
|
||||
ref_id,
|
||||
ref_table,
|
||||
payload
|
||||
)
|
||||
VALUES (
|
||||
NEW.owner_id,
|
||||
NEW.tenant_id,
|
||||
'session_status',
|
||||
NEW.id,
|
||||
'agenda_eventos',
|
||||
jsonb_build_object(
|
||||
'title', CASE WHEN NEW.status = 'faltou' THEN 'Paciente faltou' ELSE 'Sessão cancelada' END,
|
||||
'detail', COALESCE(v_nome, 'Paciente') || ' — ' || to_char(NEW.starts_at, 'DD/MM HH24:MI'),
|
||||
'deeplink', '/therapist/agenda',
|
||||
'avatar_initials', upper(left(COALESCE(v_nome, '?'), 2))
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
|
||||
ALTER FUNCTION public.notify_on_session_status() OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: on_new_user_seed_patient_groups(); Type: FUNCTION; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -7832,6 +7931,24 @@ CREATE TABLE public.insurance_plans (
|
||||
|
||||
ALTER TABLE public.insurance_plans OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: login_carousel_slides; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.login_carousel_slides (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
title text NOT NULL,
|
||||
body text NOT NULL,
|
||||
icon text DEFAULT 'pi-star'::text NOT NULL,
|
||||
ordem integer DEFAULT 0 NOT NULL,
|
||||
ativo boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now()
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.login_carousel_slides OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: module_features; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -7863,6 +7980,27 @@ CREATE TABLE public.modules (
|
||||
|
||||
ALTER TABLE public.modules OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: notifications; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TABLE public.notifications (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid,
|
||||
type text NOT NULL,
|
||||
ref_id uuid,
|
||||
ref_table text,
|
||||
payload jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
read_at timestamp with time zone,
|
||||
archived boolean DEFAULT false NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT notifications_type_check CHECK ((type = ANY (ARRAY['new_scheduling'::text, 'new_patient'::text, 'recurrence_alert'::text, 'session_status'::text])))
|
||||
);
|
||||
|
||||
|
||||
ALTER TABLE public.notifications OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: plan_features; Type: TABLE; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -10497,6 +10635,14 @@ ALTER TABLE ONLY public.insurance_plans
|
||||
ADD CONSTRAINT insurance_plans_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: login_carousel_slides login_carousel_slides_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.login_carousel_slides
|
||||
ADD CONSTRAINT login_carousel_slides_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: module_features module_features_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -10521,6 +10667,14 @@ ALTER TABLE ONLY public.modules
|
||||
ADD CONSTRAINT modules_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: notifications notifications_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.notifications
|
||||
ADD CONSTRAINT notifications_pkey PRIMARY KEY (id);
|
||||
|
||||
|
||||
--
|
||||
-- Name: owner_users owner_users_pkey; Type: CONSTRAINT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -12042,6 +12196,20 @@ CREATE INDEX ix_plan_public_bullets_plan ON public.plan_public_bullets USING btr
|
||||
CREATE INDEX ix_plan_public_sort ON public.plan_public USING btree (sort_order);
|
||||
|
||||
|
||||
--
|
||||
-- Name: notifications_owner_created; Type: INDEX; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE INDEX notifications_owner_created ON public.notifications USING btree (owner_id, created_at DESC);
|
||||
|
||||
|
||||
--
|
||||
-- Name: notifications_owner_unread; Type: INDEX; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE INDEX notifications_owner_unread ON public.notifications USING btree (owner_id, read_at) WHERE (read_at IS NULL);
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_discounts_owner_idx; Type: INDEX; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -13050,6 +13218,27 @@ CREATE TRIGGER trg_no_change_plan_target BEFORE UPDATE ON public.plans FOR EACH
|
||||
CREATE TRIGGER trg_no_delete_core_plans BEFORE DELETE ON public.plans FOR EACH ROW EXECUTE FUNCTION public.guard_no_delete_core_plans();
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_intake_requests trg_notify_on_intake; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notify_on_intake AFTER INSERT ON public.patient_intake_requests FOR EACH ROW EXECUTE FUNCTION public.notify_on_intake();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agendador_solicitacoes trg_notify_on_scheduling; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notify_on_scheduling AFTER INSERT ON public.agendador_solicitacoes FOR EACH ROW EXECUTE FUNCTION public.notify_on_scheduling();
|
||||
|
||||
|
||||
--
|
||||
-- Name: agenda_eventos trg_notify_on_session_status; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE TRIGGER trg_notify_on_session_status AFTER UPDATE OF status ON public.agenda_eventos FOR EACH ROW EXECUTE FUNCTION public.notify_on_session_status();
|
||||
|
||||
|
||||
--
|
||||
-- Name: tenant_members trg_patient_cannot_own_tenant; Type: TRIGGER; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -13643,6 +13832,14 @@ ALTER TABLE ONLY public.module_features
|
||||
ADD CONSTRAINT module_features_module_id_fkey FOREIGN KEY (module_id) REFERENCES public.modules(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: notifications notifications_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER TABLE ONLY public.notifications
|
||||
ADD CONSTRAINT notifications_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
|
||||
--
|
||||
-- Name: patient_discounts patient_discounts_owner_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -14960,6 +15157,12 @@ ALTER TABLE public.insurance_plans ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "insurance_plans: owner full access" ON public.insurance_plans USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
|
||||
|
||||
|
||||
--
|
||||
-- Name: login_carousel_slides; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER TABLE public.login_carousel_slides ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
--
|
||||
-- Name: module_features; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -15000,6 +15203,19 @@ CREATE POLICY modules_read_authenticated ON public.modules FOR SELECT TO authent
|
||||
CREATE POLICY modules_write_saas_admin ON public.modules TO authenticated USING (public.is_saas_admin()) WITH CHECK (public.is_saas_admin());
|
||||
|
||||
|
||||
--
|
||||
-- Name: notifications; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
--
|
||||
-- Name: notifications owner only; Type: POLICY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE POLICY "owner only" ON public.notifications USING ((owner_id = auth.uid())) WITH CHECK ((owner_id = auth.uid()));
|
||||
|
||||
|
||||
--
|
||||
-- Name: owner_users; Type: ROW SECURITY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -15333,6 +15549,13 @@ CREATE POLICY profiles_select_own ON public.profiles FOR SELECT USING ((id = aut
|
||||
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE USING ((id = auth.uid())) WITH CHECK ((id = auth.uid()));
|
||||
|
||||
|
||||
--
|
||||
-- Name: login_carousel_slides public_read; Type: POLICY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE POLICY public_read ON public.login_carousel_slides FOR SELECT USING ((ativo = true));
|
||||
|
||||
|
||||
--
|
||||
-- Name: features read features (auth); Type: POLICY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -15455,6 +15678,15 @@ CREATE POLICY "saas_admin can update subscription_intents" ON public.subscriptio
|
||||
WHERE (a.user_id = auth.uid()))));
|
||||
|
||||
|
||||
--
|
||||
-- Name: login_carousel_slides saas_admin_full; Type: POLICY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE POLICY saas_admin_full ON public.login_carousel_slides USING ((EXISTS ( SELECT 1
|
||||
FROM public.profiles
|
||||
WHERE ((profiles.id = auth.uid()) AND (profiles.role = 'saas_admin'::text)))));
|
||||
|
||||
|
||||
--
|
||||
-- Name: saas_docs saas_admin_full_access; Type: POLICY; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -15998,6 +16230,29 @@ CREATE PUBLICATION supabase_realtime WITH (publish = 'insert, update, delete, tr
|
||||
|
||||
ALTER PUBLICATION supabase_realtime OWNER TO postgres;
|
||||
|
||||
--
|
||||
-- Name: supabase_realtime_messages_publication; Type: PUBLICATION; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
|
||||
CREATE PUBLICATION supabase_realtime_messages_publication WITH (publish = 'insert, update, delete, truncate');
|
||||
|
||||
|
||||
ALTER PUBLICATION supabase_realtime_messages_publication OWNER TO supabase_admin;
|
||||
|
||||
--
|
||||
-- Name: supabase_realtime notifications; Type: PUBLICATION TABLE; Schema: public; Owner: postgres
|
||||
--
|
||||
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE ONLY public.notifications;
|
||||
|
||||
|
||||
--
|
||||
-- Name: supabase_realtime_messages_publication messages; Type: PUBLICATION TABLE; Schema: realtime; Owner: supabase_admin
|
||||
--
|
||||
|
||||
ALTER PUBLICATION supabase_realtime_messages_publication ADD TABLE ONLY realtime.messages;
|
||||
|
||||
|
||||
--
|
||||
-- Name: SCHEMA auth; Type: ACL; Schema: -; Owner: supabase_admin
|
||||
--
|
||||
@@ -19290,6 +19545,36 @@ GRANT ALL ON FUNCTION public.my_tenants() TO authenticated;
|
||||
GRANT ALL ON FUNCTION public.my_tenants() TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: FUNCTION notify_on_intake(); Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
GRANT ALL ON FUNCTION public.notify_on_intake() TO postgres;
|
||||
GRANT ALL ON FUNCTION public.notify_on_intake() TO anon;
|
||||
GRANT ALL ON FUNCTION public.notify_on_intake() TO authenticated;
|
||||
GRANT ALL ON FUNCTION public.notify_on_intake() TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: FUNCTION notify_on_scheduling(); Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO postgres;
|
||||
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO anon;
|
||||
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO authenticated;
|
||||
GRANT ALL ON FUNCTION public.notify_on_scheduling() TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: FUNCTION notify_on_session_status(); Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
GRANT ALL ON FUNCTION public.notify_on_session_status() TO postgres;
|
||||
GRANT ALL ON FUNCTION public.notify_on_session_status() TO anon;
|
||||
GRANT ALL ON FUNCTION public.notify_on_session_status() TO authenticated;
|
||||
GRANT ALL ON FUNCTION public.notify_on_session_status() TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: FUNCTION oid_dist(oid, oid); Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -20773,6 +21058,16 @@ GRANT ALL ON TABLE public.insurance_plans TO authenticated;
|
||||
GRANT ALL ON TABLE public.insurance_plans TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE login_carousel_slides; Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.login_carousel_slides TO postgres;
|
||||
GRANT ALL ON TABLE public.login_carousel_slides TO anon;
|
||||
GRANT ALL ON TABLE public.login_carousel_slides TO authenticated;
|
||||
GRANT ALL ON TABLE public.login_carousel_slides TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE module_features; Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -20793,6 +21088,16 @@ GRANT ALL ON TABLE public.modules TO authenticated;
|
||||
GRANT ALL ON TABLE public.modules TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE notifications; Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
|
||||
GRANT ALL ON TABLE public.notifications TO postgres;
|
||||
GRANT ALL ON TABLE public.notifications TO anon;
|
||||
GRANT ALL ON TABLE public.notifications TO authenticated;
|
||||
GRANT ALL ON TABLE public.notifications TO service_role;
|
||||
|
||||
|
||||
--
|
||||
-- Name: TABLE plan_features; Type: ACL; Schema: public; Owner: supabase_admin
|
||||
--
|
||||
@@ -21929,5 +22234,5 @@ ALTER EVENT TRIGGER pgrst_drop_watch OWNER TO supabase_admin;
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict WhNBUHGPb7r3TzvGfUbgAGypOAZRhELU6FHGPvMhYkVWhF2Y5HPG9HrKQluVdLN
|
||||
\unrestrict exm15ajuo5LlVoZOAon82WdOxbqbyivLILLlrvWu0yn6dCEmYCyZgXRS28Q2h1h
|
||||
|
||||
|
||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/list": "^6.1.20",
|
||||
"@fullcalendar/resource": "^6.1.20",
|
||||
"@fullcalendar/resource-timegrid": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
@@ -24,6 +25,7 @@
|
||||
"primevue": "^4.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"tailwindcss-primeui": "^0.6.0",
|
||||
"v-offline": "^3.5.1",
|
||||
"vue": "^3.4.34",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
@@ -596,6 +598,14 @@
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/list": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz",
|
||||
"integrity": "sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==",
|
||||
"peerDependencies": {
|
||||
"@fullcalendar/core": "~6.1.20"
|
||||
}
|
||||
},
|
||||
"node_modules/@fullcalendar/premium-common": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
|
||||
@@ -3945,6 +3955,11 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/ping.js": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ping.js/-/ping.js-0.3.0.tgz",
|
||||
"integrity": "sha512-qisFwio7j0cwYbOcRL4BlTdxKALcpGPTkpl8ichGASgkrVqfI3sZfQDsP8wETR5rfutXZJLjlJ117aLkRnk2mA=="
|
||||
},
|
||||
"node_modules/pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
@@ -4746,6 +4761,18 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/v-offline": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/v-offline/-/v-offline-3.5.1.tgz",
|
||||
"integrity": "sha512-i9ydsGk9oJfMivtGI85U3m/6Bkqg07DkebbKV21t++OiwEKzArQs9GqqDETD972nNGz5q7+cr1fm6B2A1uht9A==",
|
||||
"dependencies": {
|
||||
"ping.js": "^0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ping.js": "^0.3.0",
|
||||
"vue": "^3.5.27"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
@@ -5826,6 +5853,12 @@
|
||||
"integrity": "sha512-p6txmc5txL0bMiPaJxe2ip6o0T384TyoD2KGdsU6UjZ5yoBlaY+dg7kxfnYKpYMzEJLG58n+URrHr2PgNL2fyA==",
|
||||
"requires": {}
|
||||
},
|
||||
"@fullcalendar/list": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/list/-/list-6.1.20.tgz",
|
||||
"integrity": "sha512-7Hzkbb7uuSqrXwTyD0Ld/7SwWNxPD6SlU548vtkIpH55rZ4qquwtwYdMPgorHos5OynHA4OUrZNcH51CjrCf2g==",
|
||||
"requires": {}
|
||||
},
|
||||
"@fullcalendar/premium-common": {
|
||||
"version": "6.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@fullcalendar/premium-common/-/premium-common-6.1.20.tgz",
|
||||
@@ -7991,6 +8024,11 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true
|
||||
},
|
||||
"ping.js": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ping.js/-/ping.js-0.3.0.tgz",
|
||||
"integrity": "sha512-qisFwio7j0cwYbOcRL4BlTdxKALcpGPTkpl8ichGASgkrVqfI3sZfQDsP8wETR5rfutXZJLjlJ117aLkRnk2mA=="
|
||||
},
|
||||
"pinia": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
|
||||
@@ -8521,6 +8559,14 @@
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"dev": true
|
||||
},
|
||||
"v-offline": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/v-offline/-/v-offline-3.5.1.tgz",
|
||||
"integrity": "sha512-i9ydsGk9oJfMivtGI85U3m/6Bkqg07DkebbKV21t++OiwEKzArQs9GqqDETD972nNGz5q7+cr1fm6B2A1uht9A==",
|
||||
"requires": {
|
||||
"ping.js": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@fullcalendar/core": "^6.1.20",
|
||||
"@fullcalendar/daygrid": "^6.1.20",
|
||||
"@fullcalendar/interaction": "^6.1.20",
|
||||
"@fullcalendar/list": "^6.1.20",
|
||||
"@fullcalendar/resource": "^6.1.20",
|
||||
"@fullcalendar/resource-timegrid": "^6.1.20",
|
||||
"@fullcalendar/timegrid": "^6.1.20",
|
||||
@@ -29,6 +30,7 @@
|
||||
"primevue": "^4.5.4",
|
||||
"quill": "^2.0.3",
|
||||
"tailwindcss-primeui": "^0.6.0",
|
||||
"v-offline": "^3.5.1",
|
||||
"vue": "^3.4.34",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
|
||||
@@ -4,9 +4,11 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
||||
import { fetchDocsForPath } from '@/composables/useAjuda'
|
||||
|
||||
import AjudaDrawer from '@/components/AjudaDrawer.vue'
|
||||
import AppOfflineOverlay from '@/components/AppOfflineOverlay.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tenantStore = useTenantStore()
|
||||
@@ -177,4 +179,7 @@ watch(
|
||||
<Teleport to="body">
|
||||
<AjudaDrawer />
|
||||
</Teleport>
|
||||
|
||||
<!-- Overlay de sem conexão -->
|
||||
<AppOfflineOverlay />
|
||||
</template>
|
||||
@@ -1,3 +1,31 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
/* ── Imports ─────────────────────────── */
|
||||
@use 'primeicons/primeicons.css';
|
||||
@use '@/assets/layout/layout.scss';
|
||||
|
||||
/* ── Design Tokens (Tailwind override) ─ */
|
||||
:root {
|
||||
--text-xs: 0.8rem;
|
||||
}
|
||||
|
||||
/* ── Dark mode (opcional) ───────────── */
|
||||
.app-dark {
|
||||
--text-xs: 0.82rem;
|
||||
}
|
||||
|
||||
/* ── Responsivo (opcional) ─────────── */
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--text-xs: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Highlight pulse (acionado externamente via classe JS) */
|
||||
@keyframes highlight-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(99,102,241,0.7), 0 0 0 0 rgba(99,102,241,0.4); }
|
||||
40% { box-shadow: 0 0 0 8px rgba(99,102,241,0.3), 0 0 0 16px rgba(99,102,241,0.1); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(99,102,241,0), 0 0 0 0 rgba(99,102,241,0); }
|
||||
}
|
||||
.notif-card--highlight {
|
||||
animation: highlight-pulse 1s ease-out 3;
|
||||
border-color: rgba(99,102,241,0.6) !important;
|
||||
}
|
||||
245
src/components/AppOfflineOverlay.vue
Normal file
245
src/components/AppOfflineOverlay.vue
Normal 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>
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-surface-500">
|
||||
Dica: “Gerar usuário” preenche automaticamente com dados fictícios.
|
||||
Dica: "Gerar usuário" preenche automaticamente com dados fictícios.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +136,7 @@ import { supabase } from '@/lib/supabase/client'
|
||||
const { canSee } = useRoleGuard()
|
||||
|
||||
/**
|
||||
* Lista “curada” de pensadores influentes na psicanálise e seu entorno.
|
||||
* Lista "curada" de pensadores influentes na psicanálise e seu entorno.
|
||||
* Usada para geração rápida de dados fictícios.
|
||||
*/
|
||||
const PSICANALISE_PENSADORES = Object.freeze([
|
||||
|
||||
@@ -134,7 +134,7 @@ onMounted(load)
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Resumo tipo “cards” -->
|
||||
<!-- Resumo tipo "cards" -->
|
||||
<div class="grid grid-cols-12 gap-3 mb-4">
|
||||
<div class="col-span-12 md:col-span-4 p-3 rounded-xl border border-[var(--surface-border)]">
|
||||
<div class="text-600 text-sm">Tipo de slots</div>
|
||||
@@ -158,7 +158,7 @@ onMounted(load)
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-900 font-medium">Jornada do dia</div>
|
||||
<div class="text-600 text-sm">
|
||||
(Isso vem das suas “janelas semanais”)
|
||||
(Isso vem das suas "janelas semanais")
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ function normalizeIntervals(list) {
|
||||
return merged
|
||||
}
|
||||
|
||||
// retorna “sobras” de [s,e] depois de remover intervalos ocupados
|
||||
// retorna "sobras" de [s,e] depois de remover intervalos ocupados
|
||||
function subtractIntervals(s, e, occupiedMerged) {
|
||||
let segments = [{ s, e }]
|
||||
for (const occ of occupiedMerged) {
|
||||
@@ -127,7 +127,7 @@ function addPauseSmart({ label, inicio, fim }) {
|
||||
fim: minToHHMM(seg.e)
|
||||
}))
|
||||
|
||||
// se houve “recorte”, avisa
|
||||
// se houve "recorte", avisa
|
||||
if (segments.length !== 1 || (segments[0].s !== s || segments[0].e !== e)) {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
<div class="text-gray-900 mb-2 text-3xl font-semibold">Joséphine Miller</div>
|
||||
<span class="text-gray-600 text-2xl">Peak Interactive</span>
|
||||
<p class="text-gray-900 sm:line-height-2 md:line-height-4 text-2xl mt-6" style="max-width: 800px">
|
||||
“Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.”
|
||||
"Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum."
|
||||
</p>
|
||||
<img src="/demo/images/landing/peak-logo.svg" class="mt-6" alt="Company logo" />
|
||||
</div>
|
||||
|
||||
@@ -103,7 +103,7 @@ function goToHistory () {
|
||||
<!-- Empty state -->
|
||||
<div v-else class="notification-drawer__empty">
|
||||
<i class="pi pi-bell-slash notification-drawer__empty-icon" />
|
||||
<p class="notification-drawer__empty-text">Tudo em dia</p>
|
||||
<p class="notification-drawer__empty-text">Tudo em dia por aqui 🎉</p>
|
||||
<p class="notification-drawer__empty-sub">Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ function goToHistory () {
|
||||
<div class="notification-drawer__footer">
|
||||
<button class="notification-drawer__history-link" @click="goToHistory">
|
||||
<i class="pi pi-history" />
|
||||
Ver histórico completo
|
||||
Ver histórico completo →
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -4,41 +4,38 @@ import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { ptBR } from 'date-fns/locale'
|
||||
import { useNotificationStore } from '@/stores/notificationStore'
|
||||
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
item: { type: Object, required: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['read', 'archive'])
|
||||
|
||||
const router = useRouter()
|
||||
const store = useNotificationStore()
|
||||
|
||||
const typeIconMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', color: 'text-red-500' },
|
||||
new_patient: { icon: 'pi-user-plus', color: 'text-sky-500' },
|
||||
recurrence_alert: { icon: 'pi-refresh', color: 'text-amber-500' },
|
||||
session_status: { icon: 'pi-calendar-times', color: 'text-orange-500' }
|
||||
const typeMap = {
|
||||
new_scheduling: { icon: 'pi-inbox', border: 'border-red-500', },
|
||||
new_patient: { icon: 'pi-user-plus', border: 'border-sky-500', },
|
||||
recurrence_alert: { icon: 'pi-refresh', border: 'border-amber-500', },
|
||||
session_status: { icon: 'pi-calendar-times', border: 'border-orange-500', },
|
||||
}
|
||||
|
||||
const typeIcon = computed(() => typeIconMap[props.item.type] || { icon: 'pi-bell', color: 'text-gray-400' })
|
||||
|
||||
const meta = computed(() => typeMap[props.item.type] || { icon: 'pi-bell', border: 'border-gray-300' })
|
||||
const isUnread = computed(() => !props.item.read_at)
|
||||
|
||||
const timeAgo = computed(() =>
|
||||
formatDistanceToNow(new Date(props.item.created_at), { addSuffix: true, locale: ptBR })
|
||||
)
|
||||
|
||||
const avatarInitials = computed(() =>
|
||||
props.item.payload?.avatar_initials || '??'
|
||||
)
|
||||
const initials = computed(() => props.item.payload?.avatar_initials || '?')
|
||||
|
||||
function handleRowClick () {
|
||||
const deeplink = props.item.payload?.deeplink
|
||||
if (deeplink) {
|
||||
router.push(deeplink)
|
||||
store.drawerOpen = false
|
||||
emit('read', props.item.id)
|
||||
}
|
||||
}
|
||||
@@ -56,45 +53,42 @@ function handleArchive (e) {
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="notification-item"
|
||||
:class="{ 'notification-item--unread': isUnread }"
|
||||
@click="handleRowClick"
|
||||
class="notif-item"
|
||||
:class="[meta.border, isUnread ? 'notif-item--unread' : '']"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleRowClick"
|
||||
@keydown.enter="handleRowClick"
|
||||
>
|
||||
<!-- Dot indicador -->
|
||||
<span v-if="isUnread" class="notification-item__dot" aria-hidden="true" />
|
||||
|
||||
<!-- Ícone de tipo -->
|
||||
<span class="notification-item__type-icon" aria-hidden="true">
|
||||
<i :class="['pi', typeIcon.icon, typeIcon.color]" />
|
||||
</span>
|
||||
<!-- Ícone do tipo -->
|
||||
<div class="notif-item__icon" aria-hidden="true">
|
||||
<i :class="['pi', meta.icon]" />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<span class="notification-item__avatar" aria-hidden="true">
|
||||
{{ avatarInitials }}
|
||||
</span>
|
||||
<div class="notif-item__avatar" aria-hidden="true">
|
||||
{{ initials }}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="notification-item__content">
|
||||
<p class="notification-item__title">{{ item.payload?.title }}</p>
|
||||
<p class="notification-item__detail">{{ item.payload?.detail }}</p>
|
||||
<p class="notification-item__time">{{ timeAgo }}</p>
|
||||
<div class="notif-item__body">
|
||||
<p class="notif-item__title">{{ item.payload?.title }}</p>
|
||||
<p class="notif-item__detail">{{ item.payload?.detail }}</p>
|
||||
<p class="notif-item__time">{{ timeAgo }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="notification-item__actions" @click.stop>
|
||||
<div class="notif-item__actions" @click.stop>
|
||||
<button
|
||||
v-if="isUnread"
|
||||
class="notification-item__action-btn"
|
||||
class="notif-item__btn"
|
||||
title="Marcar como lida"
|
||||
@click="handleMarkRead"
|
||||
>
|
||||
<i class="pi pi-check" />
|
||||
</button>
|
||||
<button
|
||||
class="notification-item__action-btn"
|
||||
class="notif-item__btn"
|
||||
title="Arquivar"
|
||||
@click="handleArchive"
|
||||
>
|
||||
@@ -105,101 +99,92 @@ function handleArchive (e) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-item {
|
||||
position: relative;
|
||||
.notif-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.625rem;
|
||||
padding: 0.75rem 1rem;
|
||||
cursor: pointer;
|
||||
border-left-width: 3px;
|
||||
border-left-style: solid;
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.notification-item:hover {
|
||||
.notif-item:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
.notification-item--unread {
|
||||
background: color-mix(in srgb, var(--primary-color) 6%, transparent);
|
||||
.notif-item--unread {
|
||||
background: rgba(99, 102, 241, 0.05);
|
||||
}
|
||||
.notification-item--unread:hover {
|
||||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||||
.notif-item--unread:hover {
|
||||
background: rgba(99, 102, 241, 0.09);
|
||||
}
|
||||
|
||||
.notification-item__dot {
|
||||
position: absolute;
|
||||
left: 0.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #6366f1;
|
||||
.notif-item__icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-item__type-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-top: 0.125rem;
|
||||
flex-shrink: 0;
|
||||
font-size: 0.85rem;
|
||||
padding-top: 0.15rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.notification-item__avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.notif-item__avatar {
|
||||
flex-shrink: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--surface-200);
|
||||
color: var(--text-color);
|
||||
font-size: 0.7rem;
|
||||
background: linear-gradient(135deg, #6366f1, #38bdf8);
|
||||
color: #fff;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
letter-spacing: 0.05em;
|
||||
letter-spacing: 0.04em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.notification-item__content {
|
||||
.notif-item__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notification-item__title {
|
||||
.notif-item__title {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
margin: 0 0 0.125rem;
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notification-item__detail {
|
||||
font-size: 0.8rem;
|
||||
.notif-item__detail {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0 0 0.125rem;
|
||||
margin: 0 0 0.1rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.notification-item__time {
|
||||
font-size: 0.72rem;
|
||||
.notif-item__time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.notification-item__actions {
|
||||
.notif-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.notification-item:hover .notification-item__actions {
|
||||
.notif-item:hover .notif-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.notification-item__action-btn {
|
||||
.notif-item__btn {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 50%;
|
||||
@@ -212,7 +197,7 @@ function handleArchive (e) {
|
||||
font-size: 0.75rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.notification-item__action-btn:hover {
|
||||
.notif-item__btn:hover {
|
||||
background: var(--surface-border);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
219
src/components/patients/PatientActionMenu.vue
Normal file
219
src/components/patients/PatientActionMenu.vue
Normal 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>
|
||||
85
src/composables/useMenuBadges.js
Normal file
85
src/composables/useMenuBadges.js
Normal 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
|
||||
}
|
||||
}
|
||||
74
src/composables/usePatientLifecycle.js
Normal file
74
src/composables/usePatientLifecycle.js
Normal 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),
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,7 @@ const calendarOptions = computed(() => {
|
||||
// Header desativado (você controla no Toolbar)
|
||||
headerToolbar: false,
|
||||
|
||||
// Visão “produto”: blocos com linhas suaves
|
||||
// Visão "produto": blocos com linhas suaves
|
||||
nowIndicator: true,
|
||||
allDaySlot: false,
|
||||
expandRows: true,
|
||||
@@ -93,7 +93,7 @@ const calendarOptions = computed(() => {
|
||||
hour12: false
|
||||
},
|
||||
|
||||
// Horário “verdadeiro” de funcionamento (se você usar)
|
||||
// Horário "verdadeiro" de funcionamento (se você usar)
|
||||
businessHours: props.businessHours,
|
||||
|
||||
// Dados
|
||||
@@ -183,7 +183,7 @@ onMounted(() => {
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Deixa o calendário “respirar” dentro de cards/layouts */
|
||||
/* Deixa o calendário "respirar" dentro de cards/layouts */
|
||||
:deep(.fc){
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
@@ -287,6 +287,7 @@ function buildFcOptions (ownerId) {
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
const pacienteStatus = ext.paciente_status || ''
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
@@ -307,6 +308,9 @@ function buildFcOptions (ownerId) {
|
||||
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
const statusBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
|
||||
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
||||
: ''
|
||||
|
||||
return {
|
||||
html: `<div class="ev-custom">
|
||||
@@ -314,10 +318,25 @@ function buildFcOptions (ownerId) {
|
||||
<div class="ev-body">
|
||||
${timeHtml}
|
||||
<div class="ev-title">${esc(title)}</div>
|
||||
${statusBadge}
|
||||
${obsHtml}
|
||||
</div>
|
||||
</div>`
|
||||
}
|
||||
},
|
||||
|
||||
eventClassNames: (arg) => {
|
||||
const classes = []
|
||||
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
|
||||
return classes
|
||||
},
|
||||
|
||||
eventDidMount: (info) => {
|
||||
const bgColor = info.event.extendedProps?.commitment_bg_color
|
||||
if (bgColor) {
|
||||
info.el.style.setProperty('background-color', bgColor, 'important')
|
||||
info.el.style.setProperty('border-color', bgColor, 'important')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,21 @@
|
||||
Este dia é folga na sua jornada. Você ainda pode salvar se necessário.
|
||||
</Message>
|
||||
|
||||
<!-- ── Restrições de status do paciente ───────────── -->
|
||||
<Message v-if="isArchivedPastEdit" severity="warn" class="mb-3" :closable="false">
|
||||
<i class="pi pi-lock mr-1" />
|
||||
<b>Paciente arquivado.</b> O histórico de sessões é somente leitura.
|
||||
</Message>
|
||||
<Message v-if="isEdit && form.paciente_status === 'Inativo' && isSessionFuture" severity="warn" class="mb-3" :closable="false">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>Paciente inativo.</b> Remarcação de sessões está bloqueada.
|
||||
</Message>
|
||||
<Message v-if="!isEdit && isSessionEvent && form.paciente_id && !agendaPerms.canCreateSession" severity="error" class="mb-3" :closable="false">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
|
||||
Novos agendamentos estão bloqueados.
|
||||
</Message>
|
||||
|
||||
<!-- ── Alerta: solicitação pendente neste horário ─── -->
|
||||
<Message v-if="solicitacaoPendente && isSessionEvent && !isEdit" severity="info" class="mb-3" :closable="false">
|
||||
<div class="flex items-center justify-between gap-3 w-full flex-wrap">
|
||||
@@ -182,7 +197,13 @@
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-bold text-base truncate">{{ form.paciente_nome }}</div>
|
||||
<div class="text-xs text-color-secondary">Paciente vinculado</div>
|
||||
<div class="flex items-center gap-1.5 flex-wrap">
|
||||
<span class="text-xs text-color-secondary">Paciente vinculado</span>
|
||||
<span
|
||||
v-if="form.paciente_status === 'Inativo' || form.paciente_status === 'Arquivado'"
|
||||
style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;"
|
||||
>{{ form.paciente_status === 'Arquivado' ? 'arquivado' : 'desativado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<Button v-if="!patientLocked" icon="pi pi-pencil" severity="secondary" outlined size="small" class="rounded-full h-8 w-8" v-tooltip.top="'Trocar'" @click="openPacientePicker" />
|
||||
@@ -265,10 +286,12 @@
|
||||
<div class="field-card__body">
|
||||
<SelectButton
|
||||
v-model="form.status"
|
||||
:options="statusOptions"
|
||||
:options="statusOptionsFiltered"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
optionDisabled="disabled"
|
||||
:allowEmpty="false"
|
||||
:disabled="isArchivedPastEdit"
|
||||
class="w-full status-select-btn"
|
||||
/>
|
||||
</div>
|
||||
@@ -684,6 +707,7 @@
|
||||
variant="filled"
|
||||
rows="3"
|
||||
autoResize
|
||||
:disabled="isArchivedPastEdit"
|
||||
/>
|
||||
<label for="aed-observacoes-side">Observação</label>
|
||||
</FloatLabel>
|
||||
@@ -692,6 +716,11 @@
|
||||
<!-- Opção de recorrência para sessão SEM série (criação ou avulsa) -->
|
||||
<template v-if="!hasSerie">
|
||||
<div class="side-card__title mb-2">Frequência</div>
|
||||
<Message v-if="isSessionEvent && form.paciente_id && !agendaPerms.canCreateRecurrence" severity="warn" class="mb-3" :closable="false">
|
||||
<i class="pi pi-ban mr-1" />
|
||||
<b>{{ form.paciente_status === 'Arquivado' ? 'Paciente arquivado.' : 'Paciente inativo.' }}</b>
|
||||
Criação de recorrências está bloqueada.
|
||||
</Message>
|
||||
|
||||
<!-- Data de início (= form.dia) com botão Hoje -->
|
||||
<div class="rec-startdate-row mb-3">
|
||||
@@ -708,8 +737,12 @@
|
||||
v-for="f in freqOpcoes"
|
||||
:key="f.value"
|
||||
class="freq-chip"
|
||||
:class="{ 'freq-chip--active': recorrenciaType === f.value }"
|
||||
@click="recorrenciaType = f.value"
|
||||
:class="{
|
||||
'freq-chip--active': recorrenciaType === f.value,
|
||||
'opacity-40 cursor-not-allowed': f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence
|
||||
}"
|
||||
:disabled="f.value !== 'avulsa' && !agendaPerms.canCreateRecurrence"
|
||||
@click="(!agendaPerms.canCreateRecurrence && f.value !== 'avulsa') ? null : (recorrenciaType = f.value)"
|
||||
>{{ f.label }}</button>
|
||||
</div>
|
||||
|
||||
@@ -1101,6 +1134,7 @@ import { useServices } from '@/features/agenda/composables/useServices'
|
||||
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
|
||||
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
|
||||
import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans'
|
||||
import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle'
|
||||
|
||||
function patientInitials (nome) {
|
||||
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
|
||||
@@ -1663,7 +1697,8 @@ const patients = ref([])
|
||||
|
||||
const filteredPatients = computed(() => {
|
||||
const q = String(pacienteSearch.value || '').trim().toLowerCase()
|
||||
const list = patients.value || []
|
||||
// Somente pacientes Ativos podem ser selecionados para novos agendamentos
|
||||
const list = (patients.value || []).filter(p => p.status === 'Ativo')
|
||||
if (!q) return list
|
||||
return list.filter(p => {
|
||||
const nome = String(p.nome || '').toLowerCase()
|
||||
@@ -2137,6 +2172,39 @@ const pillDeleteMenuItems = computed(() => {
|
||||
})
|
||||
|
||||
function isPast (iso) { return iso ? new Date(iso) < new Date() : false }
|
||||
|
||||
// ── Permissões de agenda por status do paciente ───────────────────────────
|
||||
const agendaPerms = computed(() => getPatientAgendaPermissions(form.value.paciente_status || ''))
|
||||
|
||||
// Sessão atual é futura? (para edição: usa inicio_em do evento original)
|
||||
const isSessionFuture = computed(() => {
|
||||
if (!isEdit.value) return true
|
||||
const iso = props.eventRow?.inicio_em
|
||||
return iso ? new Date(iso) > new Date() : true
|
||||
})
|
||||
|
||||
// Arquivado editando sessão passada → somente leitura
|
||||
const isArchivedPastEdit = computed(() =>
|
||||
isEdit.value &&
|
||||
form.value.paciente_status === 'Arquivado' &&
|
||||
!isSessionFuture.value
|
||||
)
|
||||
|
||||
// Inativo editando sessão futura → remarcar bloqueado
|
||||
const isInativoFutureEdit = computed(() =>
|
||||
isEdit.value &&
|
||||
form.value.paciente_status === 'Inativo' &&
|
||||
isSessionFuture.value
|
||||
)
|
||||
|
||||
// StatusOptions com remarcar desabilitado para Inativo
|
||||
const statusOptionsFiltered = computed(() => [
|
||||
{ label: 'Agendado', value: 'agendado' },
|
||||
{ label: 'Realizado', value: 'realizado' },
|
||||
{ label: 'Faltou', value: 'faltou' },
|
||||
{ label: 'Cancelado', value: 'cancelado' },
|
||||
{ label: 'Remarcar', value: 'remarcar', disabled: isInativoFutureEdit.value },
|
||||
])
|
||||
function fmtWeekdayShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3) }
|
||||
function fmtDayNum (iso) { return new Date(iso).getDate() }
|
||||
function fmtMonthShort (iso) { return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', '') }
|
||||
@@ -2411,6 +2479,18 @@ const canSave = computed(() => {
|
||||
if (!form.value.commitment_id) return false
|
||||
if (requiresPatient.value && !form.value.paciente_id) return false
|
||||
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false
|
||||
|
||||
// ── Restrições por status do paciente ────────────────────
|
||||
if (isSessionEvent.value && form.value.paciente_status) {
|
||||
const perms = agendaPerms.value
|
||||
// Criar sessão avulsa ou com recorrência: bloqueado para Inativo/Arquivado
|
||||
if (!isEdit.value && !perms.canCreateSession) return false
|
||||
// Criar recorrência: bloqueado para Inativo/Arquivado
|
||||
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false
|
||||
// Arquivado tentando salvar sessão passada: bloqueado
|
||||
if (isArchivedPastEdit.value) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -2618,6 +2698,7 @@ function resetForm () {
|
||||
paciente_id: r?.paciente_id ?? null,
|
||||
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
|
||||
paciente_avatar: r?.paciente_avatar ?? '',
|
||||
paciente_status: r?.paciente_status ?? '',
|
||||
commitment_id: r?.determined_commitment_id ?? null,
|
||||
titulo_custom: r?.titulo_custom || '',
|
||||
status: r?.status || 'agendado',
|
||||
|
||||
@@ -93,7 +93,7 @@ const emit = defineEmits(['refresh', 'collapse'])
|
||||
padding-right: .25rem;
|
||||
}
|
||||
|
||||
/* Melhor sensação de “sessões” */
|
||||
/* Melhor sensação de "sessões" */
|
||||
.slot-top, .slot-bottom{
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -113,7 +113,7 @@ const attentionSeverity = computed(() => {
|
||||
<!-- Ações rápidas -->
|
||||
<div class="flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||
<div class="text-xs" style="color: var(--text-color-secondary);">
|
||||
Ações rápidas (criação sempre abre modal — nada nasce “direto”).
|
||||
Ações rápidas (criação sempre abre modal — nada nasce "direto").
|
||||
</div>
|
||||
|
||||
<div class="flex align-items-center gap-2">
|
||||
|
||||
@@ -35,7 +35,7 @@ const BASE_SELECT = `
|
||||
mirror_of_event_id, price,
|
||||
insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id,
|
||||
patients!agenda_eventos_patient_id_fkey (
|
||||
id, nome_completo, avatar_url
|
||||
id, nome_completo, avatar_url, status
|
||||
),
|
||||
determined_commitments!agenda_eventos_determined_commitment_fk (
|
||||
id, bg_color, text_color
|
||||
@@ -183,5 +183,6 @@ function flattenRow (r) {
|
||||
delete out.patients
|
||||
out.paciente_nome = patient?.nome_completo || out.paciente_nome || ''
|
||||
out.paciente_avatar = patient?.avatar_url || out.paciente_avatar || ''
|
||||
out.paciente_status = patient?.status || out.paciente_status || ''
|
||||
return out
|
||||
}
|
||||
@@ -164,6 +164,10 @@
|
||||
@click="gotoResult(r)"
|
||||
>
|
||||
<div class="font-medium truncate">{{ r.titulo || 'Sem título' }}</div>
|
||||
<span
|
||||
v-if="r.patients?.status === 'Inativo' || r.patients?.status === 'Arquivado'"
|
||||
class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5"
|
||||
>{{ r.patients?.status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
|
||||
<div class="mt-1 flex items-center justify-between gap-2 text-xs opacity-70">
|
||||
<span class="truncate">{{ fmtDateTime(r.inicio_em) }}</span>
|
||||
@@ -304,6 +308,10 @@
|
||||
<div class="font-semibold text-sm truncate">
|
||||
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
|
||||
</div>
|
||||
<span
|
||||
v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'"
|
||||
class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5"
|
||||
>{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
|
||||
<!-- Linha 3: título (se paciente diferente de título) -->
|
||||
<div
|
||||
@@ -623,7 +631,18 @@ const timeModeOptions = [
|
||||
{ label: 'Meu Horário', value: 'my' }
|
||||
]
|
||||
|
||||
const mosaicMode = computed(() => timeMode.value === '24' ? 'full_24h' : 'work_hours')
|
||||
const mosaicMode = ref('work_hours')
|
||||
|
||||
const mosaicModeOptions = [
|
||||
{ label: 'Horas de Trabalho', value: 'work_hours' },
|
||||
{ label: 'Grade Completa', value: 'full_24h' }
|
||||
]
|
||||
|
||||
// Sincroniza mosaicMode com timeMode: '24h' força grade completa
|
||||
watch(timeMode, (v) => {
|
||||
if (v === '24') mosaicMode.value = 'full_24h'
|
||||
else if (mosaicMode.value === 'full_24h') mosaicMode.value = 'work_hours'
|
||||
})
|
||||
|
||||
function settingsFallbackStart () {
|
||||
const s = settings.value
|
||||
@@ -936,15 +955,26 @@ const baseRows = computed(() => {
|
||||
})
|
||||
|
||||
if (!onlySessions.value) return refined
|
||||
return refined.filter(r => {
|
||||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
|
||||
return tipo === EVENTO_TIPO.SESSAO || r.masked === true
|
||||
})
|
||||
// Filtrar por patient_id — filtro por tipo não funciona pois o enum do banco
|
||||
// usa 'sessao' para todos os compromissos não-bloqueio (Análise, Leitura, etc.)
|
||||
return refined.filter(r => !!(r.patient_id || r.masked))
|
||||
})
|
||||
|
||||
const allEvents = computed(() => {
|
||||
// Mapa id → cores para injetar em ocorrências virtuais (que não têm o join determined_commitments)
|
||||
const colorMap = new Map(
|
||||
(commitmentOptionsNormalized.value || [])
|
||||
.filter(c => c.id)
|
||||
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
|
||||
)
|
||||
function withCommitmentColors (r) {
|
||||
if (r.determined_commitments || !r.determined_commitment_id) return r
|
||||
const colors = colorMap.get(r.determined_commitment_id)
|
||||
return colors ? { ...r, determined_commitments: colors } : r
|
||||
}
|
||||
|
||||
// eventos reais (sem ocorrências virtuais para evitar duplicatas)
|
||||
const realRows = (baseRows.value || []).filter(r => !r.is_occurrence)
|
||||
const realRows = (baseRows.value || []).filter(r => !r.is_occurrence).map(withCommitmentColors)
|
||||
const base = mapAgendaEventosToCalendarEvents(realRows)
|
||||
|
||||
// ocorrências virtuais das séries
|
||||
@@ -953,8 +983,8 @@ const allEvents = computed(() => {
|
||||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
|
||||
const dc = r.determined_commitment_id
|
||||
if (tipo === EVENTO_TIPO.SESSAO && dc && !isSessionCommitmentId(dc)) return maskPrivateRow(r)
|
||||
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return null
|
||||
return r
|
||||
if (onlySessions.value && !(r.patient_id || r.masked)) return null
|
||||
return withCommitmentColors(r)
|
||||
}).filter(Boolean)
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
|
||||
|
||||
@@ -2059,7 +2089,7 @@ async function _reloadRange () {
|
||||
// Expande recorrências para cada terapeuta no range
|
||||
const allMerged = []
|
||||
for (const ownId of ownerIds.value) {
|
||||
const merged = await loadAndExpand(ownId, start, end, rows.value.filter(r => r.owner_id === ownId))
|
||||
const merged = await loadAndExpand(ownId, start, end, rows.value.filter(r => r.owner_id === ownId), tenantId.value)
|
||||
allMerged.push(...merged.filter(r => r.is_occurrence))
|
||||
}
|
||||
_occurrenceRows.value = allMerged
|
||||
@@ -2536,7 +2566,7 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: greenyellow;
|
||||
background: var(--primary-color, #6366f1);
|
||||
}
|
||||
|
||||
/* Badge numérico no header */
|
||||
|
||||
@@ -91,10 +91,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Aviso: fora da jornada -->
|
||||
<div v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
|
||||
<div ref="foraJornadaBannerRef" v-if="hasEventsOutsideWorkHours" class="mx-3 md:mx-4 mb-3 rounded-[6px] p-3" style="background:color-mix(in srgb,var(--yellow-400,#facc15) 10%,var(--surface-card));border:1px solid color-mix(in srgb,var(--yellow-400,#facc15) 35%,transparent);">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="pi pi-exclamation-triangle shrink-0" style="color:var(--yellow-600,#ca8a04);" />
|
||||
<div class="font-semibold text-sm flex-1">Compromissos fora da jornada</div>
|
||||
<div class="font-semibold text-sm">Compromissos fora da jornada</div>
|
||||
<Button label="Ver 24h" size="small" severity="secondary" outlined class="rounded-full shrink-0" @click="timeMode = '24'" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -185,12 +185,21 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
|
||||
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]">↻</span>
|
||||
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
<button
|
||||
v-if="ev.patient_id || ev.paciente_id"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
|
||||
title="Opções"
|
||||
@click.stop="openTodayEvMenu($event, ev)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
||||
</button>
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -199,6 +208,25 @@
|
||||
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton(); agPanelOpen = false" />
|
||||
</div>
|
||||
|
||||
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
|
||||
<div
|
||||
v-if="desativadoPatients.length"
|
||||
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
|
||||
@click="desativadoDialogOpen = true"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
|
||||
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
|
||||
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
|
||||
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
|
||||
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
com sessões agendadas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pacientes -->
|
||||
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
||||
@@ -406,12 +434,21 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-semibold truncate">{{ ev.paciente_nome || ev.patient_name || ev.titulo || '—' }}</span>
|
||||
<div class="flex items-center gap-1 mt-0.5">
|
||||
<div class="flex items-center gap-1 mt-0.5 flex-wrap">
|
||||
<span class="text-[0.65rem] bg-[var(--surface-border)] text-[var(--text-color-secondary)] px-1.5 py-px rounded font-semibold">{{ ev.modalidade || 'Presencial' }}</span>
|
||||
<span v-if="ev.recurrence_id" class="text-[1rem] text-[var(--primary-color,#6366f1)]" title="Recorrente">↻</span>
|
||||
<span v-if="ev.paciente_status === 'Inativo' || ev.paciente_status === 'Arquivado'" class="text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide">{{ ev.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<i class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
<button
|
||||
v-if="ev.patient_id || ev.paciente_id"
|
||||
class="w-6 h-6 rounded-full flex items-center justify-center border-none bg-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-border)] hover:text-[var(--primary-color,#6366f1)] transition-colors duration-100 cursor-pointer flex-shrink-0"
|
||||
title="Opções"
|
||||
@click.stop="openTodayEvMenu($event, ev)"
|
||||
>
|
||||
<i class="pi pi-ellipsis-v text-[0.7rem]" />
|
||||
</button>
|
||||
<i v-else class="text-xs text-[var(--text-color-secondary)] flex-shrink-0" :class="statusIcon(ev.status)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -420,6 +457,25 @@
|
||||
<Button label="Novo Compromisso" icon="pi pi-plus" class="w-full rounded-full" @click="onCreateFromButton" />
|
||||
</div>
|
||||
|
||||
<!-- Card: Pacientes Desativados/Arquivados com sessões pendentes -->
|
||||
<div
|
||||
v-if="desativadoPatients.length"
|
||||
class="border border-orange-500/40 rounded-md bg-orange-500/5 p-3 cursor-pointer hover:bg-orange-500/10 transition-colors duration-150"
|
||||
@click="desativadoDialogOpen = true"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1.5">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500 text-sm" />
|
||||
<span class="text-[0.72rem] font-bold text-orange-600 uppercase tracking-wide flex-1">Atenção: sessões pendentes</span>
|
||||
<span class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-orange-500 text-white text-[0.62rem] font-bold">
|
||||
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-[0.7rem] text-orange-700/80 leading-snug m-0">
|
||||
{{ desativadoPatients.length }} paciente{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
desativado{{ desativadoPatients.length > 1 ? 's' : '' }} ou arquivado{{ desativadoPatients.length > 1 ? 's' : '' }}
|
||||
com sessões agendadas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Pacientes -->
|
||||
<div class="border border-[var(--surface-border)] rounded-md bg-[var(--surface-card)] p-3">
|
||||
@@ -549,7 +605,7 @@
|
||||
|
||||
<!-- Sem resultados -->
|
||||
<div v-else-if="searchResults.length === 0" class="text-color-secondary text-sm">
|
||||
Nenhum resultado para “<b>{{ searchTrim }}</b>"
|
||||
Nenhum resultado para "<b>{{ searchTrim }}</b>"
|
||||
<span class="text-xs"> ({{ searchScope === 'month' ? 'mês inteiro' : 'período atual' }})</span>.
|
||||
</div>
|
||||
|
||||
@@ -579,6 +635,7 @@
|
||||
<div class="font-semibold text-sm truncate">
|
||||
{{ r.paciente_nome || r.patient_name || r.titulo || 'Sem título' }}
|
||||
</div>
|
||||
<span v-if="r.paciente_status === 'Inativo' || r.paciente_status === 'Arquivado'" class="inline-block text-[0.6rem] bg-orange-500 text-white px-1.5 py-px rounded font-bold uppercase tracking-wide mt-0.5">{{ r.paciente_status === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado' }}</span>
|
||||
|
||||
<!-- Linha 3: título (se paciente diferente de título) -->
|
||||
<div
|
||||
@@ -737,6 +794,140 @@
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════
|
||||
Dialog: Sessões Pendentes de Pacientes Desativados/Arquivados
|
||||
═══════════════════════════════════════════════════════════ -->
|
||||
<Dialog
|
||||
v-model:visible="desativadoDialogOpen"
|
||||
modal
|
||||
:draggable="false"
|
||||
:style="{ width: '1100px', maxWidth: '97vw', height: '85vh' }"
|
||||
:pt="{ content: { style: 'padding:0; display:flex; flex-direction:column; height:100%; overflow:hidden;' }, header: { style: 'padding: 1rem 1.25rem 0.75rem' } }"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<i class="pi pi-exclamation-triangle text-orange-500" />
|
||||
<span class="font-semibold text-base">Sessões agendadas — pacientes desativados</span>
|
||||
<span class="ml-1 inline-flex items-center justify-center min-w-[22px] h-[22px] px-1 rounded-full bg-orange-500 text-white text-xs font-bold">
|
||||
{{ desativadoPatients.reduce((s, p) => s + p.sessions.length, 0) }}
|
||||
</span>
|
||||
<!-- Tabs pacientes -->
|
||||
<div class="flex gap-1 ml-auto flex-wrap">
|
||||
<button
|
||||
v-for="p in desativadoPatients"
|
||||
:key="p.id"
|
||||
class="px-3 py-1 rounded-full text-xs font-semibold border transition-all duration-150"
|
||||
:class="desativadoSelected?.id === p.id
|
||||
? 'bg-orange-500 text-white border-orange-500'
|
||||
: 'bg-transparent text-orange-600 border-orange-500/40 hover:bg-orange-500/10'"
|
||||
@click="desativadoSelected = p"
|
||||
>
|
||||
{{ (p.nome_completo || '—').split(' ')[0] }}
|
||||
<span class="ml-1 opacity-70">({{ p.sessions.length }})</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Body: split panel -->
|
||||
<div v-if="desativadoSelected" class="flex flex-col lg:flex-row flex-1 min-h-0 overflow-hidden">
|
||||
|
||||
<!-- Sidebar esquerda: lista de sessões -->
|
||||
<div class="w-full lg:w-[340px] lg:flex-shrink-0 flex flex-col border-b lg:border-b-0 lg:border-r border-[var(--surface-border)]">
|
||||
|
||||
<!-- Patient info -->
|
||||
<div class="px-4 py-3 border-b border-[var(--surface-border)] bg-orange-500/5">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full flex items-center justify-center text-white font-bold text-sm flex-shrink-0"
|
||||
style="background: #f97316;">
|
||||
{{ (desativadoSelected.nome_completo || '?').charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-sm truncate">{{ desativadoSelected.nome_completo }}</div>
|
||||
<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 6px;border-radius:3px;line-height:1.5;">
|
||||
{{ desativadoSelected.status === 'Arquivado' ? 'arquivado' : 'desativado' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-auto text-right flex-shrink-0">
|
||||
<div class="text-lg font-bold text-orange-500">{{ desativadoSelected.sessions.length }}</div>
|
||||
<div class="text-[0.65rem] text-[var(--text-color-secondary)]">sessão(ões)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sessions list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<div
|
||||
v-for="s in desativadoSelected.sessions"
|
||||
:key="s.id"
|
||||
class="rounded-lg border p-2.5 cursor-pointer transition-all duration-150 group"
|
||||
:class="desativadoFocused?.id === s.id
|
||||
? 'border-orange-500 bg-orange-500/8 shadow-sm'
|
||||
: 'border-[var(--surface-border)] bg-[var(--surface-card)] hover:border-orange-500/40 hover:bg-orange-500/5'"
|
||||
@click="focusDesativadoSession(s)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs font-bold text-[var(--text-color)]">
|
||||
{{ fmtDesativadoDate(s.inicio_em) }}
|
||||
</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">
|
||||
{{ fmtDesativadoTime(s.inicio_em) }} · {{ fmtDesativadoDur(s.inicio_em, s.fim_em) }}
|
||||
</div>
|
||||
<div v-if="s.titulo" class="text-[0.7rem] text-[var(--text-color-secondary)] truncate mt-0.5">{{ s.titulo }}</div>
|
||||
</div>
|
||||
<Tag :value="s.modalidade || 'Presencial'" severity="secondary" class="text-[0.6rem] shrink-0" />
|
||||
</div>
|
||||
<div class="flex gap-1.5 mt-2">
|
||||
<Button
|
||||
label="Ver na agenda"
|
||||
icon="pi pi-external-link"
|
||||
size="small"
|
||||
severity="warn"
|
||||
outlined
|
||||
class="flex-1 text-[0.65rem]"
|
||||
@click.stop="openSessionInMainCalendar(s)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Painel direito: mini FullCalendar -->
|
||||
<div class="flex-1 min-w-0 min-h-[300px] lg:min-h-0 overflow-hidden flex flex-col">
|
||||
<div class="px-4 py-2 border-b border-[var(--surface-border)] flex items-center gap-2">
|
||||
<i class="pi pi-calendar text-[var(--text-color-secondary)] text-xs" />
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">Clique em uma sessão para abrir na agenda principal e cancelar ou remarcar</span>
|
||||
</div>
|
||||
<div class="flex-1 overflow-auto p-2">
|
||||
<FullCalendar
|
||||
v-if="desativadoDialogOpen && desativadoSelected"
|
||||
ref="desativadoFcRef"
|
||||
:options="desativadoFcOptions"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Fechar" severity="secondary" outlined @click="desativadoDialogOpen = false" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Menu contexto: Sessões Hoje -->
|
||||
<Menu ref="todayEvMenuRef" :model="todayEvMenuItems" :popup="true" />
|
||||
|
||||
<!-- Dialog: Prontuário -->
|
||||
<PatientProntuario
|
||||
:key="selectedPatient?.id || 'none'"
|
||||
v-model="prontuarioOpen"
|
||||
:patient="selectedPatient"
|
||||
@close="closeProntuario"
|
||||
/>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@@ -753,11 +944,13 @@ import Calendar from 'primevue/calendar'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import timeGridPlugin from '@fullcalendar/timegrid'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import listPlugin from '@fullcalendar/list'
|
||||
import interactionPlugin from '@fullcalendar/interaction'
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br'
|
||||
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
|
||||
import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue'
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
import ProximosFeriadosCard from '@/features/agenda/components/ProximosFeriadosCard.vue'
|
||||
|
||||
import { useSupportDebugStore } from '@/support/supportDebugStore'
|
||||
@@ -810,7 +1003,7 @@ const {
|
||||
const commitmentOptionsNormalized = computed(() => {
|
||||
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
|
||||
|
||||
// prioridade pra “Sessão" primeiro (native_key = session)
|
||||
// prioridade pra "Sessão" primeiro (native_key = session)
|
||||
const priority = new Map([
|
||||
['session', 0],
|
||||
['class', 1],
|
||||
@@ -876,13 +1069,16 @@ onMounted(async () => {
|
||||
if (tid) loadFeriados(tid)
|
||||
})
|
||||
|
||||
// Carrega desativados assim que ownerId estiver disponível
|
||||
watch(ownerId, (id) => { if (id) loadDesativados() }, { immediate: true })
|
||||
|
||||
// Range atual
|
||||
const currentRange = ref({ start: null, end: null })
|
||||
|
||||
// -----------------------------
|
||||
// Topbar state
|
||||
// -----------------------------
|
||||
const onlySessions = ref(true)
|
||||
const onlySessions = ref(false)
|
||||
const calendarView = ref('day') // day | week | month
|
||||
const timeMode = ref('my') // 24 | 12 | my
|
||||
const search = ref('')
|
||||
@@ -954,6 +1150,52 @@ const headerMenuRef = ref(null)
|
||||
const agPanelOpen = ref(false)
|
||||
const blockMenuRef = ref(null)
|
||||
|
||||
// ── Prontuário ────────────────────────────────────────────────
|
||||
const prontuarioOpen = ref(false)
|
||||
const selectedPatient = ref(null)
|
||||
|
||||
function openProntuario (patientId, patientNome) {
|
||||
if (!patientId) return
|
||||
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
|
||||
prontuarioOpen.value = true
|
||||
}
|
||||
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
|
||||
|
||||
// ── Menu de contexto: Sessões Hoje ────────────────────────────
|
||||
const todayEvMenuRef = ref(null)
|
||||
const _todayEvAtivo = ref(null)
|
||||
|
||||
const todayEvMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Opções',
|
||||
items: [
|
||||
{
|
||||
label: 'Ver prontuário',
|
||||
icon: 'pi pi-file-edit',
|
||||
disabled: !(_todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id),
|
||||
command: () => {
|
||||
const id = _todayEvAtivo.value?.patient_id || _todayEvAtivo.value?.paciente_id
|
||||
const nome = _todayEvAtivo.value?.paciente_nome || _todayEvAtivo.value?.patient_name || ''
|
||||
openProntuario(id, nome)
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Abrir na agenda',
|
||||
icon: 'pi pi-calendar',
|
||||
command: () => {
|
||||
if (_todayEvAtivo.value) onEventRowClick(_todayEvAtivo.value)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function openTodayEvMenu (event, ev) {
|
||||
event.stopPropagation()
|
||||
_todayEvAtivo.value = ev
|
||||
todayEvMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// Bloqueio dialog
|
||||
const bloqueioDialogOpen = ref(false)
|
||||
const bloqueioMode = ref('horario')
|
||||
@@ -1098,8 +1340,10 @@ const allRows = computed(() => [
|
||||
|
||||
const calendarRows = computed(() => {
|
||||
return allRows.value.filter(r => {
|
||||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO)
|
||||
if (onlySessions.value && tipo !== EVENTO_TIPO.SESSAO) return false
|
||||
// "Apenas Sessões" = eventos vinculados a paciente.
|
||||
// Filtrar por tipo não funciona pois o banco usa 'sessao' para todos os compromissos
|
||||
// que não são bloqueio — compromissos pessoais (Análise, Leitura, etc.) têm o mesmo tipo.
|
||||
if (onlySessions.value && !(r.patient_id || r.paciente_id)) return false
|
||||
return true
|
||||
})
|
||||
})
|
||||
@@ -1172,7 +1416,8 @@ const searchResults = computed(() => {
|
||||
? monthSearchRows.value
|
||||
: (calendarRows.value || []).map(r => ({
|
||||
...r,
|
||||
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || ''
|
||||
paciente_nome: r.paciente_nome || r.patient_name || r.nome_paciente || '',
|
||||
paciente_status: r.paciente_status || r.extendedProps?.paciente_status || ''
|
||||
}))
|
||||
return source.filter(r => _matchRow(r, q))
|
||||
})
|
||||
@@ -1191,7 +1436,7 @@ async function loadMonthSearchRows () {
|
||||
// mergeWithStoredSessions deduplicar sessões materializadas de séries.
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo)')
|
||||
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, insurance_plan_service_id, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', uid)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', startISO)
|
||||
@@ -1199,7 +1444,7 @@ async function loadMonthSearchRows () {
|
||||
.order('inicio_em', { ascending: true })
|
||||
if (error) throw error
|
||||
|
||||
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '' }))
|
||||
const realRows = (data || []).map(r => ({ ...r, paciente_nome: r.patients?.nome_completo || '', paciente_status: r.patients?.status || '' }))
|
||||
|
||||
// 2. Ocorrências virtuais de recorrência (não existem em agenda_eventos).
|
||||
// loadAndExpand retorna merged = reais + virtuais; filtramos só is_occurrence
|
||||
@@ -1230,10 +1475,27 @@ watch(currentDate, (newD, oldD) => {
|
||||
})
|
||||
|
||||
const calendarEvents = computed(() => {
|
||||
// Mapa id → {bg_color, text_color} dos commitments já carregados na página.
|
||||
// Usado para injetar cores nas ocorrências virtuais, que não têm o join
|
||||
// determined_commitments (useRecurrence faz select('*') sem join).
|
||||
const colorMap = new Map(
|
||||
(commitmentOptionsNormalized.value || [])
|
||||
.filter(c => c.id)
|
||||
.map(c => [c.id, { bg_color: c.bg_color || null, text_color: c.text_color || null }])
|
||||
)
|
||||
|
||||
// Injeta determined_commitments em qualquer row que ainda não tenha — resolve
|
||||
// tanto ocorrências virtuais quanto eventos reais com join nulo.
|
||||
function withCommitmentColors (r) {
|
||||
if (r.determined_commitments || !r.determined_commitment_id) return r
|
||||
const colors = colorMap.get(r.determined_commitment_id)
|
||||
return colors ? { ...r, determined_commitments: colors } : r
|
||||
}
|
||||
|
||||
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
|
||||
// em cada grupo — as virtuais precisam do mesmo tratamento de cores
|
||||
const realRows = calendarRows.value.filter(r => !r.is_occurrence)
|
||||
const occRows = calendarRows.value.filter(r => r.is_occurrence)
|
||||
const realRows = calendarRows.value.filter(r => !r.is_occurrence).map(withCommitmentColors)
|
||||
const occRows = calendarRows.value.filter(r => r.is_occurrence).map(withCommitmentColors)
|
||||
|
||||
const base = mapAgendaEventosToCalendarEvents(realRows)
|
||||
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
|
||||
@@ -1392,6 +1654,7 @@ const fcOptions = computed(() => ({
|
||||
const obs = ext.observacoes || ''
|
||||
const title = arg.event.title || ''
|
||||
const timeText = arg.timeText || ''
|
||||
const pacienteStatus = ext.paciente_status || ''
|
||||
|
||||
const esc = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<')
|
||||
@@ -1412,6 +1675,9 @@ const fcOptions = computed(() => ({
|
||||
|
||||
const obsHtml = obs ? `<div class="ev-obs">${esc(obs)}</div>` : ''
|
||||
const timeHtml = timeText ? `<div class="ev-time">${esc(timeText)}</div>` : ''
|
||||
const inativoBadge = (pacienteStatus === 'Inativo' || pacienteStatus === 'Arquivado')
|
||||
? `<span style="display:inline-block;background:#f97316;color:#fff;font-size:9px;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.4;margin-top:2px;">${pacienteStatus === 'Arquivado' ? 'paciente arquivado' : 'paciente desativado'}</span>`
|
||||
: ''
|
||||
|
||||
return {
|
||||
html: `<div class="ev-custom">
|
||||
@@ -1419,6 +1685,7 @@ const fcOptions = computed(() => ({
|
||||
<div class="ev-body">
|
||||
${timeHtml}
|
||||
<div class="ev-title">${esc(title)}</div>
|
||||
${inativoBadge}
|
||||
${obsHtml}
|
||||
</div>
|
||||
</div>`
|
||||
@@ -1442,12 +1709,47 @@ const fcOptions = computed(() => ({
|
||||
const classes = []
|
||||
if (tipo === EVENTO_TIPO.SESSAO) classes.push('evt-session')
|
||||
if (tipo === EVENTO_TIPO.BLOQUEIO) classes.push('evt-block')
|
||||
// Quando o evento já tem cor do commitment, marca para que o CSS
|
||||
// não sobrescreva com a cor primária padrão via !important
|
||||
if (arg?.event?.backgroundColor) classes.push('evt-has-color')
|
||||
if (qn && hit) classes.push('evt-hit')
|
||||
if (qn && !hit) classes.push('evt-dim')
|
||||
return classes
|
||||
},
|
||||
|
||||
eventDidMount: (info) => {
|
||||
const bgColor = info.event.extendedProps?.commitment_bg_color
|
||||
if (bgColor) {
|
||||
info.el.style.setProperty('background-color', bgColor, 'important')
|
||||
info.el.style.setProperty('border-color', bgColor, 'important')
|
||||
}
|
||||
// Marca o elemento com o id do evento para scroll+pulse posterior
|
||||
info.el.dataset.eventId = info.event.id
|
||||
}
|
||||
}))
|
||||
|
||||
// ── Scroll + pulse no evento do FullCalendar ─────────────────
|
||||
const foraJornadaBannerRef = ref(null)
|
||||
|
||||
async function scrollToAndPulseEvent (eventId) {
|
||||
await nextTick()
|
||||
// Aguarda o FC renderizar após possível mudança de view/data
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Pulsa o banner "fora da jornada" se estiver visível
|
||||
const banner = foraJornadaBannerRef.value
|
||||
if (banner) {
|
||||
banner.classList.add('notif-card--highlight')
|
||||
setTimeout(() => banner.classList.remove('notif-card--highlight'), 2000)
|
||||
}
|
||||
|
||||
const el = document.querySelector(`[data-event-id="${eventId}"]`)
|
||||
if (!el) return
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
el.classList.add('notif-card--highlight')
|
||||
setTimeout(() => el.classList.remove('notif-card--highlight'), 2000)
|
||||
}
|
||||
|
||||
|
||||
// ── Resumo do dia (coluna direita) ────────────────────────────────────────────
|
||||
const todayEvents = computed(() => {
|
||||
@@ -1508,6 +1810,7 @@ function onEventRowClick (ev) {
|
||||
if (ev.inicio_em) {
|
||||
getApi()?.gotoDate?.(new Date(ev.inicio_em))
|
||||
calendarView.value = 'day'
|
||||
scrollToAndPulseEvent(ev.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1569,6 +1872,119 @@ function getApi () {
|
||||
return fcRef.value?.getApi?.() || null
|
||||
}
|
||||
|
||||
// ── Pacientes Desativados com sessões pendentes ────────────
|
||||
const desativadoPatients = ref([])
|
||||
const desativadoDialogOpen = ref(false)
|
||||
const desativadoSelected = ref(null)
|
||||
const desativadoFocused = ref(null)
|
||||
const desativadoFcRef = ref(null)
|
||||
|
||||
async function loadDesativados () {
|
||||
if (!ownerId.value) return
|
||||
try {
|
||||
const { data: pats, error: pErr } = await supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo, status')
|
||||
.eq('owner_id', ownerId.value)
|
||||
.in('status', ['Inativo', 'Arquivado'])
|
||||
|
||||
if (pErr) { console.warn('[loadDesativados] patients error:', pErr); desativadoPatients.value = []; return }
|
||||
if (!pats?.length) { desativadoPatients.value = []; return }
|
||||
|
||||
const patIds = pats.map(p => p.id)
|
||||
const sessQ = supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, patient_id, inicio_em, fim_em, status, titulo, modalidade, determined_commitment_id')
|
||||
.in('patient_id', patIds)
|
||||
.order('inicio_em', { ascending: true })
|
||||
if (ownerId.value) sessQ.eq('owner_id', ownerId.value)
|
||||
if (clinicTenantId.value) sessQ.eq('tenant_id', clinicTenantId.value)
|
||||
const { data: sessions, error: sErr } = await sessQ
|
||||
|
||||
if (sErr) { console.warn('[loadDesativados] sessions error:', sErr) }
|
||||
|
||||
const byPat = new Map()
|
||||
for (const s of (sessions || [])) {
|
||||
if (!byPat.has(s.patient_id)) byPat.set(s.patient_id, [])
|
||||
byPat.get(s.patient_id).push(s)
|
||||
}
|
||||
|
||||
desativadoPatients.value = pats
|
||||
.filter(p => byPat.has(p.id))
|
||||
.map(p => ({ ...p, sessions: byPat.get(p.id) }))
|
||||
|
||||
if (desativadoPatients.value.length && !desativadoSelected.value) {
|
||||
desativadoSelected.value = desativadoPatients.value[0]
|
||||
}
|
||||
} catch (e) { console.warn('[loadDesativados] erro:', e) }
|
||||
}
|
||||
|
||||
const desativadoFcOptions = computed(() => {
|
||||
const patient = desativadoSelected.value
|
||||
if (!patient) return {}
|
||||
const events = (patient.sessions || []).map(s => ({
|
||||
id: s.id,
|
||||
title: s.titulo || 'Sessão',
|
||||
start: s.inicio_em,
|
||||
end: s.fim_em,
|
||||
backgroundColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
|
||||
borderColor: desativadoFocused.value?.id === s.id ? '#ea580c' : '#f97316',
|
||||
textColor: '#fff',
|
||||
extendedProps: { session: s }
|
||||
}))
|
||||
const firstDate = patient.sessions[0]?.inicio_em
|
||||
return {
|
||||
plugins: [listPlugin, dayGridPlugin, interactionPlugin],
|
||||
locale: ptBrLocale,
|
||||
initialView: 'listMonth',
|
||||
initialDate: firstDate || new Date().toISOString(),
|
||||
events,
|
||||
headerToolbar: { left: 'prev,next today', center: 'title', right: 'listMonth,dayGridMonth' },
|
||||
height: '100%',
|
||||
noEventsText: 'Nenhuma sessão encontrada.',
|
||||
eventClick: (info) => {
|
||||
const s = info.event.extendedProps.session
|
||||
openSessionInMainCalendar(s)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(desativadoSelected, () => {
|
||||
desativadoFocused.value = null
|
||||
})
|
||||
|
||||
function focusDesativadoSession (session) {
|
||||
desativadoFocused.value = session
|
||||
const api = desativadoFcRef.value?.getApi?.()
|
||||
if (api) api.gotoDate(new Date(session.inicio_em))
|
||||
}
|
||||
|
||||
function openSessionInMainCalendar (session) {
|
||||
desativadoDialogOpen.value = false
|
||||
const date = new Date(session.inicio_em)
|
||||
currentDate.value = date
|
||||
getApi()?.gotoDate?.(date)
|
||||
// Muda para visão de dia para facilitar encontrar a sessão
|
||||
const api = getApi()
|
||||
if (api) api.changeView('timeGridDay', date)
|
||||
}
|
||||
|
||||
// Helpers de formato para o painel de desativados
|
||||
function fmtDesativadoDate (iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' })
|
||||
}
|
||||
function fmtDesativadoTime (iso) {
|
||||
if (!iso) return '—'
|
||||
return new Date(iso).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
function fmtDesativadoDur (start, end) {
|
||||
if (!start || !end) return ''
|
||||
const min = Math.round((new Date(end) - new Date(start)) / 60000)
|
||||
return `${min}min`
|
||||
}
|
||||
|
||||
watch(calendarView, async () => {
|
||||
await nextTick()
|
||||
getApi()?.changeView?.(fcViewName.value)
|
||||
@@ -1947,6 +2363,7 @@ function onEventClick (info) {
|
||||
paciente_id: ep.paciente_id ?? null,
|
||||
paciente_nome: ep.paciente_nome ?? null,
|
||||
paciente_avatar: ep.paciente_avatar ?? null,
|
||||
paciente_status: ep.paciente_status ?? null,
|
||||
tipo: normalizeEventoTipo(ep.tipo, EVENTO_TIPO.SESSAO),
|
||||
status: ep.status,
|
||||
titulo: ev.title,
|
||||
@@ -2847,11 +3264,16 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
/* ── Cores dos eventos (global — aplicadas pelo FullCalendar) ── */
|
||||
.fc-event.evt-session {
|
||||
/* Cor primária padrão só quando o evento não tem cor personalizada do commitment */
|
||||
.fc-event.evt-session:not(.evt-has-color) {
|
||||
background-color: var(--p-primary-500, #6366f1) !important;
|
||||
border-color: var(--p-primary-600, #4f46e5) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* Cor de texto branca garantida para eventos com cor personalizada */
|
||||
.fc-event.evt-session.evt-has-color {
|
||||
color: #fff !important;
|
||||
}
|
||||
.fc-event.evt-block {
|
||||
background-color: #ef4444 !important;
|
||||
border-color: #dc2626 !important;
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function listClinicEvents ({ tenantId, ownerIds, startISO, endISO }
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.select('*, patients!agenda_eventos_patient_id_fkey(id, nome_completo, avatar_url, status), determined_commitments!agenda_eventos_determined_commitment_fk(id, bg_color, text_color)')
|
||||
.eq('tenant_id', tenantId)
|
||||
.in('owner_id', safeOwnerIds)
|
||||
.gte('inicio_em', startISO)
|
||||
|
||||
@@ -89,6 +89,7 @@ function _mapRow (r) {
|
||||
paciente_id: r.patient_id ?? null, // alias para compatibilidade com dialog/form
|
||||
paciente_nome: nomeP,
|
||||
paciente_avatar: r.patients?.avatar_url ?? r.paciente_avatar ?? null,
|
||||
paciente_status: r.patients?.status ?? r.paciente_status ?? null,
|
||||
|
||||
// campos
|
||||
observacoes: r.observacoes ?? null,
|
||||
|
||||
@@ -157,6 +157,18 @@
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Inativos</div>
|
||||
</div>
|
||||
|
||||
<!-- Arquivados -->
|
||||
<div
|
||||
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border min-w-[72px] flex-1 cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
|
||||
:class="filters.status === 'Arquivado'
|
||||
? 'border-slate-500 bg-slate-500/5 shadow-[0_0_0_3px_rgba(100,116,139,0.15)]'
|
||||
: 'border-slate-500/30 bg-slate-500/5 hover:border-slate-500/50'"
|
||||
@click="setStatus('Arquivado')"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none text-slate-500">{{ kpis.archived }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 whitespace-nowrap">Arquivados</div>
|
||||
</div>
|
||||
|
||||
<!-- Último atendimento — não clicável -->
|
||||
<div
|
||||
class="flex flex-col gap-1 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] min-w-[72px] flex-1"
|
||||
@@ -436,7 +448,7 @@
|
||||
|
||||
<Column field="status" header="Status" v-if="isColVisible('status')" :key="'col-status'" sortable style="width: 9rem;">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="data.status" :severity="data.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
<Tag :value="data.status" :severity="statusSeverity(data.status)" />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
@@ -481,13 +493,17 @@
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 20rem;" frozen alignFrozen="right">
|
||||
<Column :key="'col-acoes'" header="Ações" style="width: 22rem;" frozen alignFrozen="right">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
|
||||
<Button v-if="historySet.has(data.id)" :label="`Sessões × ${sessionCountMap.get(data.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(data)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(data)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="goEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Excluir'" @click="confirmDeleteOne(data)" />
|
||||
<PatientActionMenu
|
||||
:patient="data"
|
||||
:hasHistory="historySet.has(data.id)"
|
||||
@updated="fetchAll"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -542,7 +558,7 @@
|
||||
</div>
|
||||
<div class="text-base text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
|
||||
</div>
|
||||
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
|
||||
<Tag :value="pat.status" :severity="statusSeverity(pat.status)" />
|
||||
</div>
|
||||
|
||||
<!-- Grupos + Tags -->
|
||||
@@ -553,10 +569,14 @@
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="mt-3 flex gap-2 justify-end flex-wrap">
|
||||
<Button label="Sessões" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
|
||||
<Button v-if="historySet.has(pat.id)" :label="`Sessões × ${sessionCountMap.get(pat.id) || 0}`" icon="pi pi-calendar" size="small" severity="info" outlined @click="abrirSessoes(pat)" />
|
||||
<Button label="Prontuário" icon="pi pi-file" size="small" @click="openProntuario(pat)" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" @click="goEdit(pat)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" @click="confirmDeleteOne(pat)" />
|
||||
<PatientActionMenu
|
||||
:patient="pat"
|
||||
:hasHistory="historySet.has(pat.id)"
|
||||
@updated="fetchAll"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -586,14 +606,85 @@
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel value="grupos">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="text-color-secondary">Atalho para a página de Grupos.</div>
|
||||
<Button label="Abrir Grupos" icon="pi pi-external-link" outlined @click="goGroups" />
|
||||
<!-- Cabeçalho da view de grupos -->
|
||||
<div class="flex items-center justify-between gap-3 mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-sitemap text-[var(--primary-color,#6366f1)]" />
|
||||
<span class="font-semibold text-[var(--text-color)]">Pacientes distribuídos por grupo</span>
|
||||
<span
|
||||
v-if="groupedPatientsView.length"
|
||||
class="inline-flex items-center justify-center min-w-[20px] h-5 px-1 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[0.65rem] font-bold"
|
||||
>{{ groupedPatientsView.length }}</span>
|
||||
</div>
|
||||
<Button label="Gerenciar grupos" icon="pi pi-external-link" severity="secondary" outlined size="small" @click="goGroups" />
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="groupedPatientsView.length === 0" class="flex flex-col items-center justify-center gap-3 py-12 text-[var(--text-color-secondary)]">
|
||||
<div class="w-14 h-14 rounded-xl bg-indigo-500/10 flex items-center justify-center">
|
||||
<i class="pi pi-sitemap text-2xl text-indigo-500" />
|
||||
</div>
|
||||
<div class="font-semibold text-[var(--text-color)]">Nenhuma associação encontrada</div>
|
||||
<div class="text-sm opacity-70 text-center max-w-xs">Associe pacientes a grupos no cadastro ou na listagem para visualizá-los aqui.</div>
|
||||
<Button label="Gerenciar grupos" icon="pi pi-sitemap" outlined size="small" class="mt-1" @click="goGroups" />
|
||||
</div>
|
||||
|
||||
<!-- Grid de grupos -->
|
||||
<div v-else class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
|
||||
<div
|
||||
v-for="grp in groupedPatientsView"
|
||||
:key="grp.id"
|
||||
class="rounded-xl border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden flex flex-col shadow-sm hover:shadow-md transition-shadow duration-200"
|
||||
>
|
||||
<!-- Barra de cor do grupo -->
|
||||
<div class="h-1.5 w-full" :style="grpColorStyle(grp.color)" />
|
||||
|
||||
<!-- Header do grupo -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div
|
||||
class="w-9 h-9 rounded-lg flex items-center justify-center text-white font-bold text-sm flex-shrink-0 shadow-sm"
|
||||
:style="grpColorStyle(grp.color)"
|
||||
>
|
||||
{{ (grp.name || '?')[0].toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-semibold text-[var(--text-color)] truncate text-sm">{{ grp.name }}</div>
|
||||
<div class="text-[0.72rem] text-[var(--text-color-secondary)] opacity-70">
|
||||
{{ grp.patients.length }} paciente{{ grp.patients.length !== 1 ? 's' : '' }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
class="inline-flex items-center justify-center min-w-[26px] h-6 px-1.5 rounded-full text-white text-xs font-bold flex-shrink-0"
|
||||
:style="grpColorStyle(grp.color)"
|
||||
>{{ grp.patients.length }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
@@ -696,6 +787,7 @@ import ProgressSpinner from 'primevue/progressspinner'
|
||||
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||
import PatientActionMenu from '@/components/patients/PatientActionMenu.vue'
|
||||
|
||||
// ── Descontos por paciente ────────────────────────────────────────
|
||||
const discountMap = ref({})
|
||||
@@ -827,20 +919,24 @@ function setAllColumns () { selectedColumns.value = columnCatalogAll.map(c =
|
||||
|
||||
const sort = reactive({ field: 'created_at', order: -1 })
|
||||
|
||||
const kpis = reactive({ total: 0, active: 0, inactive: 0, latestLastAttended: '' })
|
||||
const kpis = reactive({ total: 0, active: 0, inactive: 0, archived: 0, latestLastAttended: '' })
|
||||
|
||||
const filters = reactive({
|
||||
status: 'Todos', search: '',
|
||||
status: 'Ativo', search: '',
|
||||
groupId: null, tagId: null,
|
||||
createdFrom: null, createdTo: null
|
||||
})
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Todos', value: 'Todos' },
|
||||
{ label: 'Ativo', value: 'Ativo' },
|
||||
{ label: 'Inativo', value: 'Inativo' }
|
||||
{ label: 'Ativos', value: 'Ativo' },
|
||||
{ label: 'Inativos', value: 'Inativo' },
|
||||
{ label: 'Arquivados', value: 'Arquivado' },
|
||||
{ label: 'Todos', value: 'Todos' }
|
||||
]
|
||||
|
||||
const historySet = ref(new Set())
|
||||
const sessionCountMap = ref(new Map())
|
||||
|
||||
const groupOptions = computed(() => (groups.value || []).map(g => ({ label: g.name, value: g.id })))
|
||||
const tagOptions = computed(() => (tags.value || []).map(t => ({ label: t.name, value: t.id })))
|
||||
|
||||
@@ -964,7 +1060,7 @@ function onFilterChangedDebounced () {
|
||||
function onFilterChanged () { updateKpis() }
|
||||
function setStatus (s) { filters.status = s; onFilterChanged() }
|
||||
function clearAllFilters () {
|
||||
filters.status = 'Todos'; filters.search = ''; filters.groupId = null
|
||||
filters.status = 'Ativo'; filters.search = ''; filters.groupId = null
|
||||
filters.tagId = null; filters.createdFrom = null; filters.createdTo = null
|
||||
onFilterChanged()
|
||||
}
|
||||
@@ -987,6 +1083,15 @@ function normalizeStatus (s) {
|
||||
return v.charAt(0).toUpperCase() + v.slice(1)
|
||||
}
|
||||
|
||||
function statusSeverity (s) {
|
||||
if (s === 'Ativo') return 'success'
|
||||
if (s === 'Inativo') return 'warn'
|
||||
if (s === 'Arquivado') return 'secondary'
|
||||
if (s === 'Alta') return 'info'
|
||||
if (s === 'Encaminhado') return 'contrast'
|
||||
return 'secondary'
|
||||
}
|
||||
|
||||
function initials (name) {
|
||||
const parts = String(name || '').trim().split(/\s+/).filter(Boolean)
|
||||
if (!parts.length) return '—'
|
||||
@@ -1196,33 +1301,28 @@ async function hydrateAssociationsSupabase () {
|
||||
groups: groupsByPatient.get(p.id) || [],
|
||||
tags: tagsByPatient.get(p.id) || []
|
||||
}))
|
||||
|
||||
// Calcula historySet — uma única query para todos os ids
|
||||
const { data: evtCounts } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('patient_id')
|
||||
.in('patient_id', ids)
|
||||
.not('patient_id', 'is', null)
|
||||
.limit(1000)
|
||||
|
||||
const tempSet = new Set()
|
||||
const countMap = new Map()
|
||||
for (const r of (evtCounts || [])) {
|
||||
if (r.patient_id) {
|
||||
tempSet.add(r.patient_id)
|
||||
countMap.set(r.patient_id, (countMap.get(r.patient_id) || 0) + 1)
|
||||
}
|
||||
}
|
||||
historySet.value = tempSet
|
||||
sessionCountMap.value = countMap
|
||||
}
|
||||
|
||||
// ── Delete ────────────────────────────────────────────────
|
||||
function confirmDeleteOne (row) {
|
||||
const nome = row?.nome_completo || 'este paciente'
|
||||
confirm.require({
|
||||
header: 'Excluir paciente',
|
||||
message: `Tem certeza que deseja excluir "${nome}"?`,
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptLabel: 'Excluir', rejectLabel: 'Cancelar', acceptClass: 'p-button-danger',
|
||||
accept: () => removePatient(row)
|
||||
})
|
||||
}
|
||||
|
||||
async function removePatient (row) {
|
||||
try {
|
||||
await supabase.from('patient_group_patient').delete().eq('patient_id', row.id)
|
||||
await supabase.from('patient_patient_tag').delete().eq('patient_id', row.id)
|
||||
const { error } = await supabase.from('patients').delete().eq('id', row.id).eq('owner_id', uid.value)
|
||||
if (error) throw error
|
||||
patients.value = (patients.value || []).filter(p => p.id !== row.id)
|
||||
updateKpis()
|
||||
toast.add({ severity: 'success', summary: 'Ok', detail: 'Paciente excluído.', life: 2500 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Falha', detail: e?.message || 'Não consegui excluir.', life: 3500 })
|
||||
}
|
||||
}
|
||||
// Delete movido para PatientActionMenu + usePatientLifecycle
|
||||
|
||||
// ── KPIs ──────────────────────────────────────────────────
|
||||
function updateKpis () {
|
||||
@@ -1230,10 +1330,39 @@ function updateKpis () {
|
||||
kpis.total = all.length
|
||||
kpis.active = all.filter(p => p.status === 'Ativo').length
|
||||
kpis.inactive = all.filter(p => p.status === 'Inativo').length
|
||||
kpis.archived = all.filter(p => p.status === 'Arquivado').length
|
||||
const dates = all.map(p => (p.last_attended_at || '').slice(0, 10)).filter(Boolean).sort()
|
||||
kpis.latestLastAttended = dates.length ? dates[dates.length - 1] : ''
|
||||
}
|
||||
|
||||
// ── Grupos view ───────────────────────────────────────────
|
||||
const groupedPatientsView = computed(() => {
|
||||
const all = patients.value || []
|
||||
const grpMap = new Map()
|
||||
for (const g of (groups.value || [])) {
|
||||
grpMap.set(g.id, { id: g.id, name: g.name || g.nome, color: g.color || g.cor, patients: [], isSystem: !!g.is_system })
|
||||
}
|
||||
const ungrouped = { id: '__none__', name: 'Sem grupo', color: null, patients: [], isSystem: false }
|
||||
for (const p of all) {
|
||||
const gs = p.groups || []
|
||||
if (!gs.length) {
|
||||
ungrouped.patients.push(p)
|
||||
} else {
|
||||
for (const g of gs) {
|
||||
if (grpMap.has(g.id)) grpMap.get(g.id).patients.push(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = [...grpMap.values()].filter(g => g.patients.length > 0).sort((a, b) => b.patients.length - a.patients.length)
|
||||
if (ungrouped.patients.length > 0) result.push(ungrouped)
|
||||
return result
|
||||
})
|
||||
|
||||
function grpColorStyle (color) {
|
||||
if (!color) return { background: 'var(--surface-border)' }
|
||||
return { background: color.startsWith('#') ? color : `#${color}` }
|
||||
}
|
||||
|
||||
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000
|
||||
function isRecent (row) {
|
||||
if (!row?.created_at) return false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Chip from 'primevue/chip'
|
||||
@@ -14,6 +15,9 @@ import Popover from 'primevue/popover'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const toast = useToast()
|
||||
const loadError = ref('')
|
||||
|
||||
@@ -40,10 +44,6 @@ function dash(v) {
|
||||
return s ? s : '—'
|
||||
}
|
||||
|
||||
/**
|
||||
* Pega o primeiro campo "existente e não-vazio" em ordem.
|
||||
* Útil pra transição EN -> PT sem quebrar o prontuário.
|
||||
*/
|
||||
function pick(obj, keys = []) {
|
||||
for (const k of keys) {
|
||||
const v = obj?.[k]
|
||||
@@ -56,7 +56,7 @@ function pick(obj, keys = []) {
|
||||
// accordion (pode abrir vários) + scroll
|
||||
// ------------------------------------------------------
|
||||
const accordionValues = ['0', '1', '2', '3', '4', '5']
|
||||
const activeValues = ref(['0']) // começa com o primeiro aberto
|
||||
const activeValues = ref(['0'])
|
||||
const activeValue = computed(() => activeValues.value?.[0] ?? null)
|
||||
|
||||
const panelHeaderRefs = ref([])
|
||||
@@ -71,9 +71,6 @@ function toggleAllAccordions() {
|
||||
activeValues.value = allOpen.value ? [] : [...accordionValues]
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre o painel clicado (e fecha os outros).
|
||||
*/
|
||||
async function openPanel(i) {
|
||||
const v = String(i)
|
||||
activeValues.value = [v]
|
||||
@@ -125,9 +122,8 @@ onBeforeUnmount(() => {
|
||||
const loading = ref(false)
|
||||
const patientFull = ref(null)
|
||||
|
||||
// ✅ agora suporta múltiplos grupos
|
||||
const groups = ref([]) // [{id,name}]
|
||||
const tags = ref([]) // [{id,name,color}]
|
||||
const groups = ref([])
|
||||
const tags = ref([])
|
||||
|
||||
const patientData = computed(() => patientFull.value || props.patient || {})
|
||||
|
||||
@@ -169,26 +165,17 @@ function fmtRG(v) {
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Parser tolerante:
|
||||
* - ISO date: YYYY-MM-DD
|
||||
* - ISO datetime: YYYY-MM-DDTHH:mm...
|
||||
* - BR: DD-MM-YYYY
|
||||
* - BR: DD/MM/YYYY
|
||||
*/
|
||||
function parseDateLoose(v) {
|
||||
if (!v) return null
|
||||
const s = String(v).trim()
|
||||
if (!s) return null
|
||||
|
||||
// ISO (date ou datetime)
|
||||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||||
const iso = s.slice(0, 10)
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// BR DD-MM-YYYY
|
||||
let m = s.match(/^(\d{2})-(\d{2})-(\d{4})$/)
|
||||
if (m) {
|
||||
const dd = Number(m[1]); const mm = Number(m[2]); const yy = Number(m[3])
|
||||
@@ -196,7 +183,6 @@ function parseDateLoose(v) {
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// BR DD/MM/YYYY
|
||||
m = s.match(/^(\d{2})\/(\d{2})\/(\d{4})$/)
|
||||
if (m) {
|
||||
const dd = Number(m[1]); const mm = Number(m[2]); const yy = Number(m[3])
|
||||
@@ -204,7 +190,6 @@ function parseDateLoose(v) {
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
|
||||
// fallback Date
|
||||
const d = new Date(s)
|
||||
return Number.isNaN(d.getTime()) ? null : d
|
||||
}
|
||||
@@ -308,6 +293,14 @@ const observacaoResponsavel = computed(() => pick(patientData.value, ['observaca
|
||||
// notas internas
|
||||
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']))
|
||||
|
||||
// Título do dialog
|
||||
const dialogTitle = computed(() => {
|
||||
const nome = dash(nomeCompleto.value)
|
||||
const cpf = patientData.value?.cpf ? ` ${fmtCPF(patientData.value.cpf)}` : ''
|
||||
const age = ageLabel.value !== '—' ? ` · Idade: ${ageLabel.value}` : ''
|
||||
return `Prontuário: ${nome}${cpf}${age}`
|
||||
})
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Sessões do paciente (integração agenda)
|
||||
// ------------------------------------------------------
|
||||
@@ -369,7 +362,6 @@ async function loadSessions (patientId) {
|
||||
if (error) throw error
|
||||
sessions.value = data || []
|
||||
} catch (e) {
|
||||
// falha silenciosa — prontuário continua sem a seção de sessões
|
||||
sessions.value = []
|
||||
} finally {
|
||||
sessionsLoading.value = false
|
||||
@@ -406,9 +398,6 @@ async function getPatientRelations(id) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ patient_groups NÃO tem "name", tem "nome"
|
||||
*/
|
||||
async function getGroupsByIds(ids) {
|
||||
if (!ids?.length) return []
|
||||
const { data, error } = await supabase
|
||||
@@ -425,10 +414,6 @@ async function getGroupsByIds(ids) {
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ patient_tags NÃO tem "name/color", tem "nome/cor"
|
||||
* Mas aceitamos também "color" por segurança.
|
||||
*/
|
||||
async function getTagsByIds(ids) {
|
||||
if (!ids?.length) return []
|
||||
const { data, error } = await supabase
|
||||
@@ -493,15 +478,31 @@ function close() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function editPatient() {
|
||||
const id = patientData.value?.id
|
||||
if (!id) return
|
||||
|
||||
// Detecta área pelo path atual — mesmo padrão do PatientsCadastroPage
|
||||
const isTherapist = String(route.path || '').startsWith('/therapist')
|
||||
|
||||
close()
|
||||
|
||||
if (isTherapist) {
|
||||
router.push({ name: 'therapist-patients-edit', params: { id } })
|
||||
} else {
|
||||
router.push({ name: 'admin-pacientes-cadastro-edit', params: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------
|
||||
// ✅ Tags com cor + contraste
|
||||
// Tags com cor + contraste
|
||||
// ---------------------------------------------
|
||||
function normalizeHexColor(c) {
|
||||
const s = String(c ?? '').trim()
|
||||
if (!s) return ''
|
||||
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s
|
||||
if (/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return `#${s}`
|
||||
return s // pode ser 'rgb(...)' ou nome de cor
|
||||
return s
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
@@ -516,7 +517,6 @@ function hexToRgb(hex) {
|
||||
}
|
||||
|
||||
function relativeLuminance({ r, g, b }) {
|
||||
// sRGB -> linear
|
||||
const srgb = [r, g, b].map(v => v / 255).map(c => (
|
||||
c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
||||
))
|
||||
@@ -526,7 +526,7 @@ function relativeLuminance({ r, g, b }) {
|
||||
function bestTextColor(bg) {
|
||||
const c = normalizeHexColor(bg)
|
||||
const rgb = hexToRgb(c)
|
||||
if (!rgb) return '#0f172a' // slate-900 fallback
|
||||
if (!rgb) return '#0f172a'
|
||||
const lum = relativeLuminance(rgb)
|
||||
return lum < 0.45 ? '#ffffff' : '#0f172a'
|
||||
}
|
||||
@@ -568,130 +568,125 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
<Dialog
|
||||
v-model:visible="model"
|
||||
modal
|
||||
:draggable="false"
|
||||
maximizable
|
||||
:header="dialogTitle"
|
||||
:style="{ width: '96vw', maxWidth: '1400px' }"
|
||||
:contentStyle="{ padding: 0 }"
|
||||
pt:mask:class="backdrop-blur-sm"
|
||||
pt:header:class="border-b border-[var(--surface-border,#e2e8f0)] px-4 py-3"
|
||||
pt:title:class="text-[0.95rem] font-bold text-[var(--text-color)] truncate"
|
||||
@hide="close"
|
||||
>
|
||||
<Toast />
|
||||
|
||||
<div class="bg-gray-100">
|
||||
<div class="p-3">
|
||||
<Card class="shadow-sm rounded-2xl overflow-hidden">
|
||||
<template #title>
|
||||
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<!-- ── CONTEÚDO ── -->
|
||||
<div class="bg-[var(--surface-ground,#f5f7fa)]">
|
||||
<div class="p-3 md:p-4">
|
||||
|
||||
<div v-if="loadError" class="mb-3 rounded-md border border-red-200 bg-white px-3.5 py-3">
|
||||
<div class="font-semibold text-red-600 text-sm">Falha ao carregar</div>
|
||||
<div class="mt-0.5 text-xs text-[var(--text-color-secondary)]">{{ loadError }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-16 text-[var(--text-color-secondary)] gap-2 text-sm">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 xl:grid-cols-[260px_1fr] gap-3">
|
||||
|
||||
<!-- ── SIDEBAR ── -->
|
||||
<aside class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-3.5 xl:sticky xl:top-2 xl:self-start">
|
||||
|
||||
<!-- Avatar + info rápida -->
|
||||
<div class="flex items-center gap-3 pb-3.5 mb-3.5 border-b border-[var(--surface-border,#e2e8f0)] xl:flex-col xl:items-center xl:gap-2 xl:text-center">
|
||||
<div class="w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] flex-shrink-0">
|
||||
<img :src="avatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold text-[var(--text-color)] truncate">{{ dash(nomeCompleto) }}</div>
|
||||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">{{ ageLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grupo e Tags -->
|
||||
<div class="text-sm text-[var(--text-color-secondary)] flex flex-col gap-2.5">
|
||||
<div>
|
||||
<div class="text-lg font-semibold leading-none">Prontuário</div>
|
||||
<div class="mt-1 text-sm text-slate-600">
|
||||
Paciente: <b>{{ dash(nomeCompleto) }}</b> · Idade: <b>{{ ageLabel }}</b>
|
||||
</div>
|
||||
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">{{ groupCountLabel }}</span>
|
||||
<div class="mt-1 text-xs font-medium text-[var(--text-color)]">{{ groupLabel }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
:label="allOpen ? 'Fechar seções' : 'Abrir seções'"
|
||||
:icon="allOpen ? 'pi pi-angle-double-up' : 'pi pi-angle-double-down'"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="toggleAllAccordions"
|
||||
/>
|
||||
<Button label="Copiar resumo" icon="pi pi-copy" severity="secondary" outlined @click="copyResumo" />
|
||||
<Button label="Fechar" icon="pi pi-times" severity="secondary" outlined @click="close" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div v-if="loadError" class="m-3 rounded-xl border border-red-200 bg-white p-3">
|
||||
<div class="font-semibold text-red-600">Falha ao carregar</div>
|
||||
<div class="mt-1 text-sm text-slate-700">{{ loadError }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="p-4 text-slate-600">Carregando…</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 xl:grid-cols-[260px_1fr] gap-4 p-3">
|
||||
<!-- sidebar -->
|
||||
<aside class="xl:sticky xl:top-2 self-start">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-3">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="h-24 w-24 rounded-full overflow-hidden border border-slate-200 bg-slate-50">
|
||||
<img :src="avatarUrl" alt="avatar" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
|
||||
<div class="w-full text-sm text-slate-700">
|
||||
<div><b>{{ groupCountLabel }}:</b> {{ groupLabel }}</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<b>Tags:</b>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<div>
|
||||
<span class="text-xs font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-60">Tags</span>
|
||||
<div class="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<Chip
|
||||
v-for="t in tags"
|
||||
:key="t.id"
|
||||
:label="t.name"
|
||||
:style="tagStyle(t)"
|
||||
class="!border !rounded-full"
|
||||
class="!border !rounded-full !text-xs"
|
||||
/>
|
||||
<span v-if="!tags?.length" class="text-slate-500">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="!tags?.length" class="text-xs text-[var(--text-color-secondary)]">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- >=1200px -->
|
||||
<div v-if="!isCompact" class="mt-3 flex flex-col gap-2">
|
||||
<!-- Nav — desktop (≥ xl) -->
|
||||
<div v-if="!isCompact" class="mt-3.5 pt-3.5 border-t border-[var(--surface-border,#e2e8f0)] flex flex-col gap-1">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="w-full rounded-xl border bg-white px-3 py-2 text-left flex items-center gap-2 transition
|
||||
hover:-translate-y-[1px] hover:bg-slate-50"
|
||||
:class="activeValue === item.value ? 'border-primary-300 bg-primary-50' : 'border-slate-200'"
|
||||
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm border transition-colors duration-100"
|
||||
:class="activeValue === item.value
|
||||
? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold'
|
||||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="openPanel(Number(item.value))"
|
||||
>
|
||||
<i :class="item.icon" class="opacity-80"></i>
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
<i :class="item.icon" class="text-sm opacity-70 flex-shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- main -->
|
||||
<main class="min-w-0">
|
||||
<!-- <1200px -->
|
||||
<div v-if="isCompact" class="sticky top-2 z-10 rounded-2xl border border-slate-200 bg-white p-3 mb-3">
|
||||
<!-- ── MAIN ── -->
|
||||
<main class="min-w-0 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
|
||||
|
||||
<!-- Nav compacto (<xl) -->
|
||||
<div v-if="isCompact" class="sticky top-2 z-10 border-b border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3.5 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
class="w-full"
|
||||
class="w-full !rounded-full"
|
||||
icon="pi pi-chevron-down"
|
||||
iconPos="right"
|
||||
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
|
||||
@click="toggleNav($event)"
|
||||
/>
|
||||
|
||||
<Popover ref="navPopover">
|
||||
<div class="min-w-[260px] flex flex-col gap-3">
|
||||
<span class="font-medium block">Seções</span>
|
||||
<ul class="list-none p-0 m-0 flex flex-col gap-1">
|
||||
<li
|
||||
<div class="flex min-w-[240px] flex-col gap-1 p-1">
|
||||
<button
|
||||
v-for="item in navItems"
|
||||
:key="item.value"
|
||||
class="flex items-center gap-2 px-2 py-2 cursor-pointer rounded-lg hover:bg-slate-100"
|
||||
:class="activeValue === item.value ? 'bg-slate-100' : ''"
|
||||
type="button"
|
||||
class="flex items-center gap-2.5 rounded-md px-3 py-2 text-left text-sm border border-transparent cursor-pointer"
|
||||
:class="activeValue === item.value ? 'bg-indigo-500/8 border-indigo-300/40 text-indigo-700 font-semibold' : 'text-[var(--text-color)] hover:bg-[var(--surface-ground,#f8fafc)] font-medium'"
|
||||
@click="selectNav(item)"
|
||||
>
|
||||
<i :class="item.icon" class="opacity-85"></i>
|
||||
<span class="font-medium">{{ item.label }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<i :class="item.icon" class="text-sm opacity-70 flex-shrink-0" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div class="p-4">
|
||||
<Accordion multiple v-model:value="activeValues">
|
||||
|
||||
<!-- 1. INFORMAÇÕES PESSOAIS -->
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 0)">1. INFORMAÇÕES PESSOAIS</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
@@ -817,7 +812,7 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</IconField>
|
||||
<label>{{ groupCountLabel }}</label>
|
||||
</FloatLabel>
|
||||
<small class="text-slate-500">Utilizado para importar o formulário de anamnese</small>
|
||||
<small class="text-[var(--text-color-secondary)] text-xs mt-1 block">Utilizado para importar o formulário de anamnese</small>
|
||||
</div>
|
||||
|
||||
<div class="min-w-0">
|
||||
@@ -858,10 +853,11 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 2. ENDEREÇO -->
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 1)">2. ENDEREÇO</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||
<div class="min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
@@ -945,10 +941,11 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 3. DADOS ADICIONAIS -->
|
||||
<AccordionPanel value="2">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 2)">3. DADOS ADICIONAIS</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||
<div class="min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
@@ -1002,10 +999,11 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 4. RESPONSÁVEL -->
|
||||
<AccordionPanel value="3">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 3)">4. RESPONSÁVEL</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 pt-1">
|
||||
<div class="md:col-span-2 min-w-0">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
@@ -1046,13 +1044,14 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 5. ANOTAÇÕES INTERNAS -->
|
||||
<AccordionPanel value="4">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 4)">5. ANOTAÇÕES INTERNAS</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<small class="block mb-3 text-slate-500">
|
||||
Este campo é interno e NÃO aparece no cadastro externo.
|
||||
</small>
|
||||
|
||||
<div class="mb-2.5 flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] opacity-70">
|
||||
<i class="pi pi-lock" />
|
||||
Campo interno: não aparece no cadastro externo.
|
||||
</div>
|
||||
<FloatLabel variant="on">
|
||||
<Textarea :modelValue="dash(notasInternas)" rows="7" class="w-full" variant="filled" readonly />
|
||||
<label>Notas internas</label>
|
||||
@@ -1060,22 +1059,27 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<!-- 6. SESSÕES -->
|
||||
<AccordionPanel value="5">
|
||||
<AccordionHeader :ref="el => setPanelHeaderRef(el, 5)">6. SESSÕES</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<div v-if="sessionsLoading" class="text-slate-500 text-sm py-2">Carregando sessões…</div>
|
||||
<div v-else-if="!sessions.length" class="text-slate-500 text-sm py-2">Nenhuma sessão registrada para este paciente.</div>
|
||||
<div v-if="sessionsLoading" class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] py-2">
|
||||
<i class="pi pi-spin pi-spinner" /> Carregando sessões…
|
||||
</div>
|
||||
<div v-else-if="!sessions.length" class="text-sm text-[var(--text-color-secondary)] py-2">
|
||||
Nenhuma sessão registrada para este paciente.
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="s in sessions"
|
||||
:key="s.id"
|
||||
class="rounded-xl border border-slate-200 bg-white px-4 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
|
||||
class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-ground,#f8fafc)] px-3.5 py-3 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="font-medium text-slate-800">
|
||||
<div class="text-sm font-semibold text-[var(--text-color)]">
|
||||
{{ s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }}
|
||||
</div>
|
||||
<div class="text-sm text-slate-500 flex flex-wrap gap-x-3 gap-y-1">
|
||||
<div class="text-xs text-[var(--text-color-secondary)] flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span><i class="pi pi-calendar mr-1 opacity-60" />{{ fmtDateTimeBR(s.inicio_em) }}</span>
|
||||
<span v-if="sessionDuration(s.inicio_em, s.fim_em)">
|
||||
<i class="pi pi-clock mr-1 opacity-60" />{{ sessionDuration(s.inicio_em, s.fim_em) }}
|
||||
@@ -1084,7 +1088,7 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
<i class="pi pi-video mr-1 opacity-60" />{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="s.observacoes" class="text-sm text-slate-600 mt-1 line-clamp-2">{{ s.observacoes }}</div>
|
||||
<div v-if="s.observacoes" class="text-xs text-[var(--text-color-secondary)] mt-0.5 line-clamp-2">{{ s.observacoes }}</div>
|
||||
</div>
|
||||
<Tag
|
||||
:value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
|
||||
@@ -1095,12 +1099,56 @@ Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
</Accordion>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── FOOTER ── -->
|
||||
<template #footer>
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<!-- Esquerda: Abrir seções -->
|
||||
<Button
|
||||
:label="allOpen ? 'Fechar seções' : 'Abrir seções'"
|
||||
:icon="allOpen ? 'pi pi-angle-double-up' : 'pi pi-angle-double-down'"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="toggleAllAccordions"
|
||||
/>
|
||||
|
||||
<!-- Direita: Copiar resumo + Editar + Fechar -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
label="Copiar resumo"
|
||||
icon="pi pi-copy"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="copyResumo"
|
||||
/>
|
||||
<Button
|
||||
label="Editar paciente"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full"
|
||||
@click="editPatient"
|
||||
/>
|
||||
<Button
|
||||
label="Fechar"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
class="rounded-full"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -17,7 +17,7 @@ const { layoutState } = useLayout()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
// ======================================================
|
||||
// ✅ Blindagem anti-“menu some”
|
||||
// ✅ Blindagem anti-"menu some"
|
||||
// - se o menuStore.model piscar como [], mantém o último menu válido
|
||||
// - evita sumiço ao entrar em /admin/clinic/features (reset momentâneo)
|
||||
// ======================================================
|
||||
|
||||
@@ -6,20 +6,17 @@ import Popover from 'primevue/popover'
|
||||
|
||||
import { sessionUser, sessionRole } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'sidebar' }
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const pop = ref(null)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// RBAC (Tenant): fonte da verdade para permissões por papel
|
||||
// ------------------------------------------------------
|
||||
const { role, canSee } = useRoleGuard()
|
||||
|
||||
// ------------------------------------------------------
|
||||
// UI labels (nome/iniciais)
|
||||
// ------------------------------------------------------
|
||||
const initials = computed(() => {
|
||||
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
|
||||
const parts = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
@@ -33,167 +30,181 @@ const label = computed(() => {
|
||||
return name || sessionUser.value?.email || 'Conta'
|
||||
})
|
||||
|
||||
/**
|
||||
* sublabel:
|
||||
* Prefere exibir o papel do TENANT (role do useRoleGuard),
|
||||
* porque governa a UI dentro da clínica.
|
||||
*/
|
||||
const sublabel = computed(() => {
|
||||
const r = role.value || sessionRole.value
|
||||
if (!r) return 'Sessão'
|
||||
|
||||
// tenant roles
|
||||
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
|
||||
if (r === 'therapist') return 'Terapeuta'
|
||||
|
||||
// portal/global roles
|
||||
if (r === 'portal_user') return 'Portal'
|
||||
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
|
||||
|
||||
if (r === 'portal_user' || r === 'patient') return 'Portal'
|
||||
return r
|
||||
})
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Popover helpers
|
||||
// ------------------------------------------------------
|
||||
function toggle (e) {
|
||||
pop.value?.toggle(e)
|
||||
}
|
||||
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
|
||||
|
||||
function close () {
|
||||
try { pop.value?.hide() } catch {}
|
||||
}
|
||||
function toggle (e) { pop.value?.toggle(e) }
|
||||
function close () { try { pop.value?.hide() } catch {} }
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Navegação segura (resolve antes; fallback se não existir)
|
||||
// ------------------------------------------------------
|
||||
async function safePush (target, fallback) {
|
||||
try {
|
||||
const r = router.resolve(target)
|
||||
if (r?.matched?.length) return await router.push(target)
|
||||
} catch {}
|
||||
|
||||
if (fallback) {
|
||||
try { return await router.push(fallback) } catch {}
|
||||
}
|
||||
|
||||
if (fallback) { try { return await router.push(fallback) } catch {} }
|
||||
return router.push('/')
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Actions
|
||||
// ------------------------------------------------------
|
||||
function goMyProfile () {
|
||||
close()
|
||||
safePush({ name: 'account-profile' }, '/account/profile')
|
||||
}
|
||||
|
||||
function goMyProfile () { close(); safePush({ name: 'account-profile' }, '/account/profile') }
|
||||
function goSecurity () { close(); safePush({ name: 'account-security' }, '/account/security') }
|
||||
function goSettings () {
|
||||
close()
|
||||
|
||||
// ✅ Configurações é RBAC (quem pode ver, vê)
|
||||
if (canSee('settings.view')) {
|
||||
return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
|
||||
}
|
||||
|
||||
// ✅ quem não pode (ex.: paciente), manda pro portal correto
|
||||
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings')
|
||||
return safePush({ name: 'portal-sessoes' }, '/portal')
|
||||
}
|
||||
|
||||
function goSecurity () {
|
||||
close()
|
||||
|
||||
// ✅ Segurança é "Account": todos podem acessar
|
||||
return safePush(
|
||||
{ name: 'account-security' },
|
||||
'/account/security'
|
||||
)
|
||||
}
|
||||
|
||||
async function signOut () {
|
||||
close()
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} catch {
|
||||
// se falhar, ainda assim manda pro login
|
||||
} finally {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
try { await supabase.auth.signOut() } catch {}
|
||||
finally { router.push('/auth/login') }
|
||||
}
|
||||
|
||||
defineExpose({ toggle })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- ── SIDEBAR: trigger + popover ── -->
|
||||
<template v-if="variant === 'sidebar'">
|
||||
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition"
|
||||
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150"
|
||||
@click="toggle"
|
||||
>
|
||||
<!-- avatar -->
|
||||
<img
|
||||
v-if="sessionUser?.user_metadata?.avatar_url"
|
||||
:src="sessionUser.user_metadata.avatar_url"
|
||||
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
|
||||
alt="avatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
|
||||
<!-- labels -->
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
|
||||
<div v-else class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold">{{ initials }}</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="truncate text-sm font-semibold text-[var(--text-color)]">
|
||||
{{ label }}
|
||||
<div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
|
||||
<div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
|
||||
</div>
|
||||
<div class="truncate text-xs text-[var(--text-color-secondary)]">
|
||||
{{ sublabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<i class="pi pi-angle-up text-xs opacity-70" />
|
||||
<i class="pi pi-angle-up text-xs opacity-40" />
|
||||
</button>
|
||||
|
||||
<Popover ref="pop" appendTo="body">
|
||||
<div class="min-w-[220px] p-1">
|
||||
<Button
|
||||
v-if="canSee('settings.view')"
|
||||
label="Configurações"
|
||||
icon="pi pi-cog"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goSettings"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Segurança"
|
||||
icon="pi pi-shield"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goSecurity"
|
||||
/>
|
||||
|
||||
<Button
|
||||
label="Meu Perfil"
|
||||
icon="pi pi-user"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goMyProfile"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-[var(--surface-border)]" />
|
||||
|
||||
<Button
|
||||
label="Sair"
|
||||
icon="pi pi-sign-out"
|
||||
severity="danger"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="signOut"
|
||||
/>
|
||||
<!-- 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: só o popover, trigger externo ── -->
|
||||
<template v-else>
|
||||
<Popover ref="pop" appendTo="body">
|
||||
<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>
|
||||
</Popover>
|
||||
</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>
|
||||
@@ -8,6 +8,7 @@ import Popover from 'primevue/popover'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
|
||||
const { layoutState, isDesktop } = useLayout()
|
||||
const router = useRouter()
|
||||
@@ -15,6 +16,15 @@ const pop = ref(null)
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['quick-create'])
|
||||
|
||||
@@ -102,7 +112,7 @@ const showProBadge = computed(() => {
|
||||
try {
|
||||
return !entitlementsStore.has(feature)
|
||||
} catch {
|
||||
// se der erro, não mostra (evita “PRO fantasma”)
|
||||
// se der erro, não mostra (evita "PRO fantasma")
|
||||
return false
|
||||
}
|
||||
})
|
||||
@@ -221,6 +231,14 @@ async function irCadastroCompleto () {
|
||||
PRO
|
||||
</span>
|
||||
|
||||
<!-- Badge contador (agenda hoje / cadastros / agendamentos) -->
|
||||
<span
|
||||
v-if="menuBadgeLabel(item)"
|
||||
class="ml-auto text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none"
|
||||
>
|
||||
{{ menuBadgeLabel(item) }}
|
||||
</span>
|
||||
|
||||
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
|
||||
</component>
|
||||
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<!-- src/layout/AppRail.vue — Mini icon rail (Layout 2) -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { sessionUser } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutConfig, layoutState, isDesktop } = useLayout()
|
||||
const router = useRouter()
|
||||
const { layoutState } = useLayout()
|
||||
|
||||
// ── Seções do rail (derivadas do model) ─────────────────────
|
||||
const railSections = computed(() => {
|
||||
@@ -38,7 +34,6 @@ const initials = computed(() => {
|
||||
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
|
||||
return (a + b).toUpperCase()
|
||||
})
|
||||
|
||||
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
|
||||
|
||||
// ── Início (fixo) ────────────────────────────────────────────
|
||||
@@ -74,20 +69,9 @@ function isActiveSectionOrChild (section) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Popover do usuário (rodapé) ───────────────────────────────
|
||||
const userPop = ref(null)
|
||||
function toggleUserPop (e) { userPop.value?.toggle(e) }
|
||||
|
||||
function goTo (path) {
|
||||
try { userPop.value?.hide() } catch {}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function signOut () {
|
||||
try { userPop.value?.hide() } catch {}
|
||||
try { await supabase.auth.signOut() } catch {}
|
||||
router.push('/auth/login')
|
||||
}
|
||||
// ── Menu do usuário (rodapé) ─────────────────────────────────
|
||||
const footerPanel = ref(null)
|
||||
function toggleUserMenu (e) { footerPanel.value?.toggle(e) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,47 +115,26 @@ async function signOut () {
|
||||
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
|
||||
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
|
||||
aria-label="Configurações"
|
||||
@click="goTo('/configuracoes')"
|
||||
@click="$router.push('/configuracoes')"
|
||||
>
|
||||
<i class="pi pi-fw pi-cog" />
|
||||
</button>
|
||||
|
||||
<!-- Avatar / user -->
|
||||
<!-- Avatar — trigger do menu de usuário -->
|
||||
<button
|
||||
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
|
||||
v-tooltip.right="{ value: userName, showDelay: 0 }"
|
||||
:aria-label="userName"
|
||||
@click="toggleUserPop"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
|
||||
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Popover usuário ────────────────────────────────── -->
|
||||
<Popover ref="userPop" appendTo="body">
|
||||
<div class="min-w-[210px] p-1 flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2.5 px-2.5 py-2 pb-2.5">
|
||||
<div class="w-9 h-9 rounded-[9px] overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center border border-[var(--surface-border)]">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" />
|
||||
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[0.83rem] font-semibold text-[var(--text-color)] truncate">{{ userName }}</div>
|
||||
<div class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">{{ sessionUser?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Menu de usuário (popup via AppMenuFooterPanel) ── -->
|
||||
<AppMenuFooterPanel ref="footerPanel" variant="rail" />
|
||||
|
||||
<div class="h-px bg-[var(--surface-border)] my-0.5" />
|
||||
|
||||
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
|
||||
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
|
||||
|
||||
<div class="h-px bg-[var(--surface-border)] my-0.5" />
|
||||
|
||||
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
|
||||
</div>
|
||||
</Popover>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutState } = useLayout()
|
||||
const entitlements = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
}
|
||||
|
||||
// ── Seção ativa ──────────────────────────────────────────────
|
||||
const currentSection = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
@@ -372,6 +382,7 @@ async function goToResult (r) {
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -388,6 +399,7 @@ async function goToResult (r) {
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
|
||||
</button>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutState, hideMobileMenu } = useLayout()
|
||||
const entitlements = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
}
|
||||
|
||||
const sections = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
return model
|
||||
@@ -389,6 +399,7 @@ watch(() => route.path, () => hideMobileMenu())
|
||||
<i v-if="child.icon" :class="child.icon" class="rs__item-icon" />
|
||||
<span>{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="rs__pro">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="rs__badge">{{ menuBadgeLabel(child) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -405,6 +416,7 @@ watch(() => route.path, () => hideMobileMenu())
|
||||
<i v-if="item.icon" :class="item.icon" class="rs__item-icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="rs__pro">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="rs__badge">{{ menuBadgeLabel(item) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -672,6 +684,15 @@ watch(() => route.path, () => hideMobileMenu())
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.rs__badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Slide-in da esquerda ────────────────────────────────── */
|
||||
.rs-slide-enter-active,
|
||||
|
||||
@@ -11,7 +11,7 @@ let outsideClickListener = null
|
||||
|
||||
// ✅ rota mudou:
|
||||
// - atualiza activePath sempre (desktop e mobile)
|
||||
// - fecha menu SOMENTE no mobile (evita “sumir” no desktop / inconsistências)
|
||||
// - fecha menu SOMENTE no mobile (evita "sumir" no desktop / inconsistências)
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
|
||||
@@ -495,7 +495,7 @@ async function logout () {
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Bootstrap entitlements (resolve “menu não alterna” sem depender do guard)
|
||||
* ✅ Bootstrap entitlements (resolve "menu não alterna" sem depender do guard)
|
||||
* - se tem tenant ativo => carrega tenant entitlements
|
||||
* - senão => carrega user entitlements
|
||||
*/
|
||||
|
||||
@@ -44,7 +44,7 @@ const layoutState = reactive({
|
||||
*
|
||||
* Motivo: você aplica tema cedo (main.js / user_settings) e depois
|
||||
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
|
||||
* isDarkTheme pode ficar “mentindo”.
|
||||
* isDarkTheme pode ficar "mentindo".
|
||||
*/
|
||||
let _syncedDarkFromDomOnce = false
|
||||
function syncDarkFromDomOnce () {
|
||||
|
||||
@@ -46,7 +46,7 @@ function resolveMenu (builder, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
// core menu anti-“sumir”
|
||||
// core menu anti-"sumir"
|
||||
function coreMenu () {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -11,7 +11,8 @@ export default function adminMenu (ctx = {}) {
|
||||
label: 'Agenda da Clínica',
|
||||
icon: 'pi pi-fw pi-calendar',
|
||||
to: { name: 'admin-agenda-clinica' },
|
||||
feature: 'agenda.view'
|
||||
feature: 'agenda.view',
|
||||
badgeKey: 'agendaHoje'
|
||||
},
|
||||
|
||||
// ✅ Compromissos determinísticos (tipos)
|
||||
@@ -41,7 +42,7 @@ export default function adminMenu (ctx = {}) {
|
||||
{ label: 'Grupos', icon: 'pi pi-fw pi-sitemap', to: { name: 'admin-pacientes-grupos' } },
|
||||
{ label: 'Tags', icon: 'pi pi-fw pi-tags', to: { name: 'admin-pacientes-tags' } },
|
||||
{ label: 'Link Externo', icon: 'pi pi-fw pi-link', to: { name: 'admin-pacientes-link-externo' } },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' } }
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: { name: 'admin-pacientes-recebidos' }, badgeKey: 'cadastrosRecebidos' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -79,7 +80,8 @@ export default function adminMenu (ctx = {}) {
|
||||
icon: 'pi pi-fw pi-inbox',
|
||||
to: { name: 'admin-agendamentos-recebidos' },
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
proBadge: true,
|
||||
badgeKey: 'agendamentosRecebidos'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export default [
|
||||
{
|
||||
label: 'Agenda',
|
||||
items: [
|
||||
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true },
|
||||
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true, badgeKey: 'agendaHoje' },
|
||||
{ label: 'Compromissos', icon: 'pi pi-fw pi-clock', to: '/therapist/agenda/compromissos', feature: 'agenda.view', proBadge: true }
|
||||
]
|
||||
},
|
||||
@@ -23,7 +23,7 @@ export default [
|
||||
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
|
||||
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
|
||||
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos' }
|
||||
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos', badgeKey: 'cadastrosRecebidos' }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -42,7 +42,8 @@ export default [
|
||||
icon: 'pi pi-fw pi-inbox',
|
||||
to: '/therapist/agendamentos-recebidos',
|
||||
feature: 'online_scheduling.manage',
|
||||
proBadge: true
|
||||
proBadge: true,
|
||||
badgeKey: 'agendamentosRecebidos'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
* - Entitlements (plano): usuário poderia acessar se tivesse feature → manda pro /upgrade
|
||||
*
|
||||
* Por que isso existe?
|
||||
* - Evitar o bug clássico: “pessoa sem permissão caiu no /upgrade”
|
||||
* - Evitar o bug clássico: "pessoa sem permissão caiu no /upgrade"
|
||||
* - Padronizar o comportamento do app em um único lugar
|
||||
* - Deixar claro: RBAC ≠ Plano
|
||||
*
|
||||
* Convenção recomendada:
|
||||
* - RBAC (role): sempre é bloqueio (403) OU home do papel (UX mais “suave”)
|
||||
* - RBAC (role): sempre é bloqueio (403) OU home do papel (UX mais "suave")
|
||||
* - Plano (feature): sempre é upgrade (porque o usuário *poderia* ter acesso pagando)
|
||||
*/
|
||||
|
||||
@@ -35,16 +35,16 @@ export function roleHomePath (role) {
|
||||
/**
|
||||
* RBAC (papel) → padrão: acesso negado (403).
|
||||
*
|
||||
* Se você preferir UX “suave”, pode mandar para a home do papel.
|
||||
* Se você preferir UX "suave", pode mandar para a home do papel.
|
||||
* Eu deixei as duas opções:
|
||||
* - use403 = true → sempre /pages/access (recomendado para clareza)
|
||||
* - use403 = false → home do papel (útil quando você quer “auto-corrigir” navegação)
|
||||
* - use403 = false → home do papel (útil quando você quer "auto-corrigir" navegação)
|
||||
*/
|
||||
export function denyByRole ({ to, currentRole, use403 = true } = {}) {
|
||||
// ✅ padrão forte: 403 (não é caso de upgrade)
|
||||
if (use403) return { path: '/pages/access' }
|
||||
|
||||
// modo “suave”: manda pra home do papel
|
||||
// modo "suave": manda pra home do papel
|
||||
const fallback = roleHomePath(currentRole)
|
||||
|
||||
// evita loop: se já está no fallback, manda pra página de acesso negado
|
||||
|
||||
@@ -635,7 +635,7 @@ export function applyGuards (router) {
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue “por engano”
|
||||
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue "por engano"
|
||||
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
|
||||
sessionStorage.setItem('redirect_after_login', to.fullPath)
|
||||
_perfEnd()
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function setSlotBloqueado(ownerId, diaSemana, horaInicio, isBloquea
|
||||
return true
|
||||
}
|
||||
|
||||
// “desbloquear”: deletar (ou marcar ativo=false; aqui vou deletar por simplicidade)
|
||||
// "desbloquear": deletar (ou marcar ativo=false; aqui vou deletar por simplicidade)
|
||||
const { error } = await supabase
|
||||
.from('agenda_slots_bloqueados_semanais')
|
||||
.delete()
|
||||
|
||||
85
src/sql-arquivos/patient_lifecycle.sql
Normal file
85
src/sql-arquivos/patient_lifecycle.sql
Normal 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;
|
||||
@@ -48,7 +48,7 @@ export const surfaces = [
|
||||
]
|
||||
|
||||
/**
|
||||
* ✅ noir: primary “vira” surface (o bloco que você pediu pra ficar aqui)
|
||||
* ✅ noir: primary "vira" surface (o bloco que você pediu pra ficar aqui)
|
||||
*/
|
||||
export const noirPrimaryFromSurface = {
|
||||
50: '{surface.50}',
|
||||
@@ -72,7 +72,7 @@ export function getSurfacePalette(surfaceName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Ponto único: “Preset Extension” baseado no layoutConfig atual
|
||||
* ✅ Ponto único: "Preset Extension" baseado no layoutConfig atual
|
||||
* Use assim: updatePreset(getPresetExt(layoutConfig))
|
||||
*/
|
||||
export function getPresetExt(layoutConfig) {
|
||||
|
||||
@@ -55,7 +55,7 @@ function goDashboard () {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- “selo” minimalista -->
|
||||
<!-- "selo" minimalista -->
|
||||
<div
|
||||
class="shrink-0 rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4"
|
||||
>
|
||||
@@ -95,7 +95,7 @@ function goDashboard () {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- rodapé “noir” discreto -->
|
||||
<!-- rodapé "noir" discreto -->
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)] opacity-80">
|
||||
Se isso estiver acontecendo com frequência, pode ser um problema de rota ou permissão.
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,7 @@ const applyingPreset = ref(false)
|
||||
// evita cliques enquanto o contexto inicial ainda tá montando
|
||||
const booting = ref(true)
|
||||
|
||||
// guarda features que o plano bloqueou (pra não ficar “clicando e errando”)
|
||||
// guarda features que o plano bloqueou (pra não ficar "clicando e errando")
|
||||
const planDenied = ref(new Set())
|
||||
|
||||
const tenantId = computed(() =>
|
||||
@@ -110,7 +110,7 @@ function requestMenuRefresh () {
|
||||
async function afterFeaturesChanged () {
|
||||
if (!tenantId.value) return
|
||||
|
||||
// ✅ refresh suave (evita “pisca vazio”)
|
||||
// ✅ refresh suave (evita "pisca vazio")
|
||||
await tf.fetchForTenant(tenantId.value, { force: false })
|
||||
|
||||
// ✅ nunca navegar/replace aqui
|
||||
@@ -292,7 +292,7 @@ watch(
|
||||
clearPlanDenied()
|
||||
|
||||
try {
|
||||
// ✅ não force no mount para evitar “pisca”
|
||||
// ✅ não force no mount para evitar "pisca"
|
||||
await tf.fetchForTenant(id, { force: false })
|
||||
|
||||
// ✅ reset só quando estiver estável (debounced)
|
||||
@@ -327,246 +327,246 @@ watch(
|
||||
<Toast />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div class=”h-px” />
|
||||
<div class="h-px" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
class=”sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”
|
||||
:style=”{ top: 'var(--layout-sticky-top, 56px)' }”
|
||||
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<div class=”absolute inset-0 pointer-events-none overflow-hidden” aria-hidden=”true”>
|
||||
<div class=”absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10” />
|
||||
<div class=”absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10” />
|
||||
<div class=”absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10” />
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-72 h-72 -bottom-20 right-24 bg-fuchsia-400/10" />
|
||||
</div>
|
||||
|
||||
<div class=”relative z-10 flex flex-col gap-2”>
|
||||
<div class=”flex items-center justify-between gap-3 flex-wrap”>
|
||||
<div class=”min-w-0”>
|
||||
<div class=”text-[1rem] font-bold tracking-tight text-[var(--text-color)]”>Tipos de Clínica</div>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-0.5”>
|
||||
<div class="relative z-10 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Tipos de Clínica</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">
|
||||
Ative/desative recursos por clínica. Controla menu, rotas e acesso no banco (RLS).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”shrink-0 flex items-center gap-2”>
|
||||
<div class="shrink-0 flex items-center gap-2">
|
||||
<Button
|
||||
label=”Recarregar”
|
||||
icon=”pi pi-refresh”
|
||||
severity=”secondary”
|
||||
label="Recarregar"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading=”loading”
|
||||
:disabled=”applyingPreset || !!savingKey”
|
||||
@click=”reload”
|
||||
:loading="loading"
|
||||
:disabled="applyingPreset || !!savingKey"
|
||||
@click="reload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”flex flex-wrap items-center gap-2”>
|
||||
<span class=”inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<i class=”pi pi-building” />
|
||||
Tenant: <b class=”font-mono”>{{ tenantId || '—' }}</b>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-building" />
|
||||
Tenant: <b class="font-mono">{{ tenantId || '<EFBFBD>"' }}</b>
|
||||
</span>
|
||||
<span class=”inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<i class=”pi pi-user” />
|
||||
Role: <b>{{ role || '—' }}</b>
|
||||
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-user" />
|
||||
Role: <b>{{ role || '<EFBFBD>"' }}</b>
|
||||
</span>
|
||||
<span
|
||||
v-if=”!tenantReady”
|
||||
class=”inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70”
|
||||
v-if="!tenantReady"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
|
||||
>
|
||||
<i class=”pi pi-spin pi-spinner” />
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
Carregando contexto…
|
||||
</span>
|
||||
<span
|
||||
v-else-if=”loading”
|
||||
class=”inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70”
|
||||
v-else-if="loading"
|
||||
class="inline-flex items-center gap-2 rounded-full border border-[var(--surface-border)] px-3 py-1 text-[1rem] text-[var(--text-color-secondary)] opacity-70"
|
||||
>
|
||||
<i class=”pi pi-spin pi-spinner” />
|
||||
<i class="pi pi-spin pi-spinner" />
|
||||
Atualizando módulos…
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”px-3 md:px-4 pb-8 flex flex-col gap-4”>
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
|
||||
<!-- Banner: somente leitura -->
|
||||
<div
|
||||
v-if=”!isOwner && tenantReady”
|
||||
class=”flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]”
|
||||
v-if="!isOwner && tenantReady"
|
||||
class="flex items-center gap-3 rounded-md border border-amber-400/40 bg-amber-400/10 px-5 py-4 text-[1rem]"
|
||||
>
|
||||
<i class=”pi pi-lock text-amber-400 shrink-0” />
|
||||
<span class=”text-[1rem] text-[var(--text-color)] opacity-90”>
|
||||
<i class="pi pi-lock text-amber-400 shrink-0" />
|
||||
<span class="text-[1rem] text-[var(--text-color)] opacity-90">
|
||||
Você está visualizando as configurações da clínica em <b>modo somente leitura</b>.
|
||||
Apenas o administrador pode ativar ou desativar módulos.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Presets -->
|
||||
<div class=”grid grid-cols-1 md:grid-cols-3 gap-3”>
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class=”flex items-start justify-between gap-3”>
|
||||
<div class=”min-w-0”>
|
||||
<div class=”text-[1rem] font-semibold text-[var(--text-color)]”>Preset: Coworking</div>
|
||||
<div class=”mt-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Coworking</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Para aluguel de salas: sem pacientes, com salas.
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size=”small”
|
||||
label=”Aplicar”
|
||||
severity=”secondary”
|
||||
size="small"
|
||||
label="Aplicar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading=”applyingPreset”
|
||||
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||
@click=”applyPreset('coworking')”
|
||||
:loading="applyingPreset"
|
||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
||||
@click="applyPreset('coworking')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class=”flex items-start justify-between gap-3”>
|
||||
<div class=”min-w-0”>
|
||||
<div class=”text-[1rem] font-semibold text-[var(--text-color)]”>Preset: Clínica com recepção</div>
|
||||
<div class=”mt-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica com recepção</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Para secretária gerenciar agenda (pacientes opcional).
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size=”small”
|
||||
label=”Aplicar”
|
||||
severity=”secondary”
|
||||
size="small"
|
||||
label="Aplicar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading=”applyingPreset”
|
||||
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||
@click=”applyPreset('reception')”
|
||||
:loading="applyingPreset"
|
||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
||||
@click="applyPreset('reception')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class=”flex items-start justify-between gap-3”>
|
||||
<div class=”min-w-0”>
|
||||
<div class=”text-[1rem] font-semibold text-[var(--text-color)]”>Preset: Clínica completa</div>
|
||||
<div class=”mt-1 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-semibold text-[var(--text-color)]">Preset: Clínica completa</div>
|
||||
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Pacientes + recepção + salas (se quiser).
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size=”small”
|
||||
label=”Aplicar”
|
||||
severity=”secondary”
|
||||
size="small"
|
||||
label="Aplicar"
|
||||
severity="secondary"
|
||||
outlined
|
||||
:loading=”applyingPreset”
|
||||
:disabled=”!isOwner || !tenantReady || loading || !!savingKey”
|
||||
@click=”applyPreset('full')”
|
||||
:loading="applyingPreset"
|
||||
:disabled="!isOwner || !tenantReady || loading || !!savingKey"
|
||||
@click="applyPreset('full')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modules -->
|
||||
<div class=”grid grid-cols-1 lg:grid-cols-2 gap-3”>
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<ModuleRow
|
||||
title=”Pacientes”
|
||||
desc=”Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist).”
|
||||
icon=”pi pi-users”
|
||||
:enabled=”isOn('patients')”
|
||||
:loading=”savingKey === 'patients'”
|
||||
:disabled=”isLocked('patients')”
|
||||
@toggle=”toggle('patients')”
|
||||
title="Pacientes"
|
||||
desc="Habilita gestão de pacientes por clínica. Todo paciente tem um responsável (therapist)."
|
||||
icon="pi pi-users"
|
||||
:enabled="isOn('patients')"
|
||||
:loading="savingKey === 'patients'"
|
||||
:disabled="isLocked('patients')"
|
||||
@toggle="toggle('patients')"
|
||||
/>
|
||||
<div
|
||||
v-if=”planDenied.has('patients')”
|
||||
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||
v-if="planDenied.has('patients')"
|
||||
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
|
||||
>
|
||||
<i class=”pi pi-lock mr-2” />
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
</div>
|
||||
<Divider class=”my-4” />
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Quando desligado:
|
||||
<ul class=”mt-2 list-disc pl-5 space-y-1”>
|
||||
<li>Menu “Pacientes” some.</li>
|
||||
<li>Rotas com <span class=”font-mono”>meta.tenantFeature = 'patients'</span> redirecionam pra cá.</li>
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>Menu "Pacientes" some.</li>
|
||||
<li>Rotas com <span class="font-mono">meta.tenantFeature = 'patients'</span> redirecionam pra cá.</li>
|
||||
<li>RLS bloqueia acesso direto no banco.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<ModuleRow
|
||||
title=”Recepção / Secretária”
|
||||
desc=”Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente).”
|
||||
icon=”pi pi-briefcase”
|
||||
:enabled=”isOn('shared_reception')”
|
||||
:loading=”savingKey === 'shared_reception'”
|
||||
:disabled=”isLocked('shared_reception')”
|
||||
@toggle=”toggle('shared_reception')”
|
||||
title="Recepção / Secretária"
|
||||
desc="Permite um papel de secretária gerenciar a agenda dos profissionais (sem precisar ver tudo do paciente)."
|
||||
icon="pi pi-briefcase"
|
||||
:enabled="isOn('shared_reception')"
|
||||
:loading="savingKey === 'shared_reception'"
|
||||
:disabled="isLocked('shared_reception')"
|
||||
@toggle="toggle('shared_reception')"
|
||||
/>
|
||||
<div
|
||||
v-if=”planDenied.has('shared_reception')”
|
||||
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||
v-if="planDenied.has('shared_reception')"
|
||||
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
|
||||
>
|
||||
<i class=”pi pi-lock mr-2” />
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
</div>
|
||||
<Divider class=”my-4” />
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||
Observação: este módulo é “produto” (UX + permissões). A base aqui é só o toggle.
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Observação: este módulo é "produto" (UX + permissões). A base aqui é só o toggle.
|
||||
Depois a gente cria:
|
||||
<ul class=”mt-2 list-disc pl-5 space-y-1”>
|
||||
<li>role <span class=”font-mono”>secretary</span> em <span class=”font-mono”>tenant_members</span></li>
|
||||
<ul class="mt-2 list-disc pl-5 space-y-1">
|
||||
<li>role <span class="font-mono">secretary</span> em <span class="font-mono">tenant_members</span></li>
|
||||
<li>policies e telas para a secretária</li>
|
||||
<li>nível de visibilidade do paciente na agenda</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<ModuleRow
|
||||
title=”Salas / Coworking”
|
||||
desc=”Habilita cadastro e reserva de salas/recursos no agendamento.”
|
||||
icon=”pi pi-building”
|
||||
:enabled=”isOn('rooms')”
|
||||
:loading=”savingKey === 'rooms'”
|
||||
:disabled=”isLocked('rooms')”
|
||||
@toggle=”toggle('rooms')”
|
||||
title="Salas / Coworking"
|
||||
desc="Habilita cadastro e reserva de salas/recursos no agendamento."
|
||||
icon="pi pi-building"
|
||||
:enabled="isOn('rooms')"
|
||||
:loading="savingKey === 'rooms'"
|
||||
:disabled="isLocked('rooms')"
|
||||
@toggle="toggle('rooms')"
|
||||
/>
|
||||
<div
|
||||
v-if=”planDenied.has('rooms')”
|
||||
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||
v-if="planDenied.has('rooms')"
|
||||
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
|
||||
>
|
||||
<i class=”pi pi-lock mr-2” />
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
</div>
|
||||
<Divider class=”my-4” />
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5">
|
||||
<ModuleRow
|
||||
title=”Link externo de cadastro”
|
||||
desc=”Libera fluxo público de intake/cadastro externo para a clínica.”
|
||||
icon=”pi pi-link”
|
||||
:enabled=”isOn('intake_public')”
|
||||
:loading=”savingKey === 'intake_public'”
|
||||
:disabled=”isLocked('intake_public')”
|
||||
@toggle=”toggle('intake_public')”
|
||||
title="Link externo de cadastro"
|
||||
desc="Libera fluxo público de intake/cadastro externo para a clínica."
|
||||
icon="pi pi-link"
|
||||
:enabled="isOn('intake_public')"
|
||||
:loading="savingKey === 'intake_public'"
|
||||
:disabled="isLocked('intake_public')"
|
||||
@toggle="toggle('intake_public')"
|
||||
/>
|
||||
<div
|
||||
v-if=”planDenied.has('intake_public')”
|
||||
class=”mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90”
|
||||
v-if="planDenied.has('intake_public')"
|
||||
class="mt-3 text-[1rem] rounded-md border border-[var(--surface-border)] p-3 opacity-90"
|
||||
>
|
||||
<i class=”pi pi-lock mr-2” />
|
||||
<i class="pi pi-lock mr-2" />
|
||||
Este módulo foi bloqueado pelo plano atual do tenant.
|
||||
</div>
|
||||
<Divider class=”my-4” />
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] leading-relaxed”>
|
||||
<Divider class="my-4" />
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] leading-relaxed">
|
||||
Você já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -183,7 +183,7 @@ function friendlyError (err) {
|
||||
|
||||
function safeRpcError (rpcError) {
|
||||
const raw = (rpcError?.message || '').toString().trim()
|
||||
// Por padrão: mensagem amigável. Se quiser ver a “real”, coloque em debugDetails.
|
||||
// Por padrão: mensagem amigável. Se quiser ver a "real", coloque em debugDetails.
|
||||
const friendly = friendlyError(rpcError)
|
||||
return { friendly, raw }
|
||||
}
|
||||
@@ -241,7 +241,7 @@ async function acceptInvite (token) {
|
||||
const { friendly, raw } = safeRpcError(error)
|
||||
state.error = friendly
|
||||
|
||||
// Se você quiser ver a mensagem “crua” para debug, descomente a linha abaixo:
|
||||
// Se você quiser ver a mensagem "crua" para debug, descomente a linha abaixo:
|
||||
// state.debugDetails = raw
|
||||
|
||||
// Opcional: toast discreto
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="min-h-screen bg-gradient-to-b from-slate-950 via-slate-900 to-slate-950 text-slate-100">
|
||||
<Toast />
|
||||
|
||||
<!-- “Backdrop” conceitual -->
|
||||
<!-- "Backdrop" conceitual -->
|
||||
<div class="pointer-events-none fixed inset-0 opacity-30">
|
||||
<div class="absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-emerald-400 blur-3xl" />
|
||||
<div class="absolute top-40 left-16 h-56 w-56 rounded-full bg-indigo-400 blur-3xl" />
|
||||
@@ -1233,7 +1233,7 @@ function validate () {
|
||||
// Progress (conceitual e útil)
|
||||
// ------------------------------------------------------
|
||||
const progressPct = computed(() => {
|
||||
// contagem simples e honesta: dá sensação de avanço sem “gamificar demais”
|
||||
// contagem simples e honesta: dá sensação de avanço sem "gamificar demais"
|
||||
const checks = [
|
||||
!!cleanStr(form.nome_completo),
|
||||
!!digitsOnly(form.telefone),
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
|
||||
<p class="mt-4 text-base md:text-lg text-[var(--text-color-secondary)] max-w-2xl leading-relaxed">
|
||||
Centralize a rotina clínica em um lugar só: pacientes, sessões, lembretes e indicadores.
|
||||
O objetivo não é “burocratizar”: é deixar o consultório respirável.
|
||||
O objetivo não é "burocratizar": é deixar o consultório respirável.
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex flex-col sm:flex-row gap-2">
|
||||
@@ -101,7 +101,7 @@
|
||||
</div>
|
||||
|
||||
<div class="mt-6 text-xs text-[var(--text-color-secondary)]">
|
||||
“A diferença entre ter uma agenda e ter um sistema mora nos detalhes.”
|
||||
"A diferença entre ter uma agenda e ter um sistema mora nos detalhes."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -322,7 +322,7 @@
|
||||
<div>
|
||||
<div class="font-semibold">3) Acompanhar</div>
|
||||
<div class="text-sm text-[var(--text-color-secondary)] mt-1 leading-relaxed">
|
||||
Financeiro e indicadores acompanham o movimento. Menos “cadê?”, mais previsibilidade.
|
||||
Financeiro e indicadores acompanham o movimento. Menos "cadê?", mais previsibilidade.
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<Tag severity="secondary" value="Recebimentos" />
|
||||
|
||||
@@ -21,7 +21,7 @@ const email = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// validação simples (sem “viajar”)
|
||||
// validação simples (sem "viajar")
|
||||
const emailOk = computed(() => /\S+@\S+\.\S+/.test(String(email.value || '').trim()))
|
||||
const passwordOk = computed(() => String(password.value || '').length >= 6)
|
||||
const canSubmit = computed(() => !loading.value && emailOk.value && passwordOk.value)
|
||||
|
||||
@@ -156,7 +156,7 @@ function isEnabled (planId, featureId) {
|
||||
|
||||
/**
|
||||
* ✅ Toggle agora NÃO salva no banco.
|
||||
* Apenas altera o estado local (links) e marca como “pendente”.
|
||||
* Apenas altera o estado local (links) e marca como "pendente".
|
||||
*/
|
||||
function toggleLocal (planId, featureId, nextValue) {
|
||||
if (loading.value || saving.value) return
|
||||
|
||||
@@ -433,91 +433,91 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref=”heroSentinelRef” class=”h-px” />
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
ref=”heroEl”
|
||||
class=”sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”
|
||||
:style=”{ top: 'var(--layout-sticky-top, 56px)' }”
|
||||
ref="heroEl"
|
||||
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<div class=”absolute inset-0 pointer-events-none overflow-hidden” aria-hidden=”true”>
|
||||
<div class=”absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10” />
|
||||
<div class=”absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10” />
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-indigo-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-emerald-400/10" />
|
||||
</div>
|
||||
|
||||
<div class=”relative z-10 flex items-center justify-between gap-3 flex-wrap”>
|
||||
<div class=”min-w-0”>
|
||||
<div class=”text-[1rem] font-bold tracking-tight text-[var(--text-color)]”>Planos e preços</div>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-0.5”>Catálogo de planos do SaaS.</div>
|
||||
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Planos e preços</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Catálogo de planos do SaaS.</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop (≥ 1200px) -->
|
||||
<div class=”hidden xl:flex items-center gap-2 flex-wrap”>
|
||||
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||
<SelectButton
|
||||
v-model=”targetFilter”
|
||||
:options=”targetFilterOptions”
|
||||
optionLabel=”label”
|
||||
optionValue=”value”
|
||||
size=”small”
|
||||
v-model="targetFilter"
|
||||
:options="targetFilterOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
size="small"
|
||||
/>
|
||||
<Button label=”Atualizar” icon=”pi pi-refresh” severity=”secondary” outlined size=”small” :loading=”loading” :disabled=”saving” @click=”fetchAll” />
|
||||
<Button label=”Adicionar plano” icon=”pi pi-plus” size=”small” :disabled=”saving” @click=”openCreate” />
|
||||
<Button label="Atualizar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving" @click="fetchAll" />
|
||||
<Button label="Adicionar plano" icon="pi pi-plus" size="small" :disabled="saving" @click="openCreate" />
|
||||
</div>
|
||||
|
||||
<!-- Ações mobile (< 1200px) -->
|
||||
<div class=”flex xl:hidden”>
|
||||
<div class="flex xl:hidden">
|
||||
<Button
|
||||
label=”Ações”
|
||||
icon=”pi pi-ellipsis-v”
|
||||
severity=”warn”
|
||||
size=”small”
|
||||
aria-haspopup=”true”
|
||||
aria-controls=”plans_hero_menu”
|
||||
@click=”(e) => heroMenuRef.toggle(e)”
|
||||
label="Ações"
|
||||
icon="pi pi-ellipsis-v"
|
||||
severity="warn"
|
||||
size="small"
|
||||
aria-haspopup="true"
|
||||
aria-controls="plans_hero_menu"
|
||||
@click="(e) => heroMenuRef.toggle(e)"
|
||||
/>
|
||||
<Menu ref=”heroMenuRef” id=”plans_hero_menu” :model=”heroMenuItems” :popup=”true” />
|
||||
<Menu ref="heroMenuRef" id="plans_hero_menu" :model="heroMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class=”px-3 md:px-4 pb-8 flex flex-col gap-4”>
|
||||
<DataTable :value=”filteredRows” dataKey=”id” :loading=”loading” stripedRows responsiveLayout=”scroll”>
|
||||
<Column field=”name” header=”Nome” sortable style=”min-width: 14rem” />
|
||||
<Column field=”key” header=”Key” sortable />
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
<DataTable :value="filteredRows" dataKey="id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||
<Column field="name" header="Nome" sortable style="min-width: 14rem" />
|
||||
<Column field="key" header="Key" sortable />
|
||||
|
||||
<Column field=”target” header=”Público” sortable style=”width: 10rem”>
|
||||
<template #body=”{ data }”>
|
||||
<span class=”font-medium”>{{ formatTargetLabel(data.target) }}</span>
|
||||
<Column field="target" header="Público" sortable style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ formatTargetLabel(data.target) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header=”Mensal” sortable style=”width: 12rem”>
|
||||
<template #body=”{ data }”>
|
||||
<span class=”font-medium”>{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
||||
<Column header="Mensal" sortable style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header=”Anual” sortable style=”width: 12rem”>
|
||||
<template #body=”{ data }”>
|
||||
<span class=”font-medium”>{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
||||
<Column header="Anual" sortable style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column v-if=”hasCreatedAt” field=”created_at” header=”Criado em” sortable />
|
||||
<Column v-if="hasCreatedAt" field="created_at" header="Criado em" sortable />
|
||||
|
||||
<Column header=”Ações” style=”width: 12rem”>
|
||||
<template #body=”{ data }”>
|
||||
<div class=”flex gap-2”>
|
||||
<Button icon=”pi pi-pencil” severity=”secondary” outlined @click=”openEdit(data)” />
|
||||
<Column header="Ações" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined @click="openEdit(data)" />
|
||||
<Button
|
||||
icon=”pi pi-trash”
|
||||
severity=”danger”
|
||||
icon="pi pi-trash"
|
||||
severity="danger"
|
||||
outlined
|
||||
:disabled=”isDeleteLockedRow(data)”
|
||||
:title=”isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'”
|
||||
@click=”askDelete(data)”
|
||||
:disabled="isDeleteLockedRow(data)"
|
||||
:title="isDeleteLockedRow(data) ? 'Plano padrão do sistema não pode ser removido.' : 'Excluir plano'"
|
||||
@click="askDelete(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -526,137 +526,137 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible=”showDlg”
|
||||
v-model:visible="showDlg"
|
||||
modal
|
||||
:draggable=”false”
|
||||
:header=”isEdit ? 'Editar plano' : 'Novo plano'”
|
||||
:style=”{ width: '620px' }”
|
||||
:draggable="false"
|
||||
:header="isEdit ? 'Editar plano' : 'Novo plano'"
|
||||
:style="{ width: '620px' }"
|
||||
>
|
||||
<div class=”flex flex-col gap-4”>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class=”block mb-2”>Público do plano</label>
|
||||
<label class="block mb-2">Público do plano</label>
|
||||
<SelectButton
|
||||
v-model=”form.target”
|
||||
:options=”targetOptions”
|
||||
optionLabel=”label”
|
||||
optionValue=”value”
|
||||
class=”w-full”
|
||||
:disabled=”isTargetLocked || saving”
|
||||
v-model="form.target"
|
||||
:options="targetOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="w-full"
|
||||
:disabled="isTargetLocked || saving"
|
||||
/>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Planos já existentes não mudam de público. Isso evita inconsistência no catálogo.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatLabel variant=”on” class=”w-full”>
|
||||
<IconField class=”w-full”>
|
||||
<InputIcon class=”pi pi-tag” />
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
v-model=”form.key”
|
||||
id=”plan_key”
|
||||
class=”w-full pr-10”
|
||||
variant=”filled”
|
||||
placeholder=”ex.: clinic_pro”
|
||||
:disabled=”(isCorePlanEditing || saving)”
|
||||
@blur=”form.key = slugifyKey(form.key)”
|
||||
v-model="form.key"
|
||||
id="plan_key"
|
||||
class="w-full pr-10"
|
||||
variant="filled"
|
||||
placeholder="ex.: clinic_pro"
|
||||
:disabled="(isCorePlanEditing || saving)"
|
||||
@blur="form.key = slugifyKey(form.key)"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”plan_key”>Key</label>
|
||||
<label for="plan_key">Key</label>
|
||||
</FloatLabel>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] -mt-3”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] -mt-3">
|
||||
Key é técnica e estável (slug). Planos padrão do sistema têm a key protegida.
|
||||
</div>
|
||||
|
||||
<FloatLabel variant=”on” class=”w-full”>
|
||||
<IconField class=”w-full”>
|
||||
<InputIcon class=”pi pi-bookmark” />
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-bookmark" />
|
||||
<InputText
|
||||
v-model=”form.name”
|
||||
id=”plan_name”
|
||||
class=”w-full pr-10”
|
||||
variant=”filled”
|
||||
placeholder=”ex.: Clínica PRO”
|
||||
:disabled=”saving”
|
||||
v-model="form.name"
|
||||
id="plan_name"
|
||||
class="w-full pr-10"
|
||||
variant="filled"
|
||||
placeholder="ex.: Clínica PRO"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”plan_name”>Nome</label>
|
||||
<label for="plan_name">Nome</label>
|
||||
</FloatLabel>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] -mt-3”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] -mt-3">
|
||||
Nome interno para administração. (Nome público vem de <b>plan_public</b>.)
|
||||
</div>
|
||||
|
||||
<div class=”grid grid-cols-1 md:grid-cols-2 gap-4”>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<FloatLabel variant=”on” class=”w-full”>
|
||||
<IconField class=”w-full”>
|
||||
<InputIcon class=”pi pi-money-bill” />
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-money-bill" />
|
||||
<InputNumber
|
||||
v-model=”form.price_monthly”
|
||||
inputId=”price_monthly”
|
||||
class=”w-full”
|
||||
inputClass=”w-full pr-10”
|
||||
variant=”filled”
|
||||
mode=”decimal”
|
||||
:minFractionDigits=”2”
|
||||
:maxFractionDigits=”2”
|
||||
placeholder=”ex.: 49,90”
|
||||
:disabled=”saving”
|
||||
v-model="form.price_monthly"
|
||||
inputId="price_monthly"
|
||||
class="w-full"
|
||||
inputClass="w-full pr-10"
|
||||
variant="filled"
|
||||
mode="decimal"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
placeholder="ex.: 49,90"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”price_monthly”>Preço mensal (R$)</label>
|
||||
<label for="price_monthly">Preço mensal (R$)</label>
|
||||
</FloatLabel>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>Deixe vazio para “sem preço definido”.</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Deixe vazio para "sem preço definido".</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FloatLabel variant=”on” class=”w-full”>
|
||||
<IconField class=”w-full”>
|
||||
<InputIcon class=”pi pi-calendar” />
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-calendar" />
|
||||
<InputNumber
|
||||
v-model=”form.price_yearly”
|
||||
inputId=”price_yearly”
|
||||
class=”w-full”
|
||||
inputClass=”w-full pr-10”
|
||||
variant=”filled”
|
||||
mode=”decimal”
|
||||
:minFractionDigits=”2”
|
||||
:maxFractionDigits=”2”
|
||||
placeholder=”ex.: 490,00”
|
||||
:disabled=”saving”
|
||||
v-model="form.price_yearly"
|
||||
inputId="price_yearly"
|
||||
class="w-full"
|
||||
inputClass="w-full pr-10"
|
||||
variant="filled"
|
||||
mode="decimal"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
placeholder="ex.: 490,00"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”price_yearly”>Preço anual (R$)</label>
|
||||
<label for="price_yearly">Preço anual (R$)</label>
|
||||
</FloatLabel>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>Deixe vazio para “sem preço definido”.</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Deixe vazio para "sem preço definido".</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- max_supervisees: só para planos de supervisor -->
|
||||
<div v-if=”form.target === 'supervisor'”>
|
||||
<FloatLabel variant=”on” class=”w-full”>
|
||||
<IconField class=”w-full”>
|
||||
<InputIcon class=”pi pi-users” />
|
||||
<div v-if="form.target === 'supervisor'">
|
||||
<FloatLabel variant="on" class="w-full">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-users" />
|
||||
<InputNumber
|
||||
v-model=”form.max_supervisees”
|
||||
inputId=”max_supervisees”
|
||||
class=”w-full”
|
||||
inputClass=”w-full pr-10”
|
||||
variant=”filled”
|
||||
:useGrouping=”false”
|
||||
:min=”1”
|
||||
placeholder=”ex.: 3”
|
||||
:disabled=”saving”
|
||||
v-model="form.max_supervisees"
|
||||
inputId="max_supervisees"
|
||||
class="w-full"
|
||||
inputClass="w-full pr-10"
|
||||
variant="filled"
|
||||
:useGrouping="false"
|
||||
:min="1"
|
||||
placeholder="ex.: 3"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”max_supervisees”>Limite de supervisionados</label>
|
||||
<label for="max_supervisees">Limite de supervisionados</label>
|
||||
</FloatLabel>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">Número máximo de terapeutas que podem ser supervisionados neste plano.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label=”Cancelar” severity=”secondary” outlined @click=”showDlg = false” :disabled=”saving” />
|
||||
<Button :label=”isEdit ? 'Salvar' : 'Criar'” icon=”pi pi-check” :loading=”saving” @click=”save” />
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="showDlg = false" :disabled="saving" />
|
||||
<Button :label="isEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -48,7 +48,7 @@ const targetOptions = [
|
||||
const previewPricePolicy = ref('hide') // 'hide' | 'consult'
|
||||
const previewPolicyOptions = [
|
||||
{ label: 'Ocultar sem preço', value: 'hide' },
|
||||
{ label: 'Mostrar “Sob consulta”', value: 'consult' }
|
||||
{ label: 'Mostrar "Sob consulta"', value: 'consult' }
|
||||
]
|
||||
|
||||
function normalizeTarget (row) {
|
||||
@@ -450,148 +450,148 @@ onBeforeUnmount(() => {
|
||||
<ConfirmDialog />
|
||||
|
||||
<!-- Sentinel -->
|
||||
<div ref=”heroSentinelRef” class=”h-px” />
|
||||
<div ref="heroSentinelRef" class="h-px" />
|
||||
|
||||
<!-- Hero sticky -->
|
||||
<div
|
||||
ref=”heroEl”
|
||||
class=”sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5”
|
||||
:style=”{ top: 'var(--layout-sticky-top, 56px)' }”
|
||||
ref="heroEl"
|
||||
class="sticky mx-3 md:mx-4 mb-4 z-20 overflow-hidden rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-5"
|
||||
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
|
||||
>
|
||||
<div class=”absolute inset-0 pointer-events-none overflow-hidden” aria-hidden=”true”>
|
||||
<div class=”absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10” />
|
||||
<div class=”absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10” />
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute rounded-full blur-[70px] w-72 h-72 -top-16 -right-20 bg-emerald-400/10" />
|
||||
<div class="absolute rounded-full blur-[70px] w-80 h-80 top-10 -left-24 bg-indigo-400/10" />
|
||||
</div>
|
||||
|
||||
<div class=”relative z-10 flex items-center justify-between gap-3 flex-wrap”>
|
||||
<div class=”min-w-0”>
|
||||
<div class=”text-[1rem] font-bold tracking-tight text-[var(--text-color)]”>Vitrine de Planos</div>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-0.5”>Configure como os planos aparecem na página pública.</div>
|
||||
<div class="relative z-10 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Vitrine de Planos</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">Configure como os planos aparecem na página pública.</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações desktop (≥ 1200px) -->
|
||||
<div class=”hidden xl:flex items-center gap-2 flex-wrap”>
|
||||
<SelectButton v-model=”targetFilter” :options=”targetOptions” optionLabel=”label” optionValue=”value” size=”small” :disabled=”loading || saving || bulletSaving” />
|
||||
<Button label=”Recarregar” icon=”pi pi-refresh” severity=”secondary” outlined size=”small” :loading=”loading” :disabled=”saving || bulletSaving” @click=”fetchAll” />
|
||||
<div class="hidden xl:flex items-center gap-2 flex-wrap">
|
||||
<SelectButton v-model="targetFilter" :options="targetOptions" optionLabel="label" optionValue="value" size="small" :disabled="loading || saving || bulletSaving" />
|
||||
<Button label="Recarregar" icon="pi pi-refresh" severity="secondary" outlined size="small" :loading="loading" :disabled="saving || bulletSaving" @click="fetchAll" />
|
||||
</div>
|
||||
|
||||
<!-- Ações mobile (< 1200px) -->
|
||||
<div class=”flex xl:hidden”>
|
||||
<div class="flex xl:hidden">
|
||||
<Button
|
||||
label=”Ações”
|
||||
icon=”pi pi-ellipsis-v”
|
||||
severity=”warn”
|
||||
size=”small”
|
||||
aria-haspopup=”true”
|
||||
aria-controls=”showcase_hero_menu”
|
||||
@click=”(e) => heroMenuRef.toggle(e)”
|
||||
label="Ações"
|
||||
icon="pi pi-ellipsis-v"
|
||||
severity="warn"
|
||||
size="small"
|
||||
aria-haspopup="true"
|
||||
aria-controls="showcase_hero_menu"
|
||||
@click="(e) => heroMenuRef.toggle(e)"
|
||||
/>
|
||||
<Menu ref=”heroMenuRef” id=”showcase_hero_menu” :model=”heroMenuItems” :popup=”true” />
|
||||
<Menu ref="heroMenuRef" id="showcase_hero_menu" :model="heroMenuItems" :popup="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class=”px-3 md:px-4 pb-8 flex flex-col gap-4”>
|
||||
<div class="px-3 md:px-4 pb-8 flex flex-col gap-4">
|
||||
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<FloatLabel variant=”on” class=”w-full md:w-80”>
|
||||
<IconField class=”w-full”>
|
||||
<InputIcon class=”pi pi-search” />
|
||||
<InputText v-model=”q” id=”plans_public_search” class=”w-full pr-10” variant=”filled” :disabled=”loading || saving || bulletSaving” />
|
||||
<FloatLabel variant="on" class="w-full md:w-80">
|
||||
<IconField class="w-full">
|
||||
<InputIcon class="pi pi-search" />
|
||||
<InputText v-model="q" id="plans_public_search" class="w-full pr-10" variant="filled" :disabled="loading || saving || bulletSaving" />
|
||||
</IconField>
|
||||
<label for=”plans_public_search”>Buscar plano</label>
|
||||
<label for="plans_public_search">Buscar plano</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Popover global (reutilizado) -->
|
||||
<Popover ref=”bulletsPop”>
|
||||
<div class=”w-[340px] max-w-[80vw]”>
|
||||
<div class=”text-[1rem] font-semibold mb-2”>{{ popPlanTitle }}</div>
|
||||
<Popover ref="bulletsPop">
|
||||
<div class="w-[340px] max-w-[80vw]">
|
||||
<div class="text-[1rem] font-semibold mb-2">{{ popPlanTitle }}</div>
|
||||
|
||||
<div v-if=”!popBullets?.length” class=”text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div v-if="!popBullets?.length" class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Nenhum benefício configurado.
|
||||
</div>
|
||||
|
||||
<ul v-else class=”m-0 pl-4 space-y-2”>
|
||||
<li v-for=”b in popBullets” :key=”b.id” class=”text-[1rem] leading-snug”>
|
||||
<span :class=”b.highlight ? 'font-semibold' : ''”>
|
||||
<ul v-else class="m-0 pl-4 space-y-2">
|
||||
<li v-for="b in popBullets" :key="b.id" class="text-[1rem] leading-snug">
|
||||
<span :class="b.highlight ? 'font-semibold' : ''">
|
||||
{{ b.text }}
|
||||
</span>
|
||||
<div v-if=”b.highlight” class=”inline ml-2 text-[1rem] text-[var(--text-color-secondary)]”>(destaque)</div>
|
||||
<div v-if="b.highlight" class="inline ml-2 text-[1rem] text-[var(--text-color-secondary)]">(destaque)</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Popover>
|
||||
|
||||
<DataTable :value=”tableRows” dataKey=”plan_id” :loading=”loading” stripedRows responsiveLayout=”scroll”>
|
||||
<Column header=”Plano” style=”min-width: 18rem”>
|
||||
<template #body=”{ data }”>
|
||||
<div class=”flex flex-col”>
|
||||
<span class=”font-semibold”>{{ data.public_name || data.plan_name || data.plan_key }}</span>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<DataTable :value="tableRows" dataKey="plan_id" :loading="loading" stripedRows responsiveLayout="scroll">
|
||||
<Column header="Plano" style="min-width: 18rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-semibold">{{ data.public_name || data.plan_name || data.plan_key }}</span>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
{{ data.plan_key }} • {{ data.plan_name || '—' }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header=”Público” style=”width: 10rem”>
|
||||
<template #body=”{ data }”>
|
||||
<Tag :value=”targetLabel(normalizeTarget(data))” :severity=”targetSeverity(normalizeTarget(data))” rounded />
|
||||
<Column header="Público" style="width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="targetLabel(normalizeTarget(data))" :severity="targetSeverity(normalizeTarget(data))" rounded />
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header=”Mensal” style=”width: 12rem”>
|
||||
<template #body=”{ data }”>
|
||||
<span class=”font-medium”>{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
||||
<Column header="Mensal" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ formatBRLFromCents(data.monthly_cents) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header=”Anual” style=”width: 12rem”>
|
||||
<template #body=”{ data }”>
|
||||
<span class=”font-medium”>{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
||||
<Column header="Anual" style="width: 12rem">
|
||||
<template #body="{ data }">
|
||||
<span class="font-medium">{{ formatBRLFromCents(data.yearly_cents) }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field=”badge” header=”Badge” style=”min-width: 12rem” />
|
||||
<Column field="badge" header="Badge" style="min-width: 12rem" />
|
||||
|
||||
<Column header=”Visível” style=”width: 8rem”>
|
||||
<template #body=”{ data }”>
|
||||
<Column header="Visível" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.is_visible ? 'Sim' : 'Não' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header=”Destaque” style=”width: 9rem”>
|
||||
<template #body=”{ data }”>
|
||||
<Column header="Destaque" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.is_featured ? 'Sim' : 'Não' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field=”sort_order” header=”Ordem” style=”width: 8rem” />
|
||||
<Column field="sort_order" header="Ordem" style="width: 8rem" />
|
||||
|
||||
<Column header=”Ações” style=”width: 14rem”>
|
||||
<template #body=”{ data }”>
|
||||
<div class=”flex gap-2 justify-end”>
|
||||
<Column header="Ações" style="width: 14rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2 justify-end">
|
||||
<Button
|
||||
severity=”secondary”
|
||||
severity="secondary"
|
||||
outlined
|
||||
size=”small”
|
||||
:disabled=”loading || saving || bulletSaving”
|
||||
@click=”(e) => openBulletsPopover(e, data)”
|
||||
size="small"
|
||||
:disabled="loading || saving || bulletSaving"
|
||||
@click="(e) => openBulletsPopover(e, data)"
|
||||
>
|
||||
<i class=”pi pi-list mr-2” />
|
||||
<span class=”font-medium”>{{ data.bullets?.length || 0 }}</span>
|
||||
<i class="pi pi-list mr-2" />
|
||||
<span class="font-medium">{{ data.bullets?.length || 0 }}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
icon=”pi pi-pencil”
|
||||
severity=”secondary”
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size=”small”
|
||||
:disabled=”loading || saving || bulletSaving”
|
||||
@click=”openEdit(data)”
|
||||
size="small"
|
||||
:disabled="loading || saving || bulletSaving"
|
||||
@click="openEdit(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -599,53 +599,53 @@ onBeforeUnmount(() => {
|
||||
</DataTable>
|
||||
|
||||
<!-- PREVIEW PÚBLICO (conceitual) -->
|
||||
<div class=”rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden”>
|
||||
<div class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden">
|
||||
<!-- Hero -->
|
||||
<div class=”relative p-6 md:p-10”>
|
||||
<div class=”absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)]” />
|
||||
<div class=”relative”>
|
||||
<div class=”flex flex-col md:flex-row md:items-end md:justify-between gap-6”>
|
||||
<div class=”max-w-2xl”>
|
||||
<div class=”flex items-center gap-2 mb-3 flex-wrap”>
|
||||
<div class="relative p-6 md:p-10">
|
||||
<div class="absolute inset-0 opacity-40 pointer-events-none bg-[radial-gradient(ellipse_at_top,rgba(16,185,129,0.18),transparent_55%)]" />
|
||||
<div class="relative">
|
||||
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6">
|
||||
<div class="max-w-2xl">
|
||||
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<Tag
|
||||
:value=”targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`”
|
||||
:severity=”targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')”
|
||||
:value="targetFilter === 'all' ? 'Vitrine (Todos)' : `Vitrine (${targetLabel(targetFilter)})`"
|
||||
:severity="targetFilter === 'therapist' ? 'success' : (targetFilter === 'clinic' ? 'info' : 'secondary')"
|
||||
rounded
|
||||
/>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Ajuste nomes, descrições, badges e benefícios — e veja o resultado aqui.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”text-3xl md:text-5xl font-semibold leading-tight”>
|
||||
<div class="text-3xl md:text-5xl font-semibold leading-tight">
|
||||
Um plano não é preço.<br />
|
||||
É promessa organizada.
|
||||
</div>
|
||||
|
||||
<div class=”text-[var(--text-color-secondary)] mt-3”>
|
||||
<div class="text-[var(--text-color-secondary)] mt-3">
|
||||
A vitrine é o lugar onde o produto deixa de ser tabela e vira escolha.
|
||||
Clareza, contraste e uma hierarquia que guia o olhar — sem ruído.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class=”flex flex-col items-start md:items-end gap-4”>
|
||||
<div class=”flex flex-col gap-2”>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>Cobrança</div>
|
||||
<div class="flex flex-col items-start md:items-end gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Cobrança</div>
|
||||
<SelectButton
|
||||
v-model=”billingInterval”
|
||||
:options=”intervalOptions”
|
||||
optionLabel=”label”
|
||||
optionValue=”value”
|
||||
v-model="billingInterval"
|
||||
:options="intervalOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class=”flex flex-col gap-2”>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>Planos sem preço</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">Planos sem preço</div>
|
||||
<SelectButton
|
||||
v-model=”previewPricePolicy”
|
||||
:options=”previewPolicyOptions”
|
||||
optionLabel=”label”
|
||||
optionValue=”value”
|
||||
v-model="previewPricePolicy"
|
||||
:options="previewPolicyOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -654,101 +654,101 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class=”p-6 md:p-10 pt-0”>
|
||||
<div v-if=”!previewPlans.length” class=”text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div class="p-6 md:p-10 pt-0">
|
||||
<div v-if="!previewPlans.length" class="text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Nenhum plano visível para este filtro.
|
||||
</div>
|
||||
|
||||
<div v-else class=”mt-6 grid grid-cols-1 md:grid-cols-3 gap-6”>
|
||||
<div v-else class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for=”p in previewPlans”
|
||||
:key=”p.plan_id”
|
||||
:class=”[
|
||||
v-for="p in previewPlans"
|
||||
:key="p.plan_id"
|
||||
:class="[
|
||||
'relative rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] overflow-hidden',
|
||||
'shadow-sm transition-transform',
|
||||
p.is_featured ? 'md:-translate-y-2 md:scale-[1.02] ring-1 ring-emerald-500/25' : ''
|
||||
]”
|
||||
]"
|
||||
>
|
||||
<div class=”h-2 w-full opacity-50 bg-[var(--surface-100)]” />
|
||||
<div class="h-2 w-full opacity-50 bg-[var(--surface-100)]" />
|
||||
|
||||
<div class=”p-6”>
|
||||
<div class=”flex items-center justify-between gap-3”>
|
||||
<div class=”flex items-center gap-2 flex-wrap”>
|
||||
<Tag :value=”targetLabel(normalizeTarget(p))” :severity=”targetSeverity(normalizeTarget(p))” rounded />
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Tag :value="targetLabel(normalizeTarget(p))" :severity="targetSeverity(normalizeTarget(p))" rounded />
|
||||
<Tag
|
||||
v-if=”p.badge || p.is_featured”
|
||||
:value=”p.badge || 'Destaque'”
|
||||
:severity=”p.is_featured ? 'success' : 'secondary'”
|
||||
v-if="p.badge || p.is_featured"
|
||||
:value="p.badge || 'Destaque'"
|
||||
:severity="p.is_featured ? 'success' : 'secondary'"
|
||||
rounded
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)]”>{{ p.plan_key }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)]">{{ p.plan_key }}</div>
|
||||
</div>
|
||||
|
||||
<div class=”mt-4”>
|
||||
<template v-if=”priceDisplayForPreview(p).kind === 'paid'”>
|
||||
<div class=”text-4xl font-semibold leading-none”>
|
||||
<div class="mt-4">
|
||||
<template v-if="priceDisplayForPreview(p).kind === 'paid'">
|
||||
<div class="text-4xl font-semibold leading-none">
|
||||
{{ priceDisplayForPreview(p).main }}
|
||||
</div>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
{{ priceDisplayForPreview(p).sub }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if=”priceDisplayForPreview(p).kind === 'free'”>
|
||||
<div class=”text-4xl font-semibold leading-none”>
|
||||
<template v-else-if="priceDisplayForPreview(p).kind === 'free'">
|
||||
<div class="text-4xl font-semibold leading-none">
|
||||
{{ priceDisplayForPreview(p).main }}
|
||||
</div>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
{{ billingInterval === 'year' ? 'no anual' : 'no mensal' }}
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class=”text-2xl font-semibold leading-none”>
|
||||
<div class="text-2xl font-semibold leading-none">
|
||||
{{ priceDisplayForPreview(p).main }}
|
||||
</div>
|
||||
<div class=”text-[1rem] text-[var(--text-color-secondary)] mt-1”>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-1">
|
||||
Fale com a equipe para montar o plano ideal.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class=”text-[var(--text-color-secondary)] mt-3 min-h-[44px]”>
|
||||
<div class="text-[var(--text-color-secondary)] mt-3 min-h-[44px]">
|
||||
{{ p.public_description || '—' }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class=”mt-5 w-full”
|
||||
:label=”p.is_featured ? 'Começar agora' : 'Selecionar plano'”
|
||||
:severity=”p.is_featured ? 'success' : 'secondary'”
|
||||
:outlined=”!p.is_featured”
|
||||
class="mt-5 w-full"
|
||||
:label="p.is_featured ? 'Começar agora' : 'Selecionar plano'"
|
||||
:severity="p.is_featured ? 'success' : 'secondary'"
|
||||
:outlined="!p.is_featured"
|
||||
/>
|
||||
|
||||
<div class=”mt-6”>
|
||||
<div class=”border-t border-dashed border-[var(--surface-border)]” />
|
||||
<div class="mt-6">
|
||||
<div class="border-t border-dashed border-[var(--surface-border)]" />
|
||||
</div>
|
||||
|
||||
<ul v-if=”p.bullets?.length” class=”mt-4 space-y-2”>
|
||||
<li v-for=”b in p.bullets” :key=”b.id” class=”flex items-start gap-2”>
|
||||
<i class=”pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]”></i>
|
||||
<span :class=”['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']”>
|
||||
<ul v-if="p.bullets?.length" class="mt-4 space-y-2">
|
||||
<li v-for="b in p.bullets" :key="b.id" class="flex items-start gap-2">
|
||||
<i class="pi pi-check mt-1 text-[1rem] text-[var(--text-color-secondary)]"></i>
|
||||
<span :class="['text-[1rem] leading-snug', b.highlight ? 'font-semibold' : '']">
|
||||
{{ b.text }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else class=”mt-4 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div v-else class="mt-4 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Nenhum benefício configurado.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if=”previewPricePolicy === 'hide'” class=”mt-6 text-[1rem] text-[var(--text-color-secondary)]”>
|
||||
<div v-if="previewPricePolicy === 'hide'" class="mt-6 text-[1rem] text-[var(--text-color-secondary)]">
|
||||
Observação: planos sem preço não aparecem no preview (política atual).
|
||||
Para exibir como “Sob consulta”, mude acima.
|
||||
Para exibir como "Sob consulta", mude acima.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -757,90 +757,90 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Dialog principal (✅ sem drag: removemos draggable) -->
|
||||
<Dialog
|
||||
v-model:visible=”showDlg”
|
||||
v-model:visible="showDlg"
|
||||
modal
|
||||
header=”Editar vitrine”
|
||||
:style=”{ width: '820px' }”
|
||||
:closable=”!saving”
|
||||
:dismissableMask=”!saving”
|
||||
:draggable=”false”
|
||||
header="Editar vitrine"
|
||||
:style="{ width: '820px' }"
|
||||
:closable="!saving"
|
||||
:dismissableMask="!saving"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class=”grid grid-cols-1 md:grid-cols-2 gap-6”>
|
||||
<div class=”flex flex-col gap-4”>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- ✅ Nome público (FloatLabel + Icon) -->
|
||||
<FloatLabel variant=”on”>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class=”pi pi-tag” />
|
||||
<InputIcon class="pi pi-tag" />
|
||||
<InputText
|
||||
id=”pp-public-name”
|
||||
v-model.trim=”form.public_name”
|
||||
class=”w-full”
|
||||
variant=”filled”
|
||||
:disabled=”saving”
|
||||
autocomplete=”off”
|
||||
id="pp-public-name"
|
||||
v-model.trim="form.public_name"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
autofocus
|
||||
@keydown.enter.prevent=”save”
|
||||
@keydown.enter.prevent="save"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”pp-public-name”>Nome público *</label>
|
||||
<label for="pp-public-name">Nome público *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- ✅ Descrição pública -->
|
||||
<FloatLabel variant=”on”>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class=”pi pi-align-left” />
|
||||
<InputIcon class="pi pi-align-left" />
|
||||
<Textarea
|
||||
id=”pp-public-desc”
|
||||
v-model.trim=”form.public_description”
|
||||
class=”w-full”
|
||||
rows=”3”
|
||||
id="pp-public-desc"
|
||||
v-model.trim="form.public_description"
|
||||
class="w-full"
|
||||
rows="3"
|
||||
autoResize
|
||||
:disabled=”saving”
|
||||
:disabled="saving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”pp-public-desc”>Descrição pública</label>
|
||||
<label for="pp-public-desc">Descrição pública</label>
|
||||
</FloatLabel>
|
||||
|
||||
<!-- ✅ Badge -->
|
||||
<FloatLabel variant=”on”>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class=”pi pi-bookmark” />
|
||||
<InputIcon class="pi pi-bookmark" />
|
||||
<InputText
|
||||
id=”pp-badge”
|
||||
v-model.trim=”form.badge”
|
||||
class=”w-full”
|
||||
variant=”filled”
|
||||
:disabled=”saving”
|
||||
autocomplete=”off”
|
||||
@keydown.enter.prevent=”save”
|
||||
id="pp-badge"
|
||||
v-model.trim="form.badge"
|
||||
class="w-full"
|
||||
variant="filled"
|
||||
:disabled="saving"
|
||||
autocomplete="off"
|
||||
@keydown.enter.prevent="save"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”pp-badge”>Badge (opcional)</label>
|
||||
<label for="pp-badge">Badge (opcional)</label>
|
||||
</FloatLabel>
|
||||
|
||||
<div class=”grid grid-cols-1 md:grid-cols-2 gap-4”>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- ✅ Ordem -->
|
||||
<FloatLabel variant=”on”>
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class=”pi pi-sort-amount-up-alt” />
|
||||
<InputIcon class="pi pi-sort-amount-up-alt" />
|
||||
<InputNumber
|
||||
id=”pp-sort”
|
||||
v-model=”form.sort_order”
|
||||
class=”w-full”
|
||||
inputClass=”w-full”
|
||||
:disabled=”saving”
|
||||
id="pp-sort"
|
||||
v-model="form.sort_order"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”pp-sort”>Ordem</label>
|
||||
<label for="pp-sort">Ordem</label>
|
||||
</FloatLabel>
|
||||
|
||||
<div class=”flex flex-col gap-3 pt-2”>
|
||||
<div class=”flex items-center gap-2”>
|
||||
<Checkbox v-model=”form.is_visible” :binary=”true” :disabled=”saving” />
|
||||
<div class="flex flex-col gap-3 pt-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="form.is_visible" :binary="true" :disabled="saving" />
|
||||
<label>Visível no público</label>
|
||||
</div>
|
||||
<div class=”flex items-center gap-2”>
|
||||
<Checkbox v-model=”form.is_featured” :binary=”true” :disabled=”saving” />
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox v-model="form.is_featured" :binary="true" :disabled="saving" />
|
||||
<label>Destaque</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -849,24 +849,24 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- bullets -->
|
||||
<div>
|
||||
<div class=”flex items-center justify-between mb-3”>
|
||||
<div class=”font-semibold”>Benefícios (bullets)</div>
|
||||
<Button label=”Adicionar” icon=”pi pi-plus” size=”small” :disabled=”saving || bulletSaving” @click=”openBulletCreate” />
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="font-semibold">Benefícios (bullets)</div>
|
||||
<Button label="Adicionar" icon="pi pi-plus" size="small" :disabled="saving || bulletSaving" @click="openBulletCreate" />
|
||||
</div>
|
||||
|
||||
<DataTable :value=”bullets” dataKey=”id” stripedRows responsiveLayout=”scroll”>
|
||||
<Column field=”text” header=”Texto” />
|
||||
<Column field=”sort_order” header=”Ordem” style=”width: 7rem” />
|
||||
<Column header=”Destaque” style=”width: 8rem”>
|
||||
<template #body=”{ data }”>
|
||||
<DataTable :value="bullets" dataKey="id" stripedRows responsiveLayout="scroll">
|
||||
<Column field="text" header="Texto" />
|
||||
<Column field="sort_order" header="Ordem" style="width: 7rem" />
|
||||
<Column header="Destaque" style="width: 8rem">
|
||||
<template #body="{ data }">
|
||||
<span>{{ data.highlight ? 'Sim' : 'Não' }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column header=”Ações” style=”width: 9rem”>
|
||||
<template #body=”{ data }”>
|
||||
<div class=”flex gap-2”>
|
||||
<Button icon=”pi pi-pencil” severity=”secondary” outlined size=”small” :disabled=”saving || bulletSaving” @click=”openBulletEdit(data)” />
|
||||
<Button icon=”pi pi-trash” severity=”danger” outlined size=”small” :disabled=”saving || bulletSaving” @click=”askDeleteBullet(data)” />
|
||||
<Column header="Ações" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="flex gap-2">
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" :disabled="saving || bulletSaving" @click="openBulletEdit(data)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" :disabled="saving || bulletSaving" @click="askDeleteBullet(data)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -875,62 +875,62 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label=”Cancelar” severity=”secondary” outlined :disabled=”saving” @click=”showDlg = false” />
|
||||
<Button label=”Salvar” icon=”pi pi-check” :loading=”saving” @click=”save” />
|
||||
<Button label="Cancelar" severity="secondary" outlined :disabled="saving" @click="showDlg = false" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Dialog bullet (✅ sem drag + inputs padronizados) -->
|
||||
<Dialog
|
||||
v-model:visible=”showBulletDlg”
|
||||
v-model:visible="showBulletDlg"
|
||||
modal
|
||||
:header=”bulletIsEdit ? 'Editar benefício' : 'Novo benefício'”
|
||||
:style=”{ width: '560px' }”
|
||||
:closable=”!bulletSaving”
|
||||
:dismissableMask=”!bulletSaving”
|
||||
:draggable=”false”
|
||||
:header="bulletIsEdit ? 'Editar benefício' : 'Novo benefício'"
|
||||
:style="{ width: '560px' }"
|
||||
:closable="!bulletSaving"
|
||||
:dismissableMask="!bulletSaving"
|
||||
:draggable="false"
|
||||
>
|
||||
<div class=”flex flex-col gap-4”>
|
||||
<FloatLabel variant=”on”>
|
||||
<div class="flex flex-col gap-4">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class=”pi pi-list” />
|
||||
<InputIcon class="pi pi-list" />
|
||||
<Textarea
|
||||
id=”pp-bullet-text”
|
||||
v-model.trim=”bulletForm.text”
|
||||
class=”w-full”
|
||||
rows=”3”
|
||||
id="pp-bullet-text"
|
||||
v-model.trim="bulletForm.text"
|
||||
class="w-full"
|
||||
rows="3"
|
||||
autoResize
|
||||
:disabled=”bulletSaving”
|
||||
:disabled="bulletSaving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”pp-bullet-text”>Texto *</label>
|
||||
<label for="pp-bullet-text">Texto *</label>
|
||||
</FloatLabel>
|
||||
|
||||
<div class=”grid grid-cols-1 md:grid-cols-2 gap-4”>
|
||||
<FloatLabel variant=”on”>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FloatLabel variant="on">
|
||||
<IconField>
|
||||
<InputIcon class=”pi pi-sort-numeric-up” />
|
||||
<InputIcon class="pi pi-sort-numeric-up" />
|
||||
<InputNumber
|
||||
id=”pp-bullet-order”
|
||||
v-model=”bulletForm.sort_order”
|
||||
class=”w-full”
|
||||
inputClass=”w-full”
|
||||
:disabled=”bulletSaving”
|
||||
id="pp-bullet-order"
|
||||
v-model="bulletForm.sort_order"
|
||||
class="w-full"
|
||||
inputClass="w-full"
|
||||
:disabled="bulletSaving"
|
||||
/>
|
||||
</IconField>
|
||||
<label for=”pp-bullet-order”>Ordem</label>
|
||||
<label for="pp-bullet-order">Ordem</label>
|
||||
</FloatLabel>
|
||||
|
||||
<div class=”flex items-center gap-2 pt-7”>
|
||||
<Checkbox v-model=”bulletForm.highlight” :binary=”true” :disabled=”bulletSaving” />
|
||||
<div class="flex items-center gap-2 pt-7">
|
||||
<Checkbox v-model="bulletForm.highlight" :binary="true" :disabled="bulletSaving" />
|
||||
<label>Destaque</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label=”Cancelar” severity=”secondary” outlined :disabled=”bulletSaving” @click=”showBulletDlg = false” />
|
||||
<Button :label=”bulletIsEdit ? 'Salvar' : 'Criar'” icon=”pi pi-check” :loading=”bulletSaving” @click=”saveBullet” />
|
||||
<Button label="Cancelar" severity="secondary" outlined :disabled="bulletSaving" @click="showBulletDlg = false" />
|
||||
<Button :label="bulletIsEdit ? 'Salvar' : 'Criar'" icon="pi pi-check" :loading="bulletSaving" @click="saveBullet" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -35,7 +35,7 @@
|
||||
'bg-indigo-500/10 text-[var(--primary-color,#6366f1)] font-bold': cell.day === selectedDay && !cell.isToday,
|
||||
'text-[var(--text-color,#1e293b)] hover:bg-[var(--surface-hover,#f1f5f9)]': !cell.isToday && !cell.isOther,
|
||||
}"
|
||||
@click="cell.day && (selectedDay = cell.day)"
|
||||
@click="cell.day && onCalDayClick($event, cell.day)"
|
||||
>
|
||||
<span>{{ cell.day }}</span>
|
||||
<span
|
||||
@@ -60,15 +60,17 @@
|
||||
<div
|
||||
v-for="ev in eventosDoDia"
|
||||
:key="ev.id"
|
||||
class="flex items-center gap-1.5 px-1.5 py-1.5 rounded-md bg-[var(--surface-ground,#f8fafc)] border-l-[3px]"
|
||||
class="flex items-center gap-1.5 px-1.5 py-1.5 rounded-md bg-[var(--surface-ground,#f8fafc)] border-l-[3px] cursor-pointer hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100"
|
||||
:style="ev.bgColor ? { borderLeftColor: ev.bgColor } : {}"
|
||||
:class="{
|
||||
'border-l-sky-400': ev.tipo === 'reuniao',
|
||||
'border-l-green-400': ev.status === 'realizado',
|
||||
'border-l-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && ev.status !== 'realizado',
|
||||
'border-l-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
|
||||
'border-l-green-400': !ev.bgColor && ev.status === 'realizado',
|
||||
'border-l-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado',
|
||||
}"
|
||||
@click="openEvMenu($event, ev)"
|
||||
>
|
||||
<div class="flex flex-col items-end min-w-[38px]">
|
||||
<span class="text-[1rem] font-bold text-[var(--text-color)]">{{ ev.hora }}</span>
|
||||
<span class="text-[0.7rem] font-bold text-[var(--text-color)]">{{ ev.hora }}</span>
|
||||
<span class="text-xs text-[var(--text-color-secondary)]">{{ ev.dur }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
@@ -93,7 +95,7 @@
|
||||
<span class="ml-auto bg-[var(--primary-color,#6366f1)] text-white rounded-full px-1.5 text-xs font-bold">{{ recorrencias.length }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 max-h-[170px] overflow-y-auto">
|
||||
<div v-for="r in recorrencias" :key="r.id" class="flex items-center gap-2 py-0.5">
|
||||
<div v-for="r in recorrencias" :key="r.id" class="flex items-center gap-2 py-0.5 cursor-pointer hover:bg-[var(--surface-hover,#f1f5f9)] rounded-md px-1 transition-colors duration-100" @click="openRecMenu($event, r)">
|
||||
<div class="w-[26px] h-[26px] rounded-full flex items-center justify-center text-[0.58rem] font-bold text-white flex-shrink-0" :style="{ background: r.color }">{{ r.initials }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-xs font-semibold">{{ r.nome }}</span>
|
||||
@@ -130,7 +132,7 @@
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1.1rem] font-bold tracking-tight text-[var(--text-color)]">{{ saudacao }} <span class="text-[var(--primary-color,#6366f1)]">👋</span></div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] mt-0.5">{{ resumoHoje }}</div>
|
||||
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">{{ resumoHoje }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Controles (desktop e mobile — mesmo conteúdo, sempre visível) -->
|
||||
@@ -163,7 +165,7 @@
|
||||
'text-[var(--text-color)]': !s.cls,
|
||||
}"
|
||||
>{{ s.value }}</div>
|
||||
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
|
||||
<div class="text-[0.7rem] text-[var(--text-color-secondary)] opacity-75">{{ s.label }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,11 +206,11 @@
|
||||
v-for="ev in timelineEvents"
|
||||
:key="ev.id"
|
||||
class="absolute top-[3px] h-[34px] rounded flex items-center px-1.5 overflow-hidden cursor-default min-w-[32px] hover:brightness-110 transition-[filter] duration-150 z-10"
|
||||
:style="ev.style"
|
||||
:style="{ ...ev.style, ...(ev.bgColor ? { backgroundColor: ev.bgColor, color: ev.txtColor || '#fff' } : {}) }"
|
||||
:class="{
|
||||
'bg-sky-400': ev.tipo === 'reuniao',
|
||||
'bg-green-500': ev.status === 'realizado',
|
||||
'bg-[var(--primary-color,#6366f1)]': ev.tipo !== 'reuniao' && ev.status !== 'realizado',
|
||||
'bg-sky-400': !ev.bgColor && ev.tipo === 'reuniao',
|
||||
'bg-green-500': !ev.bgColor && ev.status === 'realizado',
|
||||
'bg-[var(--primary-color,#6366f1)]': !ev.bgColor && ev.tipo !== 'reuniao' && ev.status !== 'realizado',
|
||||
}"
|
||||
:title="ev.tooltip"
|
||||
>
|
||||
@@ -235,7 +237,7 @@
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-red-500/10 text-red-500"><i class="pi pi-inbox" /></div>
|
||||
<div class="flex-1">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Agendador Online</span>
|
||||
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Agendador Online</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">Solicitações do portal externo</span>
|
||||
</div>
|
||||
<span v-if="solicitacoesPendentes > 0" class="rounded-full px-1.5 py-px text-xs font-bold bg-red-50 text-red-500 border border-red-300">{{ solicitacoesPendentes }}</span>
|
||||
@@ -266,7 +268,7 @@
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-sky-500/10 text-sky-500"><i class="pi pi-user-plus" /></div>
|
||||
<div class="flex-1">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Cadastros Externos</span>
|
||||
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Cadastros Externos</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">Pacientes aguardando triagem</span>
|
||||
</div>
|
||||
<span v-if="cadastrosPendentes > 0" class="rounded-full px-1.5 py-px text-xs font-bold bg-blue-50 text-blue-500 border border-blue-200">{{ cadastrosPendentes }}</span>
|
||||
@@ -275,7 +277,7 @@
|
||||
<div v-for="c in cadastros" :key="c.id" class="flex items-center gap-2">
|
||||
<div class="w-[26px] h-[26px] rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 text-white text-[0.58rem] font-bold flex items-center justify-center flex-shrink-0">{{ c.initials }}</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<span class="block text-[1rem] font-semibold">{{ c.nome }}</span>
|
||||
<span class="block text-[0.7rem] font-semibold">{{ c.nome }}</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">{{ c.detalhe }}</span>
|
||||
</div>
|
||||
<button
|
||||
@@ -299,7 +301,7 @@
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-amber-500/10 text-amber-500"><i class="pi pi-refresh" /></div>
|
||||
<div class="flex-1">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Recorrências</span>
|
||||
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Recorrências</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">Atenção necessária</span>
|
||||
</div>
|
||||
<span v-if="recAlerta.length" class="rounded-full px-1.5 py-px text-xs font-bold bg-amber-50 text-amber-500 border border-amber-200">{{ recAlerta.length }}</span>
|
||||
@@ -307,7 +309,7 @@
|
||||
<div class="flex-1 flex flex-col gap-1.5 px-3.5 py-1.5 min-h-[72px]">
|
||||
<div v-for="r in recAlerta" :key="r.id" class="flex items-center gap-2.5 py-1">
|
||||
<div class="flex-1">
|
||||
<span class="block text-[1rem] font-semibold">{{ r.nome }}</span>
|
||||
<span class="block text-[0.7rem] font-semibold">{{ r.nome }}</span>
|
||||
<span
|
||||
class="block text-xs font-semibold mt-0.5"
|
||||
:class="{
|
||||
@@ -342,7 +344,7 @@
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3 pb-2 border-b border-[var(--surface-border,#f1f5f9)]">
|
||||
<div class="w-8 h-8 rounded-md flex items-center justify-center text-[0.9rem] flex-shrink-0 bg-indigo-500/10 text-indigo-500"><i class="pi pi-chart-pie" /></div>
|
||||
<div class="flex-1">
|
||||
<span class="block text-[1rem] font-bold text-[var(--text-color)]">Radar da Semana</span>
|
||||
<span class="block text-[0.78rem] font-bold text-[var(--text-color)]">Radar da Semana</span>
|
||||
<span class="block text-xs text-[var(--text-color-secondary)]">Presença, faltas e reposições</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,13 +420,49 @@
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- Menus de contexto (fora do aside para evitar visibility:hidden) -->
|
||||
<Menu ref="calDayMenuRef" :model="calDayMenuItems" :popup="true" />
|
||||
<Menu ref="evMenuRef" :model="evMenuItems" :popup="true" />
|
||||
<Menu ref="recMenuRef" :model="recMenuItems" :popup="true" />
|
||||
|
||||
<!-- Dialog: Novo Compromisso (aberto pelo menu de contexto do mini calendário) -->
|
||||
<AgendaEventDialog
|
||||
v-if="agendaDialogOpen"
|
||||
v-model="agendaDialogOpen"
|
||||
:eventRow="agendaDialogEventRow"
|
||||
:initialStartISO="agendaDialogStartISO"
|
||||
:initialEndISO="agendaDialogEndISO"
|
||||
:ownerId="ownerId"
|
||||
:tenantId="clinicTenantId"
|
||||
:commitmentOptions="commitmentOptionsNormalized"
|
||||
newPatientRoute="/therapist/patients/cadastro"
|
||||
@save="onAgendaDialogSave"
|
||||
@delete="() => { agendaDialogOpen = false; load() }"
|
||||
/>
|
||||
|
||||
<!-- Dialog: Prontuário do paciente -->
|
||||
<PatientProntuario
|
||||
:key="selectedPatient?.id || 'none'"
|
||||
v-model="prontuarioOpen"
|
||||
:patient="selectedPatient"
|
||||
@close="closeProntuario"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Menu from 'primevue/menu'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
|
||||
import AgendaEventDialog from '@/features/agenda/components/AgendaEventDialog.vue'
|
||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
|
||||
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||
|
||||
const dashHeroSentinelRef = ref(null)
|
||||
const heroStuck = ref(false)
|
||||
@@ -449,6 +487,241 @@ const saudacao = computed(() => {
|
||||
})
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const { create: createEvento, update: updateEvento } = useAgendaEvents()
|
||||
|
||||
// ── Prontuário ────────────────────────────────────────────────
|
||||
const prontuarioOpen = ref(false)
|
||||
const selectedPatient = ref(null)
|
||||
|
||||
function openProntuario (patientId, patientNome) {
|
||||
if (!patientId) return
|
||||
selectedPatient.value = { id: patientId, nome_completo: patientNome || '' }
|
||||
prontuarioOpen.value = true
|
||||
}
|
||||
function closeProntuario () { prontuarioOpen.value = false; selectedPatient.value = null }
|
||||
|
||||
// ── Tipos de compromisso (para o dialog) ─────────────────────
|
||||
const clinicTenantId = computed(() => tenantStore.activeTenantId || tenantStore.tenantId || null)
|
||||
const { rows: determinedCommitments, load: loadCommitments } = useDeterminedCommitments(clinicTenantId)
|
||||
|
||||
const COMMITMENT_PRIORITY = new Map([
|
||||
['session', 0], ['class', 1], ['study', 2],
|
||||
['reading', 3], ['supervision', 4], ['content_creation', 5],
|
||||
])
|
||||
|
||||
const commitmentOptionsNormalized = computed(() => {
|
||||
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []
|
||||
return [...list]
|
||||
.filter(i => i?.id && i?.active !== false)
|
||||
.sort((a, b) => {
|
||||
const pa = COMMITMENT_PRIORITY.get(a.native_key) ?? 99
|
||||
const pb = COMMITMENT_PRIORITY.get(b.native_key) ?? 99
|
||||
if (pa !== pb) return pa - pb
|
||||
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR')
|
||||
})
|
||||
.map(i => ({
|
||||
id: i.id,
|
||||
tenant_id: i.tenant_id ?? null,
|
||||
created_by: i.created_by ?? null,
|
||||
name: String(i.name || '').trim() || 'Sem nome',
|
||||
description: i.description || '',
|
||||
native_key: i.native_key || null,
|
||||
is_native: !!i.is_native,
|
||||
is_locked: !!i.is_locked,
|
||||
active: i.active !== false,
|
||||
bg_color: i.bg_color || null,
|
||||
text_color: i.text_color || null,
|
||||
fields: Array.isArray(i.determined_commitment_fields)
|
||||
? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
: [],
|
||||
}))
|
||||
})
|
||||
|
||||
// ── Mini calendário: menu de contexto ────────────────────────
|
||||
const calDayMenuRef = ref(null)
|
||||
|
||||
const calDayMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Opções do dia',
|
||||
items: [
|
||||
{
|
||||
label: 'Novo Compromisso',
|
||||
icon: 'pi pi-plus-circle',
|
||||
command: () => openNovoCompromisso(),
|
||||
},
|
||||
{
|
||||
label: 'Ver dia na agenda',
|
||||
icon: 'pi pi-calendar',
|
||||
command: () => verDiaNaAgenda(),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function onCalDayClick (event, day) {
|
||||
selectedDay.value = day
|
||||
calDayMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
function verDiaNaAgenda () {
|
||||
const d = String(selectedDay.value).padStart(2, '0')
|
||||
const m = String(mesAtual + 1).padStart(2, '0')
|
||||
router.push(`/therapist/agenda?date=${anoAtual}-${m}-${d}`)
|
||||
}
|
||||
|
||||
// ── Menu de contexto: Eventos do dia ─────────────────────────
|
||||
const evMenuRef = ref(null)
|
||||
const _evAtivo = ref(null) // evento clicado
|
||||
|
||||
const evMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Opções',
|
||||
items: [
|
||||
{
|
||||
label: 'Ver prontuário',
|
||||
icon: 'pi pi-file-edit',
|
||||
disabled: !_evAtivo.value?.patientId,
|
||||
command: () => openProntuario(_evAtivo.value?.patientId, _evAtivo.value?.nome),
|
||||
},
|
||||
{
|
||||
label: 'Ver na agenda',
|
||||
icon: 'pi pi-calendar',
|
||||
command: () => {
|
||||
if (!_evAtivo.value?.inicioISO) return
|
||||
const d = new Date(_evAtivo.value.inicioISO)
|
||||
const ds = d.toISOString().slice(0, 10)
|
||||
router.push(`/therapist/agenda?date=${ds}`)
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function openEvMenu (event, ev) {
|
||||
_evAtivo.value = ev
|
||||
evMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// ── Menu de contexto: Recorrências ativas ────────────────────
|
||||
const recMenuRef = ref(null)
|
||||
const _recAtivo = ref(null) // recorrência clicada
|
||||
|
||||
const recMenuItems = computed(() => [
|
||||
{
|
||||
label: 'Opções',
|
||||
items: [
|
||||
{
|
||||
label: 'Ver prontuário',
|
||||
icon: 'pi pi-file-edit',
|
||||
disabled: !_recAtivo.value?.patientId,
|
||||
command: () => openProntuario(_recAtivo.value?.patientId, _recAtivo.value?.nome),
|
||||
},
|
||||
{
|
||||
label: 'Ver na agenda',
|
||||
icon: 'pi pi-calendar',
|
||||
command: () => router.push('/therapist/agenda'),
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
function openRecMenu (event, r) {
|
||||
_recAtivo.value = r
|
||||
recMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// ── Dialog: Novo Compromisso ──────────────────────────────────
|
||||
const agendaDialogOpen = ref(false)
|
||||
const agendaDialogEventRow = ref(null)
|
||||
const agendaDialogStartISO = ref('')
|
||||
const agendaDialogEndISO = ref('')
|
||||
|
||||
function openNovoCompromisso () {
|
||||
if (!ownerId.value) return
|
||||
const durMin = 50
|
||||
const now = new Date()
|
||||
const base = new Date(anoAtual, mesAtual, selectedDay.value, now.getHours(), now.getMinutes(), 0, 0)
|
||||
|
||||
agendaDialogEventRow.value = {
|
||||
owner_id: ownerId.value,
|
||||
tipo: 'sessao',
|
||||
status: 'agendado',
|
||||
titulo: null,
|
||||
observacoes: null,
|
||||
}
|
||||
agendaDialogStartISO.value = base.toISOString()
|
||||
agendaDialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString()
|
||||
agendaDialogOpen.value = true
|
||||
}
|
||||
|
||||
function _isUuid (v) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
|
||||
}
|
||||
|
||||
function _pickDbFields (obj) {
|
||||
const allowed = [
|
||||
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
||||
'tipo', 'status', 'titulo', 'observacoes',
|
||||
'inicio_em', 'fim_em', 'visibility_scope',
|
||||
'determined_commitment_id', 'titulo_custom', 'extra_fields',
|
||||
'recurrence_id', 'recurrence_date',
|
||||
'price', 'insurance_plan_id', 'insurance_guide_number',
|
||||
'insurance_value', 'insurance_plan_service_id',
|
||||
]
|
||||
const out = {}
|
||||
for (const k of allowed) { if (obj[k] !== undefined) out[k] = obj[k] }
|
||||
return out
|
||||
}
|
||||
|
||||
async function onAgendaDialogSave (arg) {
|
||||
try {
|
||||
const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload')
|
||||
const payload = isWrapped ? arg.payload : arg
|
||||
const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null)
|
||||
|
||||
const normalized = { ...(payload || {}) }
|
||||
|
||||
if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value
|
||||
|
||||
const tid = clinicTenantId.value
|
||||
if (!tid) throw new Error('tenant_id não encontrado.')
|
||||
normalized.tenant_id = tid
|
||||
|
||||
if (!normalized.visibility_scope) normalized.visibility_scope = 'public'
|
||||
if (!normalized.status) normalized.status = 'agendado'
|
||||
if (!normalized.tipo) normalized.tipo = 'sessao'
|
||||
if (!String(normalized.titulo || '').trim()) normalized.titulo = normalized.tipo === 'bloqueio' ? 'Ocupado' : 'Sessão'
|
||||
if (!_isUuid(normalized.paciente_id)) normalized.paciente_id = null
|
||||
if (normalized.determined_commitment_id && !_isUuid(normalized.determined_commitment_id)) normalized.determined_commitment_id = null
|
||||
|
||||
const dbPayload = _pickDbFields(normalized)
|
||||
|
||||
if (id) {
|
||||
await updateEvento(id, dbPayload)
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 })
|
||||
} else {
|
||||
await createEvento(dbPayload)
|
||||
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado com sucesso.', life: 2500 })
|
||||
}
|
||||
|
||||
agendaDialogOpen.value = false
|
||||
await load()
|
||||
|
||||
} catch (e) {
|
||||
const msg = String(e?.message || '')
|
||||
const isOverlap =
|
||||
e?.code === '23P01' ||
|
||||
msg.includes('agenda_eventos_sem_sobreposicao') ||
|
||||
msg.includes('exclusion constraint')
|
||||
if (isOverlap) {
|
||||
toast.add({ severity: 'warn', summary: 'Conflito de horário', detail: 'Já existe um compromisso neste horário.', life: 4000 })
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: msg || 'Tente novamente.', life: 4000 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ownerId = ref(null)
|
||||
const eventosDoMes = ref([])
|
||||
@@ -506,18 +779,33 @@ const STATUS_ICON = {
|
||||
agendado: 'pi pi-clock',
|
||||
}
|
||||
|
||||
const commitmentColorMap = computed(() =>
|
||||
new Map(
|
||||
commitmentOptionsNormalized.value
|
||||
.filter(c => c.id && c.bg_color)
|
||||
.map(c => [c.id, { bg_color: c.bg_color, text_color: c.text_color }])
|
||||
)
|
||||
)
|
||||
|
||||
function buildEventoItem (ev) {
|
||||
const inicio = new Date(ev.inicio_em)
|
||||
const fim = ev.fim_em ? new Date(ev.fim_em) : null
|
||||
const durMin = fim ? Math.round((fim - inicio) / 60000) : 50
|
||||
const h = inicio.getHours().toString().padStart(2, '0')
|
||||
const m = inicio.getMinutes().toString().padStart(2, '0')
|
||||
const joinColor = ev.determined_commitments
|
||||
const mapColor = ev.determined_commitment_id ? commitmentColorMap.value.get(ev.determined_commitment_id) : null
|
||||
const bgColor = joinColor?.bg_color ? `#${joinColor.bg_color}` : mapColor?.bg_color ? `#${mapColor.bg_color}` : null
|
||||
const txtColor = joinColor?.text_color ? `#${joinColor.text_color}` : mapColor?.text_color ? `#${mapColor.text_color}` : null
|
||||
return {
|
||||
id: ev.id, hora: `${h}:${m}`, dur: `${durMin}min`,
|
||||
nome: ev.patients?.nome_completo || ev.titulo || ev.titulo_custom || '—',
|
||||
modalidade: ev.modalidade || 'Presencial', recorrente: !!ev.recurrence_id,
|
||||
status: ev.status || 'agendado', statusIcon: STATUS_ICON[ev.status] || 'pi pi-clock',
|
||||
tipo: ev.tipo || 'sessao',
|
||||
patientId: ev.patient_id || null,
|
||||
inicioISO: ev.inicio_em || null,
|
||||
bgColor, txtColor,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,40 +864,62 @@ const recorrencias = computed(() =>
|
||||
const diaLabel = weekdays.map(d => DIAS_PT[d]).join(', ')
|
||||
const hora = r.start_time ? String(r.start_time).slice(0, 5) : ''
|
||||
const proxLabel = nextOccurrenceLabel(r)
|
||||
return { id: r.id, nome: nomeAb, freq: `${freq} · ${diaLabel}${hora ? ' ' + hora : ''}`, proxLabel, proxHoje: proxLabel === 'Hoje', color: hashColor(r.patient_id || r.id), initials: initials(nome) }
|
||||
return { id: r.id, nome: nomeAb, freq: `${freq} · ${diaLabel}${hora ? ' ' + hora : ''}`, proxLabel, proxHoje: proxLabel === 'Hoje', color: hashColor(r.patient_id || r.id), initials: initials(nome), patientId: r.patient_id || null }
|
||||
})
|
||||
)
|
||||
|
||||
const eventosHoje = computed(() =>
|
||||
eventosDoMes.value.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d.getDate() === hoje && d.getMonth() === mesAtual && d.getFullYear() === anoAtual })
|
||||
)
|
||||
// ── Derivados de eventosDoMes — single pass ───────────────────
|
||||
// Um único computed varre o array uma vez e extrai tudo,
|
||||
// evitando N loops separados que re-executam a cada reatividade.
|
||||
const _statsDoMes = computed(() => {
|
||||
const now = agora.value
|
||||
const semIni = new Date(now); semIni.setDate(now.getDate() - now.getDay()); semIni.setHours(0, 0, 0, 0)
|
||||
const semFim = new Date(semIni); semFim.setDate(semIni.getDate() + 6); semFim.setHours(23, 59, 59, 999)
|
||||
const daqui30 = new Date(now); daqui30.setDate(now.getDate() + 30)
|
||||
|
||||
const eventosSemana = computed(() => {
|
||||
const now = agora.value, ini = new Date(now)
|
||||
ini.setDate(now.getDate() - now.getDay()); ini.setHours(0, 0, 0, 0)
|
||||
const fim = new Date(ini); fim.setDate(ini.getDate() + 6); fim.setHours(23, 59, 59, 999)
|
||||
return eventosDoMes.value.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d >= ini && d <= fim })
|
||||
let hojeCnt = 0, semanaCnt = 0, realizadosCnt = 0, encerradosCnt = 0
|
||||
const hojeLista = [], timelineLista = []
|
||||
const diasSemanaMap = [[], [], [], [], [], [], []]
|
||||
|
||||
for (const ev of eventosDoMes.value) {
|
||||
if (!ev.inicio_em) continue
|
||||
const d = new Date(ev.inicio_em)
|
||||
const dDay = d.getDate(), dMon = d.getMonth(), dYear = d.getFullYear()
|
||||
const isHoje = dDay === hoje && dMon === mesAtual && dYear === anoAtual
|
||||
|
||||
if (isHoje) { hojeCnt++; hojeLista.push(ev); timelineLista.push(ev) }
|
||||
if (d >= semIni && d <= semFim) {
|
||||
semanaCnt++
|
||||
diasSemanaMap[d.getDay()].push(ev)
|
||||
}
|
||||
if (d < now && ['realizado','faltou','cancelado'].includes(ev.status)) {
|
||||
encerradosCnt++
|
||||
if (ev.status === 'realizado') realizadosCnt++
|
||||
}
|
||||
}
|
||||
|
||||
const taxaPresenca = encerradosCnt > 0 ? Math.round((realizadosCnt / encerradosCnt) * 100) : null
|
||||
|
||||
return { hojeCnt, semanaCnt, taxaPresenca, hojeLista, timelineLista, diasSemanaMap }
|
||||
})
|
||||
|
||||
const taxaPresenca = computed(() => {
|
||||
const encerrados = eventosDoMes.value.filter(ev => ev.inicio_em && new Date(ev.inicio_em) < new Date() && ['realizado','faltou','cancelado'].includes(ev.status))
|
||||
if (!encerrados.length) return null
|
||||
return Math.round((encerrados.filter(ev => ev.status === 'realizado').length / encerrados.length) * 100)
|
||||
})
|
||||
const eventosHoje = computed(() => _statsDoMes.value.hojeLista)
|
||||
const eventosSemana = computed(() => ({ length: _statsDoMes.value.semanaCnt }))
|
||||
const taxaPresenca = computed(() => _statsDoMes.value.taxaPresenca)
|
||||
|
||||
const quickStats = computed(() => {
|
||||
const pendentes = _solicitacoesBruto.value.length + _cadastrosBruto.value.length
|
||||
const pct = taxaPresenca.value
|
||||
return [
|
||||
{ value: String(eventosHoje.value.length), label: 'Hoje', cls: '' },
|
||||
{ value: String(_statsDoMes.value.hojeCnt), label: 'Hoje', cls: '' },
|
||||
{ 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' : '' },
|
||||
]
|
||||
})
|
||||
|
||||
const resumoHoje = computed(() => {
|
||||
const sessoes = eventosHoje.value.filter(ev => ev.tipo !== 'bloqueio').length
|
||||
const sessoes = _statsDoMes.value.hojeLista.filter(ev => ev.tipo !== 'bloqueio').length
|
||||
const sols = _solicitacoesBruto.value.length
|
||||
const parts = []
|
||||
if (sessoes === 1) parts.push('1 sessão hoje')
|
||||
@@ -648,11 +958,11 @@ const cadastros = computed(() =>
|
||||
const cadastrosPendentes = computed(() => cadastros.value.length)
|
||||
|
||||
const recAlerta = computed(() => {
|
||||
const daqui30 = new Date(); daqui30.setDate(daqui30.getDate() + 30)
|
||||
const now = new Date(), daqui30 = new Date(); daqui30.setDate(now.getDate() + 30)
|
||||
const alerts = []
|
||||
for (const r of regraRecorrencias.value) {
|
||||
const nome = (r._patientNome || '—').split(' ').slice(0, 2).join(' ')
|
||||
if (r.end_date) { const ed = new Date(r.end_date + 'T00:00:00'); if (ed >= new Date() && ed <= daqui30) alerts.push({ id: r.id + '_end', nome, motivo: 'Encerramento próximo', tipo: 'feriado' }) }
|
||||
if (r.end_date) { const ed = new Date(r.end_date + 'T00:00:00'); if (ed >= now && ed <= daqui30) alerts.push({ id: r.id + '_end', nome, motivo: 'Encerramento próximo', tipo: 'feriado' }) }
|
||||
if (r.max_occurrences && r._sessionsCount !== undefined) {
|
||||
const pct = (r._sessionsCount / r.max_occurrences) * 100
|
||||
if (pct > 75) alerts.push({ id: r.id + '_limit', nome, motivo: 'Limite próximo', tipo: 'limite', sessoesUsadas: r._sessionsCount, totalSessoes: r.max_occurrences, progresso: Math.round(pct) })
|
||||
@@ -662,12 +972,14 @@ const recAlerta = computed(() => {
|
||||
})
|
||||
|
||||
const radarSemana = computed(() => {
|
||||
const now = agora.value, dow = now.getDay(), iniSem = new Date(now)
|
||||
iniSem.setDate(now.getDate() - dow); iniSem.setHours(0, 0, 0, 0)
|
||||
const diasMap = _statsDoMes.value.diasSemanaMap
|
||||
const dow = agora.value.getDay()
|
||||
return DIAS_PT.map((dia, i) => {
|
||||
const dayDate = new Date(iniSem); dayDate.setDate(iniSem.getDate() + i)
|
||||
const evs = eventosDoMes.value.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d.getDate() === dayDate.getDate() && d.getMonth() === dayDate.getMonth() && d.getFullYear() === dayDate.getFullYear() })
|
||||
const total = evs.length, presentes = evs.filter(ev => ev.status === 'realizado').length, faltas = evs.filter(ev => ev.status === 'faltou').length, reposicao = evs.filter(ev => ['reposicao','reposição'].includes(ev.status)).length
|
||||
const evs = diasMap[i]
|
||||
const total = evs.length
|
||||
const presentes = evs.filter(ev => ev.status === 'realizado').length
|
||||
const faltas = evs.filter(ev => ev.status === 'faltou').length
|
||||
const reposicao = evs.filter(ev => ['reposicao','reposição'].includes(ev.status)).length
|
||||
let status = 'ok'
|
||||
if (faltas > 0 && faltas >= presentes) status = 'falta'
|
||||
else if (reposicao > 0 && reposicao > presentes) status = 'repo'
|
||||
@@ -691,15 +1003,16 @@ const hoursRange = [7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
|
||||
const TL_START = 7, TL_END = 20, TL_SPAN = TL_END - TL_START
|
||||
function toPercent (h, m) { return ((h + m / 60 - TL_START) / TL_SPAN) * 100 }
|
||||
|
||||
|
||||
const timelineEvents = computed(() =>
|
||||
eventosDoMes.value
|
||||
.filter(ev => { if (!ev.inicio_em) return false; const d = new Date(ev.inicio_em); return d.getDate() === hoje && d.getMonth() === mesAtual && d.getFullYear() === anoAtual })
|
||||
_statsDoMes.value.timelineLista
|
||||
.slice()
|
||||
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em))
|
||||
.map(ev => {
|
||||
const item = buildEventoItem(ev)
|
||||
const [hh, mm] = item.hora.split(':').map(Number)
|
||||
const durMin = parseInt(item.dur) || 50
|
||||
return { id: item.id, label: item.nome.split(' ')[0], tipo: item.tipo, status: item.status, tooltip: `${item.hora} · ${item.nome} · ${item.modalidade}`, badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '', style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' } }
|
||||
return { id: item.id, label: item.nome.split(' ')[0], tipo: item.tipo, status: item.status, tooltip: `${item.hora} · ${item.nome} · ${item.modalidade}`, badge: item.modalidade?.toLowerCase() === 'online' ? '📱' : '', bgColor: item.bgColor, txtColor: item.txtColor, style: { left: toPercent(hh, mm) + '%', width: Math.max((durMin / 60 / TL_SPAN) * 100, 4) + '%' } }
|
||||
})
|
||||
)
|
||||
|
||||
@@ -714,11 +1027,12 @@ async function load () {
|
||||
if (!ownerId.value) return
|
||||
await tenantStore.ensureLoaded()
|
||||
const tid = tenantStore.activeTenantId || tenantStore.tenantId || null
|
||||
await loadCommitments()
|
||||
const mesInicio = new Date(anoAtual, mesAtual, 1, 0, 0, 0, 0).toISOString()
|
||||
const mesFim = new Date(anoAtual, mesAtual + 1, 0, 23, 59, 59, 999).toISOString()
|
||||
try {
|
||||
const [eventosRes, recRes, solRes, cadRes] = await Promise.all([
|
||||
(() => { let q = supabase.from('agenda_eventos').select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, recurrence_id, patients(nome_completo)').eq('owner_id', ownerId.value).gte('inicio_em', mesInicio).lte('inicio_em', mesFim).order('inicio_em', { ascending: true }); if (tid) q = q.eq('tenant_id', tid); return q })(),
|
||||
(() => { let q = supabase.from('agenda_eventos').select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, recurrence_id, determined_commitment_id, patients(nome_completo), determined_commitments(bg_color, text_color)').eq('owner_id', ownerId.value).gte('inicio_em', mesInicio).lte('inicio_em', mesFim).order('inicio_em', { ascending: true }); if (tid) q = q.eq('tenant_id', tid); return q })(),
|
||||
(() => { let q = supabase.from('recurrence_rules').select('id, patient_id, type, interval, weekdays, start_date, end_date, max_occurrences, start_time').eq('owner_id', ownerId.value).eq('status', 'ativo').order('start_date', { ascending: false }); if (tid) q = q.eq('tenant_id', tid); return q })(),
|
||||
supabase.from('agendador_solicitacoes').select('id, paciente_nome, paciente_sobrenome, tipo, modalidade, data_solicitada, hora_solicitada').eq('owner_id', ownerId.value).eq('status', 'pendente').order('created_at', { ascending: false }).limit(10),
|
||||
supabase.from('patient_intake_requests').select('id, nome_completo, status, created_at').eq('owner_id', ownerId.value).eq('status', 'new').order('created_at', { ascending: false }).limit(10),
|
||||
@@ -780,15 +1094,4 @@ onMounted(async () => {
|
||||
0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); }
|
||||
50% { box-shadow: 0 0 0 4px rgba(239,68,68,0); }
|
||||
}
|
||||
|
||||
/* Highlight pulse (acionado externamente via classe JS) */
|
||||
@keyframes highlight-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(99,102,241,0.7), 0 0 0 0 rgba(99,102,241,0.4); }
|
||||
40% { box-shadow: 0 0 0 8px rgba(99,102,241,0.3), 0 0 0 16px rgba(99,102,241,0.1); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(99,102,241,0), 0 0 0 0 rgba(99,102,241,0); }
|
||||
}
|
||||
.notif-card--highlight {
|
||||
animation: highlight-pulse 1s ease-out 3;
|
||||
border-color: rgba(99,102,241,0.6) !important;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user