From d6d2fe29d1a25ebbeff71525252f41b5700457fb Mon Sep 17 00:00:00 2001 From: Leonardo Date: Wed, 18 Mar 2026 09:26:09 -0300 Subject: [PATCH] =?UTF-8?q?carousel,=20agenda=20arquivados,=20agenda=20cor?= =?UTF-8?q?,=20agenda=20arquivados,=20grupos=20pacientes,=20pacientes=20ar?= =?UTF-8?q?quivados=20-=20desativados,=20sessoes=20verificadas,=20ajuste?= =?UTF-8?q?=20notifica=C3=A7=C3=B5es,=20Prontuario,=20Agenda=20Animation,?= =?UTF-8?q?=20Menu=20Profile,=20bagdes=20Profile,=20Offline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 4 +- DBS/2026-03-17/schema.sql | 309 ++++- package-lock.json | 46 + package.json | 2 + src/App.vue | 7 +- src/assets/styles.scss | 30 +- src/components/AppOfflineOverlay.vue | 245 ++++ src/components/ComponentCadastroRapido.vue | 4 +- .../agenda/AgendaOnlineGradeCard.vue | 4 +- src/components/agenda/PausasChipsEditor.vue | 4 +- src/components/landing/FeaturesWidget.vue | 2 +- .../notifications/NotificationDrawer.vue | 4 +- .../notifications/NotificationItem.vue | 143 +-- src/components/patients/PatientActionMenu.vue | 219 ++++ src/composables/useMenuBadges.js | 85 ++ src/composables/usePatientLifecycle.js | 74 ++ .../agenda/components/AgendaCalendar.vue | 6 +- .../agenda/components/AgendaClinicMosaic.vue | 35 +- .../agenda/components/AgendaEventDialog.vue | 95 +- .../agenda/components/AgendaRightPanel.vue | 2 +- .../components/cards/AgendaPulseCardGrid.vue | 2 +- .../agenda/composables/useAgendaEvents.js | 3 +- .../agenda/pages/AgendaClinicaPage.vue | 50 +- .../agenda/pages/AgendaTerapeutaPage.vue | 470 ++++++- .../agenda/services/agendaClinicRepository.js | 2 +- src/features/agenda/services/agendaMappers.js | 1 + src/features/patients/PatientsListPage.vue | 217 +++- .../patients/prontuario/PatientProntuario.vue | 1132 +++++++++-------- src/layout/AppMenu.vue | 2 +- src/layout/AppMenuFooterPanel.vue | 285 +++-- src/layout/AppMenuItem.vue | 20 +- src/layout/AppRail.vue | 63 +- src/layout/AppRailPanel.vue | 12 + src/layout/AppRailSidebar.vue | 21 + src/layout/AppSidebar.vue | 2 +- src/layout/AppTopbar.vue | 2 +- src/layout/composables/layout.js | 2 +- src/navigation/index.js | 2 +- src/navigation/menus/clinic.menu.js | 8 +- src/navigation/menus/therapist.menu.js | 7 +- src/router/accessRedirects.js | 10 +- src/router/guards.js | 2 +- src/services/agendaSlotsBloqueadosService.js | 2 +- src/sql-arquivos/patient_lifecycle.sql | 85 ++ src/theme/theme.options.js | 4 +- src/views/pages/NotFound.vue | 4 +- .../clinic/clinic/ClinicFeaturesPage.vue | 266 ++-- src/views/pages/public/AcceptInvitePage.vue | 4 +- .../pages/public/CadastroPacienteExterno.vue | 4 +- src/views/pages/public/Landingpage-v1.vue | 6 +- src/views/pages/public/Signup.vue | 2 +- .../pages/saas/SaasPlanFeaturesMatrixPage.vue | 2 +- src/views/pages/saas/SaasPlansPage.vue | 270 ++-- src/views/pages/saas/SaasPlansPublicPage.vue | 460 +++---- .../pages/therapist/TherapistDashboard.vue | 423 +++++- 55 files changed, 3655 insertions(+), 1512 deletions(-) create mode 100644 src/components/AppOfflineOverlay.vue create mode 100644 src/components/patients/PatientActionMenu.vue create mode 100644 src/composables/useMenuBadges.js create mode 100644 src/composables/usePatientLifecycle.js create mode 100644 src/sql-arquivos/patient_lifecycle.sql diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e26335d..359e088 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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\\)\")" ] } } diff --git a/DBS/2026-03-17/schema.sql b/DBS/2026-03-17/schema.sql index 82895bf..5516f6c 100644 --- a/DBS/2026-03-17/schema.sql +++ b/DBS/2026-03-17/schema.sql @@ -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 diff --git a/package-lock.json b/package-lock.json index 271a2ae..872b966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index a8328d1..24b1043 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/App.vue b/src/App.vue index e6a5557..2284970 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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( + + + \ No newline at end of file diff --git a/src/assets/styles.scss b/src/assets/styles.scss index 72ce665..883dec9 100644 --- a/src/assets/styles.scss +++ b/src/assets/styles.scss @@ -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; +} \ No newline at end of file diff --git a/src/components/AppOfflineOverlay.vue b/src/components/AppOfflineOverlay.vue new file mode 100644 index 0000000..d1c3d66 --- /dev/null +++ b/src/components/AppOfflineOverlay.vue @@ -0,0 +1,245 @@ + + + + + + + diff --git a/src/components/ComponentCadastroRapido.vue b/src/components/ComponentCadastroRapido.vue index 76f64bf..27744cc 100644 --- a/src/components/ComponentCadastroRapido.vue +++ b/src/components/ComponentCadastroRapido.vue @@ -98,7 +98,7 @@
- Dica: “Gerar usuário” preenche automaticamente com dados fictícios. + Dica: "Gerar usuário" preenche automaticamente com dados fictícios.
@@ -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([ diff --git a/src/components/agenda/AgendaOnlineGradeCard.vue b/src/components/agenda/AgendaOnlineGradeCard.vue index 876fa9c..9a2157a 100644 --- a/src/components/agenda/AgendaOnlineGradeCard.vue +++ b/src/components/agenda/AgendaOnlineGradeCard.vue @@ -134,7 +134,7 @@ onMounted(load)
- +
Tipo de slots
@@ -158,7 +158,7 @@ onMounted(load)
Jornada do dia
- (Isso vem das suas “janelas semanais”) + (Isso vem das suas "janelas semanais")
diff --git a/src/components/agenda/PausasChipsEditor.vue b/src/components/agenda/PausasChipsEditor.vue index b6b8f4f..9172b5b 100644 --- a/src/components/agenda/PausasChipsEditor.vue +++ b/src/components/agenda/PausasChipsEditor.vue @@ -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', diff --git a/src/components/landing/FeaturesWidget.vue b/src/components/landing/FeaturesWidget.vue index 6352155..49d3a8e 100644 --- a/src/components/landing/FeaturesWidget.vue +++ b/src/components/landing/FeaturesWidget.vue @@ -122,7 +122,7 @@
Joséphine Miller
Peak Interactive

- “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."

Company logo
diff --git a/src/components/notifications/NotificationDrawer.vue b/src/components/notifications/NotificationDrawer.vue index a7671c5..409fbd9 100644 --- a/src/components/notifications/NotificationDrawer.vue +++ b/src/components/notifications/NotificationDrawer.vue @@ -103,7 +103,7 @@ function goToHistory () {
-

Tudo em dia

+

Tudo em dia por aqui 🎉

Nenhuma notificação{{ filter === 'unread' ? ' não lida' : '' }}.

@@ -113,7 +113,7 @@ function goToHistory () { diff --git a/src/components/notifications/NotificationItem.vue b/src/components/notifications/NotificationItem.vue index 1569584..6801eed 100644 --- a/src/components/notifications/NotificationItem.vue +++ b/src/components/notifications/NotificationItem.vue @@ -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) { \ No newline at end of file diff --git a/src/layout/AppMenuItem.vue b/src/layout/AppMenuItem.vue index 77ac959..6607760 100644 --- a/src/layout/AppMenuItem.vue +++ b/src/layout/AppMenuItem.vue @@ -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 + + + {{ menuBadgeLabel(item) }} + + diff --git a/src/layout/AppRail.vue b/src/layout/AppRail.vue index e77c4ae..191ccf4 100644 --- a/src/layout/AppRail.vue +++ b/src/layout/AppRail.vue @@ -1,19 +1,15 @@ diff --git a/src/layout/AppRailPanel.vue b/src/layout/AppRailPanel.vue index e5e5d97..a229289 100644 --- a/src/layout/AppRailPanel.vue +++ b/src/layout/AppRailPanel.vue @@ -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) { {{ child.label }} PRO + {{ menuBadgeLabel(child) }}
@@ -388,6 +399,7 @@ async function goToResult (r) { {{ item.label }} PRO + {{ menuBadgeLabel(item) }} diff --git a/src/layout/AppRailSidebar.vue b/src/layout/AppRailSidebar.vue index 5d5ad5c..6816e58 100644 --- a/src/layout/AppRailSidebar.vue +++ b/src/layout/AppRailSidebar.vue @@ -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()) {{ child.label }} PRO + {{ menuBadgeLabel(child) }} @@ -405,6 +416,7 @@ watch(() => route.path, () => hideMobileMenu()) {{ item.label }} PRO + {{ menuBadgeLabel(item) }} @@ -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, diff --git a/src/layout/AppSidebar.vue b/src/layout/AppSidebar.vue index faff434..6d6744e 100644 --- a/src/layout/AppSidebar.vue +++ b/src/layout/AppSidebar.vue @@ -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) => { diff --git a/src/layout/AppTopbar.vue b/src/layout/AppTopbar.vue index 5658377..a2b9b6f 100644 --- a/src/layout/AppTopbar.vue +++ b/src/layout/AppTopbar.vue @@ -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 */ diff --git a/src/layout/composables/layout.js b/src/layout/composables/layout.js index c89df11..1c8814b 100644 --- a/src/layout/composables/layout.js +++ b/src/layout/composables/layout.js @@ -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 () { diff --git a/src/navigation/index.js b/src/navigation/index.js index af084c6..419635f 100644 --- a/src/navigation/index.js +++ b/src/navigation/index.js @@ -46,7 +46,7 @@ function resolveMenu (builder, ctx) { } } -// core menu anti-“sumir” +// core menu anti-"sumir" function coreMenu () { return [ { diff --git a/src/navigation/menus/clinic.menu.js b/src/navigation/menus/clinic.menu.js index fa2562b..8c75077 100644 --- a/src/navigation/menus/clinic.menu.js +++ b/src/navigation/menus/clinic.menu.js @@ -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' } ] } diff --git a/src/navigation/menus/therapist.menu.js b/src/navigation/menus/therapist.menu.js index 5209819..e48edd5 100644 --- a/src/navigation/menus/therapist.menu.js +++ b/src/navigation/menus/therapist.menu.js @@ -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' } ] }, diff --git a/src/router/accessRedirects.js b/src/router/accessRedirects.js index 4db1673..732ad7c 100644 --- a/src/router/accessRedirects.js +++ b/src/router/accessRedirects.js @@ -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 diff --git a/src/router/guards.js b/src/router/guards.js index d975e03..082248c 100644 --- a/src/router/guards.js +++ b/src/router/guards.js @@ -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() diff --git a/src/services/agendaSlotsBloqueadosService.js b/src/services/agendaSlotsBloqueadosService.js index 5e20a45..24b03aa 100644 --- a/src/services/agendaSlotsBloqueadosService.js +++ b/src/services/agendaSlotsBloqueadosService.js @@ -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() diff --git a/src/sql-arquivos/patient_lifecycle.sql b/src/sql-arquivos/patient_lifecycle.sql new file mode 100644 index 0000000..90d440f --- /dev/null +++ b/src/sql-arquivos/patient_lifecycle.sql @@ -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; diff --git a/src/theme/theme.options.js b/src/theme/theme.options.js index a07ddd1..e852498 100644 --- a/src/theme/theme.options.js +++ b/src/theme/theme.options.js @@ -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) { diff --git a/src/views/pages/NotFound.vue b/src/views/pages/NotFound.vue index 80cf6ee..39ebc0c 100644 --- a/src/views/pages/NotFound.vue +++ b/src/views/pages/NotFound.vue @@ -55,7 +55,7 @@ function goDashboard () {

- +
@@ -95,7 +95,7 @@ function goDashboard () { />
- +
Se isso estiver acontecendo com frequência, pode ser um problema de rota ou permissão.
diff --git a/src/views/pages/clinic/clinic/ClinicFeaturesPage.vue b/src/views/pages/clinic/clinic/ClinicFeaturesPage.vue index ebdb478..f156e8e 100644 --- a/src/views/pages/clinic/clinic/ClinicFeaturesPage.vue +++ b/src/views/pages/clinic/clinic/ClinicFeaturesPage.vue @@ -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( -
+
-
-
-
-
+