Compare commits

...

347 Commits

Author SHA1 Message Date
5121fcac8a Merge branch 'first-deploy' of https://github.com/killer-cf/sgse-app into first-deploy
Some checks failed
Build and Deploy Docker images / build-and-push-dockerfile-image (pull_request) Has been cancelled
2026-01-13 14:29:57 -03:00
a94ec86349 chore: update bun.lock and package.json to include @types/bun and adjust deploy workflow tags 2026-01-13 14:23:37 -03:00
Kilder Costa
177bb87af7 Merge branch 'master' into first-deploy 2026-01-12 14:53:37 -03:00
ec6ba1d95f fix some errors 2026-01-12 14:52:46 -03:00
Kilder Costa
278a7de6aa Merge pull request #74 from killer-cf/first-deploy
First deploy - prepearing
2026-01-12 14:23:30 -03:00
417394ddbe refactor: improve code readability by formatting multiline function calls and ensuring consistent indentation in security.ts 2026-01-12 13:31:22 -03:00
a4b8dd3f77 fix: improve login flow by ensuring proper type handling for redirect and prevent loopback IP blocking in security checks 2026-01-12 13:31:04 -03:00
4551adf64f update deploy ci 2026-01-12 10:47:18 -03:00
Kilder Costa
8eb6bfee10 Merge pull request #73 from killer-cf/ajustes_final_etapa1
feat: implement security enhancements for Jitsi integration, includin…
2026-01-12 09:36:46 -03:00
Kilder Costa
42b62b7959 Merge pull request #72 from killer-cf/first-deploy
chore: update package manager to bun@1.3.5 and streamline Dockerfile …
2026-01-12 09:35:00 -03:00
664d90c2e0 feat: implement security enhancements for Jitsi integration, including JWT token generation and automatic blocking of detected attacks, improving system resilience and user authentication 2026-01-12 04:34:00 -03:00
fb22f82ce6 chore: update package manager to bun@1.3.5 and streamline Dockerfile by removing unnecessary user creation and ownership settings, enhancing build efficiency 2026-01-09 16:24:38 -03:00
Kilder Costa
8a97d236a6 Merge pull request #71 from killer-cf/ajustes_final_etapa1
Ajustes final etapa1
2025-12-29 14:28:52 -03:00
b965514e53 feat: refine homologation deletion logic to include time-based filtering for adjustments and ensure accurate recalculation of work hours, enhancing data integrity 2025-12-24 11:01:11 -03:00
3ee405a002 feat: add optional date and time fields for period adjustments in point processing, improving data capture for adjustments 2025-12-24 10:53:51 -03:00
c7a64eb116 feat: add period adjustment fields to point processing and PDF generation, enhancing data capture and display for time adjustments 2025-12-24 10:41:12 -03:00
bdc0afccb8 feat: implement logic to remove hour adjustments upon homologation deletion, ensuring accurate recalculation of work hours and maintaining data integrity 2025-12-24 08:36:44 -03:00
b248472d65 feat: enhance dashboard functionality by adding user statistics, improving data filtering for dispensas, and refining timestamp handling to ensure accurate time zone management 2025-12-24 08:26:47 -03:00
e548c2c678 feat: streamline date validation in dispensa functionality by comparing date strings directly, avoiding timezone issues, and enhance date formatting for improved user readability 2025-12-23 23:06:35 -03:00
c6a52155ee feat: restore original values for linked records upon homologation deletion, including recalculation of work hours based on previous time entries, enhancing data integrity and user experience 2025-12-23 22:18:30 -03:00
5369a2ecc9 feat: update PDF generation to use symbols for day types and implement confirmation modal for record deletion in absence and license management, enhancing user experience and data integrity 2025-12-23 19:39:10 -03:00
a731015c89 feat: implement automatic adjustment removal for deleted records in absence and atestado mutations, enhancing data integrity and recalculating work hours for specific periods 2025-12-23 07:44:54 -03:00
414ae85264 feat: improve vacation status update logic to include user information when status is set to 'Cancelado_RH' and refactor work hour calculation to handle multiple entries and exits more effectively 2025-12-22 17:17:23 -03:00
a8a7469812 feat: enhance chat widget and point registration components with improved styling and synchronization handling, including border-radius adjustments and synchronization status messaging 2025-12-22 16:04:03 -03:00
e03b6d7a65 feat: implement dynamic theme support across chat components, enhancing UI consistency with reactive color updates and gradient functionalities 2025-12-22 15:13:07 -03:00
Kilder Costa
f6bf4ec918 Merge pull request #70 from killer-cf/feat-pedidos
Feat pedidos
2025-12-22 14:31:49 -03:00
Kilder Costa
88fac1fc2a Merge pull request #69 from killer-cf/ajustes_final_etapa1
Ajustes final etapa1
2025-12-22 14:29:58 -03:00
743d165af3 feat: update requisition approval process in 'Almoxarifado' to prevent approval with stock issues, enhance error handling, and conditionally render approval button based on stock availability 2025-12-22 14:20:36 -03:00
7ccca5c233 feat: implement requisition approval and rejection functionality in 'Almoxarifado', including stock verification, modal confirmations, and improved error handling for better inventory management 2025-12-22 13:47:05 -03:00
b1db926ab4 feat: enhance 'Almoxarifado' functionality by integrating barcode scanning for material entry and exit, improving user experience with loading indicators and error handling for better inventory management 2025-12-22 10:52:46 -03:00
e19c24b9ab feat: enhance 'Almoxarifado' UI with new icons, improved layout, and updated styling for better user experience and accessibility 2025-12-22 07:21:08 -03:00
ec3b5dc7ea feat: enhance input validation and user experience in 'Almoxarifado' forms by adding oninput handlers for numeric fields, ensuring non-negative values and improving data integrity 2025-12-22 00:18:30 -03:00
ae4f8fc6b3 feat: enhance 'Almoxarifado' UI with improved styling, updated component layouts, and added barcode functionality for better inventory management and user experience 2025-12-22 00:08:13 -03:00
ef9dbedb34 feat: implement material deletion functionality in 'Almoxarifado', including error handling for related stock movements and requests, and enhance user experience with confirmation modals 2025-12-21 21:01:23 -03:00
639f7c6467 feat: implement barcode search configuration in 'Almoxarifado', integrating multiple external APIs for enhanced product information retrieval and improving user experience with new modals for data handling 2025-12-21 20:40:40 -03:00
06ab7369bd fix: improve error handling in 'Almoxarifado' product search to notify users when server function is not found, and update API imports for better functionality 2025-12-21 09:19:14 -03:00
e4ffc1ae2a feat: integrate barcode scanning functionality in 'Almoxarifado' for improved product search and registration, along with image upload support for enhanced inventory management 2025-12-21 09:07:03 -03:00
fdbecff4fa feat: add chart for displaying the last 10 registered products in 'Almoxarifado', enhancing inventory visibility and user engagement 2025-12-21 08:34:55 -03:00
f0884a19a7 feat: implement email notification system for 'Almoxarifado' alerts, enhancing user awareness of stock levels and alert statuses through automated email updates 2025-12-21 08:02:14 -03:00
500b7b362c refactor: enhance 'Almoxarifado' UI with improved layout, updated styling, and efficient data handling for better user experience and performance 2025-12-21 02:32:59 -03:00
fc633c5708 feat: expand 'Almoxarifado' sidebar section with detailed submenus for improved navigation and user permissions 2025-12-20 14:08:35 -03:00
8f0452bd87 feat: add new 'Almoxarifado' section in sidebar and update navigation links for improved user experience and consistency across the application 2025-12-20 13:52:30 -03:00
8dd2674305 refactor: optimize database queries in almoxarifado and configuracaoAlmoxarifado files by replacing filter methods with indexed queries for improved performance and clarity 2025-12-20 13:45:21 -03:00
d4c7488cab refactor: update imports in almoxarifado and configuracaoAlmoxarifado files to streamline API usage and enhance code organization 2025-12-20 13:18:04 -03:00
0c7412c764 feat: implement filtering and PDF/Excel report generation for planejamentos. 2025-12-18 17:31:55 -03:00
0a4be24655 feat: Add planning cloning functionality to list and detail pages with backend support. 2025-12-18 16:57:52 -03:00
367cda7b95 feat: implement almoxarifado features including new category in recursos-humanos, configuration options in TI, and backend support for inventory management, enhancing user navigation and system functionality 2025-12-18 16:21:08 -03:00
011a867aac refactor: update fluxo instance management by enhancing the creation modal and improving the sidebar navigation structure, ensuring better user experience and code maintainability 2025-12-18 15:52:57 -03:00
1eb454815f Merge remote-tracking branch 'origin/master' into ajustes_final_etapa1 2025-12-18 14:58:03 -03:00
d10eddca39 refactor: update terminology from "PC Local" to "Servidor interno" across components and documentation for consistency in time synchronization references 2025-12-18 14:56:00 -03:00
Kilder Costa
ef68f524ed Merge pull request #68 from killer-cf/feat-pedidos
Feat pedidos
2025-12-18 14:54:53 -03:00
d3a4e5db8f refactor: update sidebar submenu link for 'Meus Processos' to improve navigation structure and maintain consistency in URL paths 2025-12-18 14:52:38 -03:00
f008610b26 refactor: enhance pedidos UI by integrating reusable components for layout and styling, improving code maintainability and user experience across various pages 2025-12-18 12:02:57 -03:00
230be4db61 refactor: simplify table header styling in compras pages by removing unnecessary classes, enhancing code clarity and maintainability 2025-12-18 10:56:03 -03:00
94373c6b94 refactor: simplify pedidos item management by removing modalidade from item configuration and validation, ensuring all items use the same ata while enhancing code clarity and maintainability 2025-12-18 08:48:40 -03:00
69914170bf feat: enhance pedidos functionality by adding new submenu options for creating and planning orders, improving user navigation and access control in the sidebar; also implement URL-based prefill for adding items, ensuring a smoother user experience when creating pedidos 2025-12-17 21:42:35 -03:00
551a2fed00 refactor: streamline authentication logic in dashboard and login routes by removing unnecessary error handling and improving user session validation, enhancing code clarity and maintainability 2025-12-17 11:28:08 -03:00
9072619e26 feat: enhance ata management by adding dataProrrogacao field and updating related logic for effective date handling, improving data integrity and user experience in pedidos 2025-12-17 10:39:33 -03:00
fbf00c824e feat: add DFD number management to pedidos, including editing functionality and validation for sending to acceptance, enhancing data integrity and user feedback 2025-12-16 14:58:35 -03:00
f90b27648f feat: enhance ata and objeto management by adding configuration options for quantity limits and usage tracking, improving data integrity and user feedback in pedidos 2025-12-16 14:20:31 -03:00
0cbae42df5 feat: add mutation to exclude absence requests and enhance point registration by blocking entries during approved absences 2025-12-16 08:55:06 -03:00
fd2669aa4f feat: implement advanced filtering and reporting features for pedidos, including status selection, date range filtering, and export options for PDF and XLSX formats 2025-12-15 15:37:57 -03:00
c7b4ea15bd feat: add filtering functionality for empresas in dashboard, allowing users to search by name or CNPJ, and enhance UI with clear feedback for no results 2025-12-15 14:34:09 -03:00
a5ad843b3e feat: implement filtering and document management features in dashboard components, enhancing user experience with improved query capabilities and UI for managing documents in pedidos and compras 2025-12-15 14:29:30 -03:00
60b53dac74 refactor: update fichaPontoPDF and processamento to enhance legend styling and accumulate saldo for all days, improving report accuracy 2025-12-15 11:50:51 -03:00
f3288b9639 feat: enhance notification bell component by refactoring notification fetching logic, improving type safety, and updating UI elements for better user experience 2025-12-15 11:33:51 -03:00
Kilder Costa
6056ee4635 Merge pull request #67 from killer-cf/feat-style
Feat style
2025-12-15 09:27:12 -03:00
c272ca05e8 feat: implement theme persistence and selection in header component, enhancing user experience with localStorage integration 2025-12-15 09:01:56 -03:00
4faf279c3e feat: refine login page functionality by adding validation, improving error handling, and enhancing user feedback mechanisms 2025-12-13 20:05:27 -03:00
Kilder Costa
a951f61676 Merge pull request #65 from killer-cf/refactor-auth
Refactor auth
2025-12-13 19:13:28 -03:00
d2c0636179 feat: enhance login page design and functionality by integrating new components, updating styles, and improving user experience 2025-12-13 19:12:08 -03:00
0404edd0ba feat: add @ark-ui/svelte dependency and configure ark-ui command in mcp.json for improved UI component management 2025-12-13 17:37:44 -03:00
91d41f6d98 feat: update header branding to reflect new system name and improve visual hierarchy 2025-12-13 17:13:09 -03:00
9e45a43910 feat: add theme selection functionality and update theme management in the application, including new themes and improved persistence handling 2025-12-13 17:09:15 -03:00
c068715fc1 refactor: remove ActionGuard and MenuProtection components, simplifying permission checks in various dashboard routes and enhancing footer with privacy policy link 2025-12-13 10:50:57 -03:00
13ec7cc8e3 feat: improve sidebar behavior for responsive layout, ensuring it opens by default on desktop and closes on mobile, with event listener for screen size changes 2025-12-12 19:37:43 -03:00
4f238022cf feat: enhance layout and component structure for dashboard, including responsive sidebar and header actions, and update footer styling 2025-12-12 16:05:28 -03:00
b771322b24 feat: Implement dedicated login page and public/dashboard layouts, refactoring authentication flow and removing the todos page. 2025-12-12 14:22:28 -03:00
Kilder Costa
98d12d40ef Merge pull request #64 from killer-cf/ajustes_gerais
Ajustes gerais
2025-12-12 11:15:21 -03:00
457e89e386 feat: enhance time synchronization logic with timeout and loading state management 2025-12-12 11:13:56 -03:00
Kilder Costa
10454b38ea Merge pull request #63 from killer-cf/feat-pedidos
Feat pedidos
2025-12-12 10:22:53 -03:00
b47a317c33 chore: update turbo dependency to version 2.6.3 in package.json and bun.lock 2025-12-12 10:13:13 -03:00
ba39167b2b feat: update sidebar menu structure to remove resolve function for links and enhance permission checks for ausências and pontos resources 2025-12-12 09:50:12 -03:00
92a9605417 feat: implement permission checks for various actions across multiple resources, including acoes, atas, atestados, ausencias, ferias, and simbolos 2025-12-12 09:26:30 -03:00
4eb49d3e63 feat: update sidebar links to use resolve function and enhance permissions structure for recursos humanos, including new actions for atestados and ausências 2025-12-11 17:01:47 -03:00
6936a59c21 feat: implement cascading recalculation of monthly hour banks when past months are updated or adjusted 2025-12-11 16:52:07 -03:00
Kilder Costa
813d614648 Merge pull request #62 from killer-cf/ajustes_gerais
chore: add empty lines to improve code readability in fichaPontoPDF a…
2025-12-11 11:54:27 -03:00
196ef90643 chore: add empty lines to improve code readability in fichaPontoPDF and error handling components 2025-12-11 11:53:20 -03:00
Kilder Costa
1a56f2ab64 Merge pull request #61 from killer-cf/feat-pedidos
feat: add optional 'aceitoPor' field to pedidos query for enhanced it…
2025-12-11 11:51:07 -03:00
84dbe50fce feat: enforce order status checks for item addition in pedidos to prevent modifications in restricted states 2025-12-11 11:50:04 -03:00
3aa1e49ddb feat: add optional 'aceitoPor' field to pedidos query for enhanced item creator tracking 2025-12-11 11:30:55 -03:00
Kilder Costa
52e6805c09 Merge pull request #60 from killer-cf/feat-pedidos
Feat pedidos
2025-12-11 10:36:38 -03:00
bd0ac0a3b4 Merge remote-tracking branch 'origin' into feat-pedidos 2025-12-11 10:08:12 -03:00
864226256a format 2025-12-10 15:09:02 -03:00
Kilder Costa
6b4cdb7497 Merge pull request #59 from killer-cf/ajustes_gerais
chore: add empty lines to improve code readability in fichaPontoPDF a…
2025-12-10 15:02:45 -03:00
21e876261b chore: update VSCode settings to set editor tab size to 2 for consistent code formatting 2025-12-10 14:56:22 -03:00
f6f87fa2e7 chore: add empty lines to improve code readability in fichaPontoPDF and error handling components 2025-12-10 14:47:28 -03:00
Kilder Costa
1fd6e550e3 Merge pull request #58 from killer-cf/ajustes_gerais
Ajustes gerais
2025-12-10 14:46:06 -03:00
56dffbaad7 feat: enhance alert diagnostics by adding template listing for improved user feedback; implement fallback template search in backend for better error handling and logging 2025-12-10 06:54:28 -03:00
9f523d99a5 refactor: update modal z-index for improved visibility and enhance alert deletion confirmation with additional messaging and logging; ensure fallback for user data in diagnostics card 2025-12-10 06:44:29 -03:00
d27c0b6f91 feat: enhance vacation approval process by adding notification system for employees, including email alerts and in-app notifications; improve error handling and user feedback during vacation management 2025-12-10 06:27:25 -03:00
f1b2cf815a Merge remote-tracking branch 'origin' into feat-pedidos 2025-12-09 15:14:42 -03:00
Kilder Costa
eb47af1fd8 Merge pull request #57 from killer-cf/ajustes_gerais
Ajustes gerais
2025-12-09 15:13:50 -03:00
73da995109 feat: add functionality to manage employee status during point registration, preventing point logging for employees on vacation or leave; enhance UI alerts to inform users of their current status 2025-12-09 15:06:36 -03:00
7b3d429e23 feat: Add new action types for order adjustments and enhance user notifications in pedidos management. 2025-12-09 14:55:01 -03:00
be3fb4ea64 feat: Enforce consistency in order items' modalidade and ata across mutations and frontend validation in pedidos management. 2025-12-09 14:49:29 -03:00
248d7cd623 refactor: clean up code formatting and improve readability in various files, including utility functions and error handling components 2025-12-09 14:44:08 -03:00
881f2fbb8b feat: Add confirmation modal for item actions and enhance user feedback with toast notifications in pedidos management. 2025-12-09 12:25:05 -03:00
090298659e feat: Implement item request/approval workflow for pedidos in analysis mode, adding conditional item modifications and new request management APIs. 2025-12-09 11:03:49 -03:00
2172d9a937 feat: add password reset functionality for users, including modal interface for generating temporary passwords and copying to clipboard; enhance backend mutation for secure password management and email notifications 2025-12-09 07:41:19 -03:00
4110b12724 refactor: remove ConnectionIndicator component from ChatWidget to streamline the chat interface and improve code clarity 2025-12-09 01:57:20 -03:00
7637cd52f1 refactor: optimize login attempt logging in Sidebar component to avoid timeouts by deferring user retrieval; enhance MessageList component with improved message processing and state management to prevent unnecessary re-renders 2025-12-09 01:49:18 -03:00
e6f380d7cc feat: implement end-to-end encryption for chat messages and files, including key management and decryption functionality; enhance chat components to support encrypted content display 2025-12-09 01:31:09 -03:00
cae6d886de refactor: remove unused scroll handling function from MessageList component to improve code clarity and maintainability 2025-12-08 23:19:53 -03:00
1810cbabe2 feat: enhance chat components with improved accessibility features, including ARIA attributes for search and user status, and implement message length validation and file type checks in message input handling 2025-12-08 23:16:05 -03:00
09af2c796b feat: Allow adding/removing items for orders in 'em_analise' status and restrict order cancellation to the creator. 2025-12-08 19:44:32 -03:00
e92b10668e feat: Implement pedido adjustment workflow with description field and dedicated mutations. 2025-12-08 18:58:40 -03:00
e46738c5bf fix: prevent premature modal closure in PrintPontoModal by deferring onClose call until PDF generation is successful; move abrirModalImpressao function for better organization 2025-12-08 12:45:05 -03:00
fdfbd8b051 feat: implement error handling and logging in server hooks to capture and notify on 404 and 500 errors, enhancing server reliability and monitoring 2025-12-08 11:52:27 -03:00
e1f1af7530 feat: integrate UserAvatar component in absence management to display user profile pictures alongside names for improved user experience 2025-12-07 16:28:11 -03:00
12984997ce refactor: remove documentation components and related routes to streamline the application structure and improve maintainability 2025-12-07 16:17:20 -03:00
10a729baed feat: enhance DocumentacaoCard with visualizer button and improve PDF generation with structured content and metadata handling 2025-12-07 11:49:20 -03:00
426e358d86 fix: correct event handling in DocumentacaoCard component and update internal reference casting in documentacaoVarredura for improved type safety 2025-12-06 21:41:56 -03:00
0ec12721ba feat: add marked library for markdown parsing and enhance documentation handling with new cron job for scheduled checks 2025-12-06 20:43:41 -03:00
f3b4721119 feat: add templateCodigo field to alert configurations and enhance alert handling with new email/chat templates for cybersecurity incidents 2025-12-06 19:34:00 -03:00
1ceef73847 fix: add optional chaining and default values to prevent errors in dashboard page data rendering 2025-12-06 09:55:46 -03:00
14127a7977 feat: update user role display in perfil page to show associated sectors for improved clarity 2025-12-06 09:50:48 -03:00
398bf102e9 feat: add ChevronDown icon and update Phone and Video icons with text color for improved visibility in ChatWindow component 2025-12-06 09:45:12 -03:00
e8137c116c feat: remove unused SVG elements and streamline icon usage in relatorios page for improved code clarity and maintainability 2025-12-06 09:35:39 -03:00
aec3201410 feat: enhance Banco de Horas management with new reporting features, including adjustments and inconsistencies tracking, advanced filters, and Excel export functionality 2025-12-06 09:32:55 -03:00
72450d1f28 feat: enhance absence management with new filters and reporting options, including PDF and Excel generation capabilities 2025-12-06 01:11:33 -03:00
ff91d8a3ab feat: Implement granular permission-based status transitions for pedidos. 2025-12-05 19:34:22 -03:00
80e9b76649 feat: Enhance sidebar active state logic with path exclusion and add new permissions for pedidos, atas, objetos, and empresas. 2025-12-05 16:36:56 -03:00
6a99ab74f1 feat: Add user management UI including filters, actions, and modals for roles, employee association, and blocking. 2025-12-05 15:42:50 -03:00
69f32a342c refactor: Remove dedicated role management page and update authentication, roles, and permission handling across backend and frontend. 2025-12-05 14:29:34 -03:00
1000b5a030 feat: add approval/rejection information and change history display in AprovarAusencias and AprovarFerias components for enhanced user feedback 2025-12-05 12:57:35 -03:00
66f995cb08 feat: implement date parsing utility across absence management components for improved date handling and consistency 2025-12-05 11:57:15 -03:00
c8d717b315 feat: Implement sector role configuration on the setores page and remove the deprecated TI config page. 2025-12-05 10:21:36 -03:00
4a1f48300f feat: replace SVG icons with Lucide components in AprovarAusencias and AprovarFerias for improved consistency and maintainability 2025-12-05 05:26:01 -03:00
8e09e8cada feat: replace SVG icons with Lucide components in chat and profile pages for improved consistency and maintainability 2025-12-05 05:03:45 -03:00
6e659514e3 feat: replace SVG icons with Lucide components in user management and dashboard pages for improved consistency and maintainability 2025-12-05 04:44:43 -03:00
29577b8e63 feat: Implement order acceptance and analysis workflows with new pages, sidebar navigation, and backend queries for filtering and permissions. 2025-12-04 17:10:06 -03:00
7621fbea36 feat: replace SVG icons with Lucide components in the Central de Chamados page for improved consistency and maintainability 2025-12-04 17:06:59 -03:00
68475f549a feat: Implement dynamic sidebar menu in the frontend, filtered by new backend user permissions. 2025-12-04 16:05:26 -03:00
300dfe7fc9 feat: replace SVG icons with Lucide components in email configuration and dashboard pages for enhanced consistency and maintainability 2025-12-04 15:58:38 -03:00
eb7f3507d3 feat: replace SVG icons with Lucide components in various dashboard pages for improved consistency and maintainability 2025-12-04 15:34:24 -03:00
88f25dc6ab feat: replace SVG icons with Lucide components across various Svelte components for improved consistency and maintainability 2025-12-04 14:30:31 -03:00
2cdf66375c feat: Remove PDF field and upload functionality from ata creation and update. 2025-12-04 09:00:57 -03:00
a3d9e782af feat: enhance LGPD request handling with email notifications and response templates; update frontend filters for improved user experience 2025-12-04 05:13:43 -03:00
7746dce25a feat: Implement batch item removal and pedido splitting for pedidos, and add document management for atas. 2025-12-03 23:37:26 -03:00
4a662c08a0 feat: implement cancellation and deletion functionality for LGPD requests; enhance UI with confirmation modals and update backend to support new operations 2025-12-03 16:57:10 -03:00
b145fcc74a refactor: standardize import statements and improve formatting in backend Convex files; update package.json script for consistency 2025-12-03 16:19:39 -03:00
fb78866a0e feat: Add prefill functionality for pedidos and enhance item matching logic with modalidade support 2025-12-03 11:31:33 -03:00
d86d7d8dbb feat: Enhance pedidos management with detailed item linking, object search, and improved UI for item configuration and details 2025-12-03 10:22:22 -03:00
4d29501849 feat: Implement Ata de Registro de Preços management and linking to objetos and pedidos 2025-12-02 23:29:42 -03:00
8a50fb6f61 fix: Correct incomplete $state initialization in multiple Svelte components and pages. 2025-12-02 19:18:53 -03:00
4bd9e21748 feat: Add 'atas' (minutes/records) management feature, and implement various improvements across UI, backend logic, and authentication. 2025-12-02 16:37:48 -03:00
d79e6959c3 feat: update ESLint and TypeScript configurations across frontend and backend; enhance component structure and improve data handling in various modules 2025-12-02 16:36:02 -03:00
f48d28067c feat: add refresh functionality to absence and vacation requests queries; update backend to support refresh parameter for improved data handling 2025-12-02 16:28:35 -03:00
c5dfddad46 feat: integrate UserAvatar component into absence and vacation request tables for enhanced employee profile visibility 2025-12-02 15:43:26 -03:00
75ab4d261d feat: add UserAvatar component to display employee profile pictures in absence and vacation requests; update backend to include profile picture URLs for employees 2025-12-02 14:54:45 -03:00
ffa4dc5fb2 refactor: improve data handling and UI feedback in LGPD-related components; enhance error handling and consent term display 2025-12-02 14:03:52 -03:00
e81054874f Merge remote-tracking branch 'origin/master' into ajustes_gerais 2025-12-02 14:03:30 -03:00
11a3c5c0e2 chore: update ESLint configuration and enhance TypeScript formatting settings in VSCode; refactor schema definitions for consistency and readability in backend Convex schema 2025-12-02 10:30:26 -03:00
b87f34fe4c Merge remote-tracking branch 'origin/master' into ajustes_gerais 2025-12-02 10:09:09 -03:00
8b5078de92 feat: add delay before redirection after user consent registration to improve user experience on the consent term page 2025-12-02 10:08:08 -03:00
Kilder Costa
93e4e1cc87 Merge pull request #56 from killer-cf/feat-pedidos
feat: Introduce structured table definitions in `convex/tables` for v…
2025-12-02 09:55:38 -03:00
0c507f41da refactor: replace useMutation with useConvexClient for API calls in LGPD-related pages to streamline data handling and improve consistency across components 2025-12-02 09:55:28 -03:00
05e7f1181d feat: Introduce structured table definitions in convex/tables for various entities and remove the todos example table. 2025-12-02 09:55:07 -03:00
e460b114ed feat: implement user consent verification and redirection for LGPD compliance in dashboard layout and consent term page 2025-12-02 06:17:23 -03:00
2825bd0e6e feat: enhance LGPD compliance features by adding configurable data protection officer details, consent term settings, and improved error handling across various components 2025-12-02 05:54:37 -03:00
Kilder Costa
a02d8f03eb Merge pull request #55 from killer-cf/feat-pedidos
Feat pedidos
2025-12-02 00:58:40 -03:00
1c0bd219b2 Merge remote-tracking branch 'origin' into feat-pedidos 2025-12-02 00:58:10 -03:00
fec5f5c33d feat: implement LGPD compliance features including data request management, consent tracking, and statistics display in the dashboard for enhanced data protection compliance 2025-12-01 22:37:43 -03:00
95c3b48ae6 feat: add UserAvatar component to display employee profile pictures in various HR pages, enhancing visual representation of employee data 2025-12-01 22:13:01 -03:00
c19c8c859e feat: add setores display and loading state to perfil page, and implement click outside functionality for dropdown menus in funcionarios page 2025-12-01 22:04:32 -03:00
b652822c30 Merge pull request #54 from killer-cf/ajustes_gerais
feat: implement template retrieval by ID and enhance error handling i…
2025-12-01 19:55:14 -03:00
6e836e9eb5 feat: implement template retrieval by ID and enhance error handling in template display for improved user experience 2025-12-01 19:54:33 -03:00
b8a67e0a57 feat: Implement initial pedido (order) management, product catalog, and TI configuration features. 2025-12-01 17:11:34 -03:00
db2105872f Merge pull request #52 from killer-cf/ajustes_gerais
Ajustes gerais - Etapa 1
2025-12-01 15:00:39 -03:00
8fabb4149c Ajustes Gerais 2025-12-01 14:51:15 -03:00
a149c5ead6 feat: enhance error handling in dashboard layout and improve UI consistency across notification templates with updated styling and structure 2025-12-01 11:45:27 -03:00
4af566e54c feat: add user and template counters to notifications page header for improved visibility and user engagement 2025-12-01 09:54:34 -03:00
4c2d12f443 feat: implement template filtering for notifications based on channel type and enhance email rendering with HTML wrapper, ensuring chat messages are sent as plain text 2025-12-01 09:50:53 -03:00
d9e78079c8 feat: update email notification handling to use scheduler for template sending, with improved error handling for fallback scenarios 2025-12-01 05:45:19 -03:00
4e3feca84d refactor: rename variable in notification template rendering for improved clarity and consistency 2025-11-30 16:47:48 -03:00
4ab151bed7 feat: add tab navigation and content management for notifications page, allowing users to switch between Enviar Notificação, Gerenciar Templates, and Agendamentos for improved organization and usability 2025-11-30 16:33:52 -03:00
2fb7df8849 feat: implement reactive event query for calendar in Atestados Licenças page, enhancing filtering capabilities based on user input for improved data presentation 2025-11-30 16:00:31 -03:00
268510bbf2 feat: update Cibersecurity SGSE title and description for clarity, and enhance Central de Chamados page by implementing filter application logic and reactivity for improved user experience 2025-11-30 15:55:48 -03:00
08f3394de3 feat: add tab navigation to Central de Chamados page, allowing users to switch between Dashboard, Painel de Chamados, and Configurações SLA for improved organization and accessibility 2025-11-30 15:48:17 -03:00
78ab6161cf feat: enhance Central de Chamados page by adding breadcrumb navigation and a structured header, improving user experience and accessibility 2025-11-30 15:44:51 -03:00
e43f9fcf14 feat: enhance ComprovantePonto component by adding logo support and restructuring document layout with auto-generated tables for employee and registration data, improving PDF output clarity and presentation 2025-11-30 15:40:58 -03:00
3204440a38 feat: improve login process by integrating GPS location tracking and optimizing IP address handling, enhancing user data accuracy and experience 2025-11-30 15:32:21 -03:00
f1c2ae0e6b feat: enhance audit page by adding user information retrieval and improving CSV export format, providing better insights and clarity in reports 2025-11-30 08:42:21 -03:00
334676b860 feat: enhance login functionality by adding IP geolocation tracking and advanced filtering options in the audit page, improving user insights and data accuracy 2025-11-30 08:12:46 -03:00
e35846103e refactor: remove unused card hover styles from app.css and update card class in dashboard to simplify styling 2025-11-30 00:43:17 -03:00
b34166691e refactor: simplify ChatWidget component by removing pulse animation and updating chat icon for improved visual clarity 2025-11-30 00:37:54 -03:00
39c948aa6b refactor: reorganize user profile display in Sidebar component, moving notification bell and user details for improved layout and accessibility 2025-11-30 00:35:20 -03:00
b85021d924 feat: implement area charts for total days by type and monthly trends in the employee leave dashboard, enhancing data visualization and user insights 2025-11-30 00:30:38 -03:00
298326e264 fix: enhance data handling in vacation dashboard by adding array checks and improving chart data structure for better stability and performance 2025-11-29 23:25:14 -03:00
545e119367 feat: add area chart for upcoming employee leave data, visualizing monthly vacation counts and enhancing dashboard insights 2025-11-29 22:27:23 -03:00
1d9f924cb8 feat: add employee profile picture retrieval to leave report, updating gestor information and table headers for improved clarity 2025-11-29 20:30:35 -03:00
f059a0c688 feat: enhance employee leave report generation by adding gestor information retrieval and improving filtering capabilities across components 2025-11-29 20:21:40 -03:00
e9e7c654ee feat: integrate ExcelJS for enhanced report generation, replacing XLSX in the employee leave report functionality, and update styling for improved user experience 2025-11-29 18:49:59 -03:00
cdb28bf742 refactor: streamline employee search components by directly using the value prop for filtering and updating dropdown visibility, enhancing synchronization and user experience 2025-11-29 18:10:53 -03:00
7defdaa59d feat: add Excel and PDF report generation functionality for employee leave and certificate data, integrating XLSX and jsPDF libraries for enhanced reporting capabilities 2025-11-29 16:56:30 -03:00
bc62cd51c0 refactor: update modal positioning logic across components to ensure consistent placement relative to the card, enhancing user experience 2025-11-29 16:40:55 -03:00
9dcd26ee82 refactor: remove SSH/Docker configuration options from Jitsi settings and streamline related backend queries and mutations 2025-11-29 16:31:18 -03:00
02b8d72f59 feat: enhance point registration with improved timestamp synchronization and direct photo processing, and add Convex Svelte best practices documentation. 2025-11-29 11:58:55 -03:00
501751c22f feat: improve point registration processing feedback with step-by-step messages and update modal positioning across components. 2025-11-28 16:50:45 -03:00
Kilder Costa
330d376930 Merge pull request #51 from killer-cf/config-self-convex
chore: Remove Jitsi and theme documentation files and refine backend …
2025-11-28 09:20:57 -03:00
5e7de6c943 chore: Remove Jitsi and theme documentation files and refine backend gitignore rules. 2025-11-28 09:20:23 -03:00
Kilder Costa
b9be21e302 Merge pull request #50 from killer-cf/config-self-convex
Config self convex
2025-11-27 15:34:01 -03:00
af21a35f05 feat: Add @convex-dev/better-auth dependency and refactor Dockerfile to support monorepo workspace builds, updating Turbo build output path. 2025-11-27 12:01:36 -03:00
277dc616b3 refactor: remove Jitsi Meet related configurations and server action definitions, and eliminate redundant Dockerfile copy. 2025-11-27 09:05:20 -03:00
Kilder Costa
ecc60f4bee Merge pull request #49 from killer-cf/config-self-convex
fix: update Dockerfile path in deploy workflow
2025-11-26 15:46:00 -03:00
0c0c7a29c0 fix: update Dockerfile path in deploy workflow
- Changed the Dockerfile path in the deploy workflow from './Dockerfile' to './apps/web/Dockerfile' to reflect the new directory structure.
2025-11-26 15:45:29 -03:00
Kilder Costa
7fd78f12ae Merge pull request #48 from killer-cf/config-self-convex
Config self convex
2025-11-26 15:42:47 -03:00
be959eb230 feat: update Dockerfile and workflow for environment variable support
- Modified the Dockerfile to include ARG and ENV for PUBLIC_CONVEX_URL and PUBLIC_CONVEX_SITE_URL, enhancing configuration flexibility.
- Updated the deploy workflow to pass these environment variables during the build process.
- Adjusted package.json to use bun for script commands and added svelte-adapter-bun for improved Svelte integration.
2025-11-26 15:42:22 -03:00
86ae2a1084 modify docker file 2025-11-26 11:40:33 -03:00
e1bd6fa61a config docker pre mod 2025-11-26 11:08:36 -03:00
Kilder Costa
edd8d1edca Merge pull request #47 from killer-cf/config-self-convex
refactor: update Dockerfile for improved workspace structure and buil…
2025-11-26 10:48:52 -03:00
75989b0546 refactor: update Dockerfile for improved workspace structure and build process
- Adjusted the Dockerfile to copy package.json files from workspace packages, ensuring proper dependency resolution.
- Modified the build context in the deploy workflow to streamline the Docker image build process.
- Enhanced the build steps to navigate to the web app directory before building, ensuring correct application setup.
2025-11-26 10:48:01 -03:00
Kilder Costa
085502d71e Merge pull request #46 from killer-cf/config-self-convex
feat: add Bun setup step to deploy workflow
2025-11-26 10:43:24 -03:00
08869fe5da feat: add Bun setup step to deploy workflow
- Introduced a new step to set up Bun in the GitHub Actions deploy workflow, enhancing the build process for JavaScript applications.
2025-11-26 10:42:41 -03:00
Kilder Costa
3e1026343e Merge pull request #45 from killer-cf/config-self-convex
fix: update Docker image context and tags in deploy workflow
2025-11-26 10:29:47 -03:00
71959f6553 fix: update branch name in deploy workflow configuration
- Changed the branch name from 'main' to 'master' in the GitHub Actions deploy workflow to align with repository conventions.
2025-11-26 10:28:11 -03:00
de694ed665 fix: update Docker image context and tags in deploy workflow
- Changed the Docker build context to './apps/web' for better organization.
- Updated the image tag from 'namespace/example:latest' to 'killercf/sgc:latest' to reflect the correct repository.
2025-11-26 10:25:30 -03:00
Kilder Costa
5aad901254 Merge pull request #44 from killer-cf/config-self-convex
Config self convex
2025-11-26 10:22:38 -03:00
daee99191c feat: extend getInstanceWithSteps query to include notes metadata
- Added new fields for tracking who updated notes, their names, and the timestamp of the update.
- Refactored the retrieval of the updater's name to improve code clarity and efficiency.
- Enhanced the data structure returned by the query to support additional notes-related information.
2025-11-26 10:21:13 -03:00
6128c20da0 feat: implement sub-steps management in workflow editor
- Added functionality for creating, updating, and deleting sub-steps within the workflow editor.
- Introduced a modal for adding new sub-steps, including fields for name and description.
- Enhanced the UI to display sub-steps with status indicators and options for updating their status.
- Updated navigation links to reflect changes in the workflow structure, ensuring consistency across the application.
- Refactored related components to accommodate the new sub-steps feature, improving overall workflow management.
2025-11-25 14:14:43 -03:00
f8d9c17f63 feat: add Svelte DnD action and enhance flow management features
- Added "svelte-dnd-action" dependency to facilitate drag-and-drop functionality.
- Introduced new "Fluxos de Trabalho" section in the dashboard for managing workflow templates and instances.
- Updated permission handling for sectors and flow templates in the backend.
- Enhanced schema definitions to support flow templates, instances, and associated documents.
- Improved UI components to include new workflow management features across various dashboard pages.
2025-11-25 00:21:35 -03:00
409872352c docs: Create guidelines for integrating Convex with Svelte/SvelteKit. 2025-11-24 14:48:04 -03:00
Kilder Costa
d8361769e4 Merge pull request #43 from killer-cf/refinament-1
Refinament 1
2025-11-24 12:28:10 -03:00
d4a3214451 Merge remote-tracking branch 'origin' into refinament-1 2025-11-24 09:01:00 -03:00
649b9b145c Merge pull request #42 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-23 16:29:55 -03:00
1089a4fdab refactor: update modal positioning logic in ErrorModal, ComprovantePonto, and RegistroPonto components
- Refactored modal positioning to align with the synchronized clock element, enhancing user experience by ensuring modals are positioned below the clock with appropriate spacing.
- Replaced timeout-based DOM updates with requestAnimationFrame for improved rendering performance and responsiveness.
- Added logic to clear modal position when modals are closed, ensuring a clean state for future interactions.
2025-11-23 16:28:48 -03:00
a3eab60fcd feat: enhance modal positioning and styling across components
- Implemented dynamic positioning for modals in ErrorModal, ComprovantePonto, and RegistroPonto components to ensure they are centered based on the viewport and container dimensions.
- Updated modal styles to improve visual consistency, including backdrop opacity adjustments and enhanced animations.
- Refactored modal handling logic to include scroll and resize event listeners for better responsiveness during user interactions.
2025-11-23 16:24:58 -03:00
ae4fc1c4d5 Merge pull request #41 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-23 15:11:29 -03:00
51096e7aff refactor: improve filtering logic for employee time records in registro-pontos page
- Enhanced the filtering mechanism to apply location and status filters more effectively, ensuring only relevant records are displayed.
- Added checks to exclude records without location information when filters are active, improving data accuracy.
- Implemented a final filtering step to ensure only groups with valid records are shown, enhancing user experience in reviewing time records.
2025-11-23 15:10:23 -03:00
00e18e79ec feat: add date range filters for employee time records in homologacao page
- Introduced date range filters for selecting start and end dates, defaulting to the last 30 days for improved user experience.
- Enhanced the UI layout to include form controls for selecting employees and date ranges, ensuring better accessibility and usability.
- Updated data handling logic to utilize the new date filters for querying employee time records.
2025-11-23 14:58:04 -03:00
1ad0ee91cb refactor: enhance saldo calculation and display in registro-pontos page
- Updated the logic for calculating daily and period saldo differences to ensure accuracy by directly computing the difference between worked and expected hours.
- Improved the display of saldo differences in the UI, including formatting adjustments for better readability.
- Refactored the rendering logic to ensure consistent styling and user experience across the registro-pontos page.
2025-11-23 14:29:49 -03:00
35e7c10ed0 refactor: update ErrorModal and RegistroPonto components for improved UI and functionality
- Refactored ErrorModal to use a div-based layout with enhanced animations and accessibility features.
- Updated RegistroPonto to include a new loading state and improved modal handling for webcam capture.
- Enhanced styling for better visual consistency and user experience across modals and registration cards.
- Introduced comparative balance calculations in RegistroPonto for better visibility of time discrepancies.
2025-11-23 13:13:24 -03:00
db2daacdad refactor: improve table rendering and layout in registro-pontos page
- Enhanced the rendering of tables for employee time records, consolidating data into structured formats for better readability.
- Updated the logic for displaying various sections, including employee information, GPS validation, and geofencing, to utilize tables for clarity.
- Improved styling and layout consistency across the page, ensuring a more user-friendly experience when reviewing time records and validation statuses.
2025-11-23 09:06:15 -03:00
e0b01cff0a feat: implement comparative balance calculation for entry/exit pairs in registro-pontos page
- Added a new function `calcularSaldoComparativoPorPar` to compute comparative balances for entry and exit pairs, enhancing the accuracy of time management.
- Updated the logic to handle expected and actual records, allowing for better visibility of discrepancies in worked hours.
- Enhanced the table rendering to display comparative balances, improving user experience and clarity in time tracking.
2025-11-23 08:29:38 -03:00
dfc975cb8f feat: enhance registro-pontos page with date range handling and expected record generation
- Introduced functions to generate all dates within a selected period and to create expected records based on user configuration.
- Updated the logic to process daily records, combining real and expected entries, and calculating daily balances.
- Enhanced table rendering to visually distinguish between marked and unmarked records, improving user experience and clarity in time management.
2025-11-23 06:06:22 -03:00
ac8e8f56b8 feat: implement saldo calculation for entry/exit pairs in registro-pontos page
- Added a new function `calcularSaldosPorPar` to compute balances for entry and exit pairs, returning a map of balances indexed by record.
- Updated the table data generation to display daily balances per pair, enhancing visibility of time management.
- Implemented logic to handle incomplete pairs and display appropriate messages in the UI, improving user experience.
2025-11-23 05:52:01 -03:00
095f041891 feat: update CSS theming and enhance variable application for improved UI consistency
- Refactored CSS styles to utilize HSL color values for better theme adaptability.
- Added custom light and dark themes with comprehensive variable definitions to ensure consistent styling across the application.
- Implemented a mechanism to force CSS variable updates, ensuring that custom themes are applied correctly during runtime.
2025-11-23 04:58:38 -03:00
467e04b605 feat: enhance RegistroPonto and WebcamCapture components for improved data handling and user experience
- Added a refresh mechanism in the RegistroPonto component to ensure queries are updated after point registration, improving data accuracy.
- Expanded the WebcamCapture component to prevent multiple simultaneous play calls, enhancing video playback reliability.
- Updated the registro-pontos page to default the date range to the last 30 days for better visibility and user convenience.
- Introduced debug logging for queries and data handling to assist in development and troubleshooting.
2025-11-22 23:57:05 -03:00
2d7761ee94 Merge pull request #40 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-22 22:41:20 -03:00
90e81e4667 feat: add "Controle de Ponto" section with management options for employee time records
- Introduced a new section for "Controle de Ponto" in the recursos-humanos page, allowing users to manage employee time records.
- Added options for viewing and managing point records, editing records, and handling dispensations, enhancing functionality for HR management.
2025-11-22 22:40:30 -03:00
5b41d35b6f feat: add user authentication and validation checks in query handlers
- Implemented user authentication checks in the `getAll` and `listarRegistrosPeriodo` query handlers, returning empty arrays for unauthenticated users.
- Enhanced date validation in `listarRegistrosPeriodo` to ensure correct date formats before processing.
- Updated the `obterEstatisticas` query to return zeroed statistics for unauthenticated users, improving data security and user experience.
2025-11-22 22:33:44 -03:00
aeaa3c903f feat: implement user authentication checks in PresenceManager and perfil pages
- Added authentication verification in the PresenceManager component to manage user presence status based on authentication state.
- Updated the perfil page to conditionally execute queries only if the user is authenticated, enhancing security and performance.
- Introduced derived variables to track user authentication status, ensuring that presence management and data fetching are only performed for logged-in users.
2025-11-22 22:25:40 -03:00
031552c836 feat: enhance sidebar and theme utilities for improved UI consistency
- Updated the Sidebar component styles to utilize theme-based classes for better visual consistency.
- Added new utility functions to retrieve and convert theme colors for use in components, enhancing theming capabilities.
- Improved layout logic to ensure the default theme is applied correctly based on user preferences and document readiness.
2025-11-22 22:10:52 -03:00
37d7318d5a feat: implement theme customization and user preferences
- Added support for user-selected themes, allowing users to customize the appearance of the application.
- Introduced a new `temaPreferido` field in the user schema to store the preferred theme.
- Updated various components to apply the selected theme dynamically based on user preferences.
- Enhanced the UI to include a theme selection interface, enabling users to preview and save their theme choices.
- Implemented a polyfill for BlobBuilder to ensure compatibility across browsers, improving the functionality of the application.
2025-11-22 22:05:52 -03:00
58ac3a4f1b feat: implement user authentication checks for queries in registro-pontos page
- Added authentication verification to conditionally execute queries for fetching employees, point records, statistics, and configuration settings based on user authentication status.
- Introduced a derived variable to manage the authenticated state of the user, enhancing security and ensuring that data is only accessible to logged-in users.
2025-11-22 21:01:27 -03:00
dc799504f6 fix: correct layout issue in registro-pontos page
- Added a missing closing div tag to ensure proper structure and rendering of the UI elements in the registro-pontos page.
2025-11-22 20:58:12 -03:00
80fc8bc82c feat: implement partial balance calculation and enhance UI for point registration
- Added a new function `calcularSaldosParciais` to compute partial balances between entry and exit records, returning a map of balances indexed by record.
- Improved the UI to display partial balances in the registro-pontos table, enhancing user visibility of time management.
- Updated the header section for better layout and information presentation regarding employee details.
2025-11-22 20:55:03 -03:00
f818756efc feat: enhance call and point registration features with sensor data integration
- Updated the CallWindow component to include connection quality states and reconnection attempts, improving user experience during calls.
- Enhanced the ChatWindow to allow starting audio and video calls in a new window, providing users with more flexibility.
- Integrated accelerometer and gyroscope data collection in the RegistroPonto component, enabling validation of point registration authenticity.
- Improved error handling and user feedback for sensor permissions and data validation, ensuring a smoother registration process.
- Updated backend logic to validate sensor data and adjust confidence scores for point registration, enhancing security against spoofing.
2025-11-22 20:49:52 -03:00
fc4b5c5ba5 feat: add date formatting utility and enhance filtering in registro-pontos
- Introduced a new utility function `formatarDataDDMMAAAA` to format dates in DD/MM/AAAA format, supporting various input types.
- Updated the `registro-pontos` page to utilize the new date formatting function for displaying dates consistently.
- Implemented advanced filtering options for status and location, allowing users to filter records based on their criteria.
- Enhanced CSV export functionality to include formatted dates and additional filtering capabilities, improving data management for users.
2025-11-22 19:32:05 -03:00
9dc816977d Merge pull request #39 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-22 18:40:54 -03:00
c056506ce5 feat: enhance time synchronization and Jitsi configuration handling
- Implemented a comprehensive time synchronization mechanism that applies GMT offsets based on user configuration, ensuring accurate timestamps across the application.
- Updated the Jitsi configuration to include SSH settings, allowing for better integration with Docker setups.
- Refactored the backend queries and mutations to handle the new SSH configuration fields, ensuring secure and flexible server management.
- Enhanced error handling and logging for time synchronization processes, providing clearer feedback for users and developers.
2025-11-22 18:18:16 -03:00
3cc35d3a1e feat: Add explicit TypeScript types and improve error handling for employee reports. 2025-11-22 16:52:31 -03:00
Kilder Costa
7871b87bb9 Merge pull request #38 from killer-cf/refinament-1
Refinament 1
2025-11-22 10:26:24 -03:00
b8a2e67f3a refactor: Update email configuration page to load data once and improve error handling, and add Svelte agent rules documentation. 2025-11-22 10:25:43 -03:00
54089f5eca fix: update Jitsi configuration handling for default values
- Refactored the Jitsi configuration logic to use nullish coalescing for default values in the frontend.
- Added a condition to reset configuration values to defaults when no configuration is available.
- Adjusted backend mutation to ensure consistent handling of the acceptSelfSignedCert parameter.
2025-11-21 22:09:30 -03:00
52823a9fac feat: integrate Jitsi configuration and dynamic loading in CallWindow
- Added support for Jitsi configuration retrieval from the backend, allowing for dynamic room name generation based on the active configuration.
- Implemented a polyfill for BlobBuilder to ensure compatibility with the lib-jitsi-meet library across different browsers.
- Enhanced error handling during the loading of the Jitsi library, providing clearer feedback for missing modules and connection issues.
- Updated Vite configuration to exclude lib-jitsi-meet from SSR and allow dynamic loading in the browser.
- Introduced a new route for Jitsi settings in the dashboard for user configuration of Jitsi Meet parameters.
2025-11-21 22:03:01 -03:00
41f7942dd1 feat: add avatar field to user profile schema and update related mutations and queries
- Introduced an optional avatar field in the user profile schema to store the URL of the generated avatar.
- Updated the atualizarPerfil mutation to handle the new avatar field.
- Modified the obterPerfil and listarParaChat queries to include the avatar field in the returned user data.
2025-11-21 20:34:04 -03:00
f167996a9f Merge pull request #37 from killer-cf/feat-controle-ponto
fix: restore enderecosMarcacao import in API definition
2025-11-21 20:11:08 -03:00
36dcdf76ce fix: restore enderecosMarcacao import in API definition
- Reintroduced the enderecosMarcacao import in the API definition to ensure proper type referencing across modules.
2025-11-21 20:10:07 -03:00
21783de25f refactor: clean up imports and improve error message formatting in ChatWindow
- Commented out unused imports in ChatWindow for better clarity.
- Reformatted error messages in the iniciarChamada function for improved readability.
- Ensured consistent spacing and formatting throughout the ChatWindow component.
2025-11-21 19:59:04 -03:00
a0fcb1571c Merge pull request #36 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-21 19:54:11 -03:00
d2959fc163 Merge branch 'master' into call-audio-video-jitsi 2025-11-21 19:54:01 -03:00
5122eacddd feat: enhance CallWindow with error handling and track management
- Added error handling logic to manage Jitsi connection and track creation errors, providing user-friendly feedback through the new ErrorModal.
- Introduced functionality to dynamically create and manage local audio and video tracks during calls.
- Updated Jitsi configuration to separate host and port for improved connection handling.
- Refactored call initiation logic to ensure robust error reporting and user guidance during connection issues.
2025-11-21 19:52:50 -03:00
1f48247493 feat: enhance ErrorModal and ChatWindow error handling
- Added HelpCircle icon to ErrorModal for improved visual feedback.
- Implemented logic to differentiate between instructions and technical details in ErrorModal.
- Updated ChatWindow to utilize traduzirErro for user-friendly error messages during call initiation.
- Enhanced error handling to provide clearer instructions and details based on error types.
2025-11-21 17:11:40 -03:00
9d2f6e7c79 feat: add error handling modal to ChatWindow and improve call initiation logic
- Introduced ErrorModal in ChatWindow to display error messages related to call initiation issues.
- Enhanced error handling during call initiation to provide user-friendly feedback based on different error scenarios.
- Refactored backend queries to streamline the retrieval of active calls, ensuring accurate status checks.
- Updated function names for clarity and consistency in the backend call management logic.
2025-11-21 16:57:21 -03:00
8fc3cf08c4 feat: enhance call functionality and improve type safety
- Updated CallControls to replace the Record icon with Radio for better representation during recording states.
- Refactored CallWindow to introduce Jitsi connection and conference interfaces, improving type safety and clarity in handling Jitsi events.
- Streamlined event handling for connection and conference states, ensuring robust management of audio/video calls.
- Enhanced ChatWindow to directly import CallWindow, simplifying the component structure and improving call handling logic.
- Improved utility functions for window management to ensure compatibility with server-side rendering.
2025-11-21 16:21:01 -03:00
ce94eb53b3 generated file 2025-11-21 14:02:22 -03:00
c5e83464ba fix: correct component declaration in ChatWindow for call handling
- Updated the ChatWindow component to ensure proper declaration of the CallWindowComponent, enhancing the functionality of audio/video calls.
- This change resolves an issue with the component rendering logic during active calls.
2025-11-21 13:22:12 -03:00
2792424454 feat: implement audio/video call functionality in chat
- Added a new schema for managing audio/video calls, including fields for call type, room name, and participant management.
- Enhanced ChatWindow component to support initiating audio and video calls with dynamic loading of the CallWindow component.
- Updated package dependencies to include 'lib-jitsi-meet' for call handling.
- Refactored existing code to accommodate new call features and improve user experience.
2025-11-21 13:17:44 -03:00
54535af9f7 Merge pull request #35 from killer-cf/feat-controle-ponto
Feat controle ponto
2025-11-21 12:48:44 -03:00
fd158f164d Merge branch 'master' into feat-controle-ponto 2025-11-21 12:45:31 -03:00
b2c15cf967 feat: add bairro field to endereco update mutation
- Introduced a new optional 'bairro' field in the atualizarEndereco mutation to enhance address detail management.
2025-11-21 10:09:53 -03:00
d6aaa15cf4 feat: enhance point registration and location validation features
- Refactored the RegistroPonto component to improve the layout and user experience, including a new section for displaying standard hours.
- Updated RelogioSincronizado to include GMT offset adjustments for accurate time display.
- Introduced new location validation logic in the backend to ensure point registrations are within allowed geofenced areas.
- Enhanced the device information schema to capture additional GPS data, improving the reliability of location checks.
- Added new endpoints for managing allowed marking addresses, facilitating better control over where points can be registered.
2025-11-21 05:12:27 -03:00
Kilder Costa
74049c25ae Merge pull request #34 from killer-cf/refactor-avatar
Refactor avatar
2025-11-20 23:26:06 -03:00
aa8dab6fd5 feat: add new avatar images and update the profile page's avatar selection list 2025-11-20 23:25:09 -03:00
3da364fb02 feat: add statistics visualization and improve data handling in point registration
- Introduced a bar chart to visualize statistics of point registrations, including on-time and late records.
- Enhanced data handling by implementing checks for valid records and improved grouping logic for better data representation.
- Added loading states and error handling for improved user feedback during data retrieval.
- Refactored the layout to include a detailed statistics section, enhancing the overall user experience in the point management interface.
2025-11-20 18:56:16 -03:00
0af8daa901 feat: replace dynamic avatar generation with static image assets 2025-11-20 15:05:17 -03:00
d50760f0db refactor: improve point management UI and query handling
- Updated the logic for handling query parameters in point management, ensuring better state management when no employee is selected.
- Enhanced the UI for editing and adjusting point records with a more modern card layout and improved input fields.
- Introduced loading states for queries to provide better user feedback during data retrieval.
- Refactored the rendering of records and homologations to improve performance and user experience.
2025-11-20 14:25:52 -03:00
Kilder Costa
209a3e088d Merge pull request #33 from killer-cf/refinament-1
Refinament 1
2025-11-20 14:17:11 -03:00
51e2efa07e refactor: enhance email scheduling functionality and improve error handling
- Added a new mutation to cancel scheduled emails, ensuring only pending emails can be canceled.
- Updated the current user query to use type casting for better type safety.
- Improved the handling of email status queries to skip execution when no email IDs are present.
- Refactored error checking for template queries to streamline the code and remove unused variables.
- Enhanced user feedback for authentication requirements when sending notifications.
2025-11-20 14:13:05 -03:00
e029cd1d6b feat: enhance dispensa management with modal confirmation and time input improvements
- Introduced a modal for confirming the removal of dispensas, improving user interaction and preventing accidental deletions.
- Updated time input fields to use a more user-friendly format, allowing for direct time selection.
- Refactored state management for dispensa creation, ensuring better handling of time and date inputs.
- Enhanced UI elements for better feedback and clarity during the dispensa creation process.
2025-11-20 13:58:12 -03:00
8ea5c0316b feat: implement homologation deletion and detail viewing features
- Added functionality to delete homologations, restricted to users with managerial permissions.
- Introduced modals for viewing details of homologations and confirming deletions, enhancing user interaction.
- Updated the backend to support homologation deletion, including necessary permission checks and data integrity management.
- Enhanced the UI to display alerts for unassociated employees and active dispensas during point registration, improving user feedback and error handling.
2025-11-20 07:01:33 -03:00
9451e69d68 chore: add EditorConfig and remove development images and test report 2025-11-19 23:45:30 -03:00
bc1e08914b Merge pull request #32 from killer-cf/feat-controle-ponto
Feat controle ponto
2025-11-19 17:00:26 -03:00
57c37fedef feat: unify editing and adjustment forms for point management
- Replaced separate editing and adjustment modes with a unified form that allows users to switch between editing hours and adjusting bank hours.
- Introduced new state management for active tab selection and formatted time input.
- Implemented functions to convert between time formats and calculate periods between dates.
- Enhanced user experience with improved layout and validation for date and time inputs.
- Updated the UI to reflect changes in the form structure, ensuring a more cohesive interaction for users managing point records.
2025-11-19 16:59:26 -03:00
db61df1fb4 feat: add new features for point management and registration
- Introduced "Homologação de Registro" and "Dispensa de Registro" sections in the dashboard for enhanced point management.
- Updated the WidgetGestaoPontos component to include new links and icons for the added features.
- Enhanced backend functionality to support the new features, including querying and managing dispensas and homologações.
- Improved the PDF generation process to include daily balance calculations for employee time records.
- Implemented checks for active dispensas to prevent unauthorized point registrations.
2025-11-19 16:37:31 -03:00
Kilder Costa
7fede4a992 Merge pull request #31 from killer-cf/refinament-1
Refinament 1
2025-11-19 16:25:21 -03:00
1c06519108 Merge remote-tracking branch 'origin' into refinament-1 2025-11-19 16:24:20 -03:00
eb95448604 refactor: streamline dashboard page and improve error handling
- Removed unnecessary refresh logic for monitoring queries to enhance performance.
- Updated error handling to ensure proper type casting and improved URL management.
- Simplified the rendering of components and improved the overall structure for better readability.
- Added a user-friendly error message for cases when dashboard data fails to load.
2025-11-19 16:23:01 -03:00
dac559d9fd refactor: remove access request functionality and related components
- Deleted the solicitacoesAcesso route and its associated components to streamline the dashboard.
- Updated dashboard stats to remove references to access requests, ensuring accurate data representation.
- Refactored backend queries to eliminate access request data handling, enhancing performance and maintainability.
- Adjusted type definitions to reflect the removal of access request functionalities.
2025-11-19 12:30:42 -03:00
c7fd824138 feat: add new route and icon for clock functionality in dashboard
- Introduced 'clock' as a new route in the dashboard, enhancing navigation options.
- Added corresponding SVG icon for the clock feature to improve visual representation.
- Updated type definitions to include new routes and palette keys for better type safety.
2025-11-19 11:58:53 -03:00
3cbe02fd1e refactor: replace Date with SvelteDate for improved date handling in absence components
- Updated date handling in CalendarioAusencias and WizardSolicitacaoAusencia components to use SvelteDate for better reactivity and consistency.
- Refactored various date-related functions to ensure compatibility with the new SvelteDate type.
- Enhanced UI elements to maintain functionality while improving code clarity and maintainability.
2025-11-19 11:47:17 -03:00
Kilder Costa
dcbc494d87 Merge pull request #30 from killer-cf/feat-licitacoes-contratos
Feat licitacoes contratos
2025-11-19 09:31:18 -03:00
263d561301 Merge remote-tracking branch 'origin' into feat-licitacoes-contratos 2025-11-19 09:29:30 -03:00
372feed819 Merge pull request #29 from killer-cf/feat-controle-ponto
Feat controle ponto
2025-11-19 06:41:57 -03:00
ed5695cf28 refactor: adjust modal dimensions and enhance PDF generation for point registration
- Updated modal height settings in ComprovantePonto and RegistroPonto components for improved layout and user experience.
- Adjusted image display sizes to ensure better visibility and consistency across the application.
- Enhanced PDF generation logic to include a summary of the employee's bank of hours, providing clearer insights into time management.
- Implemented checks for page overflow in PDF generation, ensuring content fits within the document layout.
2025-11-19 06:41:26 -03:00
7cdc726781 feat: implement customizable point registration labels and GMT offset adjustment
- Added functionality to customize labels for point registration types (Entrada, Saída, etc.) in the configuration settings.
- Introduced a GMT offset adjustment feature to account for time zone differences during point registration.
- Updated the backend to ensure default values for custom labels and GMT offset are set correctly.
- Enhanced the UI to allow users to input and save personalized names for each type of point registration.
- Improved the point registration process to utilize the new configuration settings for displaying labels consistently across the application.
2025-11-19 06:22:07 -03:00
f465bd973e refactor: improve layout and functionality of ComprovantePonto modal
- Enhanced the modal layout for better user experience, including fixed header and footer.
- Implemented a scrollable content area for improved visibility of registration details.
- Updated button styles for better interaction feedback.
- Ensured consistent error handling and loading states for data retrieval.
2025-11-19 05:14:28 -03:00
b660d123d4 feat: add PDF receipt generation for point registration
- Implemented a new feature to generate a PDF receipt for point registrations, including employee and registration details.
- Integrated jsPDF for PDF creation and added functionality to include a logo and captured images in the receipt.
- Enhanced the UI with a print button for users to easily access the receipt generation feature.
- Improved the confirmation modal layout for better user experience during point registration.
2025-11-19 05:09:54 -03:00
d16f76daeb feat: update point registration process with mandatory photo capture and location details
- Removed location details from the point receipt, now displayed only in detailed reports.
- Implemented mandatory photo capture during point registration, enhancing accountability.
- Added confirmation modal for users to verify details before finalizing point registration.
- Improved error handling for webcam access and photo capture, ensuring a smoother user experience.
- Enhanced UI components for better feedback and interaction during the registration process.
2025-11-19 04:54:03 -03:00
b8506b6d45 refactor: enhance licitacoes page layout and add contratos permissions
- Improved the layout of the licitacoes page for better readability and user experience.
- Added new permissions for contratos, including listar, criar, editar, excluir, and ver actions.
- Introduced a new schema for contratos with relevant fields and indexes to support contract management.
2025-11-18 23:11:40 -03:00
67d6b3ec72 feat: enhance webcam capture and geolocation functionality
- Improved webcam capture process with multiple constraint strategies for better compatibility and error handling.
- Added loading state management for video readiness, enhancing user experience during webcam access.
- Refactored geolocation retrieval to implement multiple strategies, improving accuracy and reliability in obtaining user location.
- Enhanced error handling for both webcam and geolocation features, providing clearer feedback to users.
2025-11-18 16:20:38 -03:00
b01d2d6786 feat: enhance point registration and management features
- Added functionality to capture and display images during point registration, improving user experience.
- Implemented error handling for image uploads and webcam access, ensuring smoother operation.
- Introduced a justification field for point registration, allowing users to provide context for their entries.
- Enhanced the backend to support new features, including image handling and justification storage.
- Updated UI components for better layout and responsiveness, improving overall usability.
2025-11-18 15:28:26 -03:00
b844260399 refactor: update CNPJ handling and API integration in empresas page
- Replaced the ReceitaWS API with BrasilAPI for fetching CNPJ data, improving reliability.
- Updated response handling to accommodate new data structure from BrasilAPI.
- Enhanced form population logic for company details based on the new API response.
- Adjusted table layout to correctly display CNPJ alongside company names.
2025-11-18 11:49:26 -03:00
f0c6e4468f feat: integrate point management features into the dashboard
- Added a new "Meu Ponto" section for users to register their work hours, breaks, and attendance.
- Introduced a "Controle de Ponto" category in the Recursos Humanos section for managing employee time records.
- Enhanced the backend schema to support point registration and configuration settings.
- Updated various components to improve UI consistency and user experience across the dashboard.
2025-11-18 11:44:12 -03:00
801a39d221 Merge remote-tracking branch 'origin' into feat-licitacoes-contratos 2025-11-18 11:18:26 -03:00
029cd9c637 add endereco e edita tabela empresas 2025-11-18 11:15:44 -03:00
52123a33b3 Merge pull request #28 from killer-cf/fix-dias-ferias
Fix dias ferias
2025-11-18 10:15:02 -03:00
031c151967 refactor: enhance AprovarAusencias component with improved UI and layout
- Updated card styling and layout for a more modern and user-friendly experience.
- Enhanced visual elements, including updated icons and spacing for better readability.
- Improved responsiveness and hover effects for interactive elements.
- Refined status display and error handling for clearer user feedback.
2025-11-18 10:13:54 -03:00
af6353fa40 refactor: enhance password change page with improved UI and functionality
- Updated the layout and styling of the password change page for a more modern and user-friendly experience.
- Integrated new icons and visual elements to enhance the overall design and accessibility.
- Improved form handling with better loading states and error messages for user feedback.
- Added security tips and password requirements to guide users during the password change process.
2025-11-18 07:09:40 -03:00
22e77d8890 refactor: enhance Sidebar login modal with improved styling and loading state management
- Added loading state management to the login modal for better user feedback during authentication.
- Updated modal styling with a gradient background and improved button interactions for a more modern look.
- Enhanced error message display and form input fields for better accessibility and user experience.
- Refined layout and spacing for a cleaner presentation of the login form and auxiliary links.
2025-11-18 07:02:54 -03:00
db098ceea9 refactor: optimize query handling and state management in perfil page
- Updated query logic to ensure stable data retrieval for user-related information, reducing unnecessary re-creations.
- Implemented derived states to manage loading and error conditions more effectively, enhancing user experience.
- Improved synchronization of query results with stable states, ensuring data consistency during loading phases.
- Refactored existing queries to utilize stable keys based on user IDs, preventing issues with undefined states.
2025-11-18 06:51:22 -03:00
422dc6f022 refactor: enhance ProtectedRoute and dashboard components for improved access control and user experience
- Updated the ProtectedRoute component to optimize access checking logic, preventing unnecessary re-checks and improving authentication flow.
- Enhanced the dashboard page to automatically open the login modal for authentication errors and refined loading states for better user feedback.
- Improved UI elements across various components for consistency and visual appeal, including updated tab styles and enhanced alert messages.
- Removed redundant footer from the vacation management page to streamline the interface.
2025-11-18 06:34:55 -03:00
3420872a37 refactor: enhance Sidebar and dashboard components for improved UI and functionality
- Updated the Sidebar component to change the support link and improve modal styling for better user experience.
- Refined the dashboard page by optimizing data handling for real-time monitoring, ensuring fallback values for activity and distribution data.
- Improved progress bar calculations to prevent division by zero errors, enhancing stability and user feedback.
- Adjusted layout and styling for consistency and better visual appeal across components.
2025-11-18 03:31:54 -03:00
71550874ce feat: update system branding and improve user interface consistency
- Changed all instances of "Sistema de Gerenciamento da Secretaria de Esportes" to "Sistema de Gerenciamento de Secretaria" for a more concise branding.
- Enhanced the PrintModal component with a user-friendly interface for selecting sections to include in PDF generation.
- Improved error handling and user feedback during PDF generation processes.
- Updated various components and routes to reflect the new branding, ensuring consistency across the application.
2025-11-18 03:10:10 -03:00
7c8be8a818 feat: implement response management for tickets in central chamados
- Added functionality to respond to tickets, including text input and file attachment options.
- Implemented methods for handling file uploads and managing response state, including feedback messages for users.
- Enhanced the UI to allow users to select a ticket and provide a response, with options to mark tickets as completed.
- Improved type safety by specifying types for user and ticket data throughout the component.
2025-11-17 19:15:50 -03:00
0e5a26b5fd feat: add 'Cancelado_RH' status to vacation management
- Introduced a new status 'Cancelado_RH' for vacation requests, allowing for better tracking of cancellations by HR.
- Updated the UI components to reflect the new status, including badge colors and alert messages.
- Enhanced backend schema and mutation to support the new status, ensuring consistency across the application.
- Improved logging and state management for better performance and user experience.
2025-11-17 19:07:03 -03:00
f021e96eb4 feat: enhance cybersecurity features and add ticket management components
- Introduced new components for managing tickets, including TicketForm, TicketCard, and TicketTimeline, to streamline the ticketing process.
- Added a new SlaChart component for visualizing SLA data.
- Implemented a CybersecurityWizcard component for enhanced security monitoring and reporting.
- Updated routing to replace the "Solicitar Acesso" page with "Abrir Chamado" for improved user navigation.
- Integrated rate limiting functionality to enhance security measures.
- Added a comprehensive test report for the cybersecurity system, detailing various attack simulations and their outcomes.
- Included new scripts for security testing and environment setup to facilitate automated security assessments.
2025-11-17 16:54:43 -03:00
2c3d231d20 refactor: enhance authentication and access control in ProtectedRoute component
- Updated the ProtectedRoute component to improve access control logic, including a timeout mechanism for handling authentication checks.
- Refactored the checkAccess function to streamline user access verification based on roles and authentication status.
- Added comments for clarity on the authentication flow and the use of the convexClient plugin in the auth.ts file.
- Improved the overall structure and readability of the code in auth.ts and ProtectedRoute.svelte.
2025-11-17 16:27:15 -03:00
2b94b56f6e feat: add empresa and contatos
- Introduced a new utility function `maskCNPJ` for formatting CNPJ values.
- Updated the dashboard pages to replace icons and improve layout, including the addition of a link to manage companies.
- Enhanced the display of upcoming features for both the Licitações and Programas Esportivos modules, indicating their development status.
2025-11-17 15:45:48 -03:00
d4e70b5e52 Merge pull request #27 from killer-cf/feat-cibersecurity
Feat cibersecurity
2025-11-17 11:49:34 -03:00
8a613128a5 Merge branch 'master' into feat-cibersecurity 2025-11-17 11:49:18 -03:00
99258d620f Merge pull request #26 from killer-cf/bug-perfil
Bug perfil
2025-11-17 11:46:25 -03:00
d173e2a255 feat: improve alert configuration form and user experience
- Added a new input field for alert configuration names, enhancing clarity for users creating or editing configurations.
- Implemented a function to clear the alert configuration form, allowing users to start fresh when creating new settings.
- Updated feedback messages to provide clearer guidance on saving and updating configurations.
- Enhanced the layout of the alert settings section for better usability and organization.
- Introduced smooth scrolling to the alert form when editing configurations, improving navigation experience.
2025-11-17 11:19:51 -03:00
7e3c100fb9 feat: enhance alert configuration management and reporting features
- Integrated jsPDF and autoTable for generating detailed security reports in PDF format, improving report acceapps/web/src/lib/components/ti/CybersecurityWizcard.sveltessibility and presentation.
- Added functionality to clear alert configuration forms, allowing users to easily create new configurations without residual data.
- Updated alert configuration management to include user-friendly input fields for email and chat notifications, enhancing user experience.
- Improved the layout and organization of the alert settings section for better clarity and usability.
- Enhanced feedback messages for saving and updating alert configurations, providing clearer user guidance.
2025-11-17 11:19:03 -03:00
05d3a394da Merge branch 'feat-central-chamados' into feat-cibersecurity 2025-11-17 10:19:10 -03:00
29118d22ce refactor: reorganize and enhance cybersecurity components
- Refactored the CybersecurityWizcard component for improved readability and maintainability, including better formatting of code and comments.
- Moved the Alertas e Notificações section to a more logical position within the layout, enhancing user experience.
- Updated text labels for clarity, changing "Wizcard de Segurança Avançada" to "Segurança Avançada" and "Cibersecurity SGSE" to "Central de segurança cibernética".
- Improved the structure of various elements for better alignment and presentation in the UI.
2025-11-17 10:14:54 -03:00
55847e2a77 feat: add SLA statistics and real-time monitoring to central-chamados
- Introduced new queries to fetch SLA statistics and real-time SLA data for better ticket management insights.
- Enhanced the central-chamados route to display SLA performance metrics, including compliance rates and ticket statuses by priority.
- Implemented fallback logic for statistics calculation to ensure data availability even when queries return undefined.
- Refactored the UI to include a dedicated section for SLA performance, improving user experience and data visibility.
2025-11-17 09:33:33 -03:00
5ef6ef8550 feat: enhance SLA management and authentication handling
- Updated the useConvexWithAuth hook to improve token management and logging for better debugging.
- Integrated automatic authentication handling in the central-chamados route, ensuring seamless user experience.
- Added a new mutation for migrating old SLA configurations to include a priority field, enhancing data consistency.
- Improved the display of SLA configurations in the UI, including detailed views and migration feedback for better user interaction.
- Refactored ticket loading logic to enrich ticket data with responsible user names, improving clarity in ticket management.
2025-11-17 08:44:18 -03:00
fb784d6f7e refactor: simplify ticket form and SLA configuration handling
- Removed SLA configuration selection from the TicketForm component to streamline the ticket creation process.
- Updated the abrir-chamado route to eliminate unnecessary SLA loading logic and directly pass loading state to the TicketForm.
- Enhanced the central-chamados route to support SLA configurations by priority, allowing for better management of SLA settings.
- Introduced new mutations for SLA configuration management, including creation, updating, and deletion of SLA settings.
- Improved email templates for ticket notifications, ensuring better communication with users regarding ticket status and updates.
2025-11-16 15:41:16 -03:00
24b8eb6a14 feat: integrate Convex authentication across ticket management routes
- Added useConvexWithAuth hook to ensure authenticated access for ticket-related pages.
- Updated the perfil and central-chamados routes to include the authentication setup.
- Enhanced user navigation by adding a new "Meus Chamados" tab in the perfil page for better access to ticket management.
2025-11-16 14:19:01 -03:00
60e0bfa69e feat: add report printing and deletion functionality
- Implemented a new feature to print security reports with structured JSON observations, enhancing report accessibility.
- Added a deletion function for reports, allowing users to remove reports with confirmation prompts.
- Updated the CybersecurityWizcard component to include action buttons for printing and deleting reports, improving user interaction.
- Enhanced the backend with a mutation to handle report deletions, ensuring proper cleanup of associated artifacts.
2025-11-16 08:24:58 -03:00
70d405d98d feat: implement alert configuration and recent report features
- Added alert configuration management for email and chat notifications, allowing users to set preferences for severity levels, attack types, and notification channels.
- Introduced functionality to save, edit, and delete alert configurations, enhancing user control over security notifications.
- Implemented a new query to list recent security reports, providing users with quick access to the latest security incidents.
- Enhanced the backend schema to support alert configurations and recent report tracking, improving overall security management capabilities.
2025-11-16 07:37:36 -03:00
88983ea297 feat: integrate rate limiting and enhance security features
- Added @convex-dev/rate-limiter dependency to manage request limits effectively.
- Implemented rate limiting configurations for IPs, users, and endpoints to prevent abuse and enhance security.
- Introduced new security analysis endpoint to detect potential attacks based on incoming requests.
- Updated backend schema to include rate limit configurations and various cyber attack types for improved incident tracking.
- Enhanced existing security functions to incorporate rate limiting checks, ensuring robust protection against brute force and other attacks.
2025-11-16 01:20:57 -03:00
ea01e2401a feat: enhance security features and backend schema
- Added new cron jobs for automatic IP block expiration and threat intelligence synchronization to improve security management.
- Expanded the backend schema to include various types of cyber attack classifications, security event statuses, and incident action types for better incident tracking and response.
- Introduced new tables for network sensors, IP reputation, port rules, threat intelligence feeds, and security events to enhance the overall security infrastructure.
- Updated API definitions to incorporate new security-related modules, ensuring comprehensive access to security functionalities.
2025-11-15 07:25:01 -03:00
118051ad56 refactor: update menu and routing for ticket management
- Replaced references to "Solicitar Acesso" with "Abrir Chamado" across the application for consistency in terminology.
- Updated routing logic to reflect the new ticket management flow, ensuring that the dashboard and sidebar components point to the correct paths.
- Removed the obsolete "Solicitar Acesso" page, streamlining the user experience and reducing unnecessary navigation options.
- Enhanced backend schema to support new ticket functionalities, including ticket creation and management.
2025-11-14 22:50:03 -03:00
485 changed files with 136192 additions and 30297 deletions

View File

@@ -0,0 +1,127 @@
---
trigger: glob
globs: **/*.svelte.ts,**/*.svelte
---
# Convex + Svelte Best Practices
This document outlines the mandatory rules and best practices for integrating Convex with Svelte in this project.
## 1. Imports
Always use the following import paths. Do NOT use `$lib/convex` or relative paths for generated files unless specifically required by a local override.
### Correct Imports:
```typescript
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
```
### Incorrect Imports (Avoid):
```typescript
import { convex } from '$lib/convex'; // Avoid direct client usage for queries
import { api } from '$lib/convex/_generated/api'; // Incorrect path
import { api } from '../convex/_generated/api'; // Relative path
```
## 2. Data Fetching
### Use `useQuery` for Reactivity
Instead of manually fetching data inside `onMount`, use the `useQuery` hook. This ensures your data is reactive and automatically updates when the backend data changes.
**Preferred Pattern:**
```svelte
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
const tasksQuery = useQuery(api.tasks.list, { status: 'pending' });
const tasks = $derived(tasksQuery.data || []);
const isLoading = $derived(tasksQuery.isLoading);
</script>
```
**Avoid Pattern:**
```svelte
<script lang="ts">
import { onMount } from 'svelte';
import { convex } from '$lib/convex';
let tasks = [];
onMount(async () => {
// This is not reactive!
tasks = await convex.query(api.tasks.list, { status: 'pending' });
});
</script>
```
### Mutations
Use `useConvexClient` to access the client for mutations.
```svelte
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
const client = useConvexClient();
async function completeTask(id) {
await client.mutation(api.tasks.complete, { id });
}
</script>
```
## 3. Type Safety
### No `any`
Strictly avoid using `any`. The Convex generated data model provides precise types for all your tables.
### Use Generated Types
Use `Doc<"tableName">` for full document objects and `Id<"tableName">` for IDs.
**Correct:**
```typescript
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
let selectedTask: Doc<'tasks'> | null = $state(null);
let taskId: Id<'tasks'>;
```
**Incorrect:**
```typescript
let selectedTask: any = $state(null);
let taskId: string;
```
### Union Types for Enums
When dealing with status fields or other enums, define the specific union type instead of casting to `any`.
**Correct:**
```typescript
async function updateStatus(newStatus: 'pending' | 'completed' | 'archived') {
// ...
}
```
**Incorrect:**
```typescript
async function updateStatus(newStatus: string) {
// ...
status: newStatus as any; // Avoid this
}
```

View File

@@ -0,0 +1,275 @@
---
trigger: glob
globs: **/*.svelte, **/*.ts, **/*.svelte.ts
---
# Convex + Svelte Guidelines
## Overview
These guidelines describe how to write **Convex** backend code **and** consume it from a **Svelte** (SvelteKit) frontend. The syntax for Convex functions stays exactly the same, but the way you import and call them from the client differs from a React/Next.js project. Below you will find the adapted sections from the original Convex style guide with Sveltespecific notes.
---
## 1. Function Syntax (Backend)
> **No change** keep the new Convex function syntax.
```typescript
import {
query,
mutation,
action,
internalQuery,
internalMutation,
internalAction
} from './_generated/server';
import { v } from 'convex/values';
export const getUser = query({
args: { userId: v.id('users') },
returns: v.object({ name: v.string(), email: v.string() }),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) throw new Error('User not found');
return { name: user.name, email: user.email };
}
});
```
---
## 2. HTTP Endpoints (Backend)
> **No change** keep the same `convex/http.ts` file.
```typescript
import { httpRouter } from 'convex/server';
import { httpAction } from './_generated/server';
const http = httpRouter();
http.route({
path: '/api/echo',
method: 'POST',
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
})
});
```
---
## 3. Validators (Backend)
> **No change** keep the same validators (`v.string()`, `v.id()`, etc.).
---
## 4. Function Registration (Backend)
> **No change** use `query`, `mutation`, `action` for public functions and `internal*` for private ones.
---
## 5. Function Calling from **Svelte**
### 5.1 Install the Convex client
```bash
npm i convex @convex-dev/convex-svelte
```
> The `@convex-dev/convex-svelte` package provides a thin wrapper that works with Svelte stores.
### 5.2 Initialise the client (e.g. in `src/lib/convex.ts`)
```typescript
import { createConvexClient } from '@convex-dev/convex-svelte';
export const convex = createConvexClient({
url: import.meta.env.VITE_CONVEX_URL // set in .env
});
```
### 5.3 Using queries in a component
```svelte
<script lang="ts">
import { convex } from '$lib/convex';
import { onMount } from 'svelte';
import { api } from '../convex/_generated/api';
let user: { name: string; email: string } | null = null;
let loading = true;
let error: string | null = null;
onMount(async () => {
try {
user = await convex.query(api.users.getUser, { userId: 'some-id' });
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
});
</script>
{#if loading}
<p>Loading…</p>
{:else if error}
<p class="error">{error}</p>
{:else if user}
<h2>{user.name}</h2>
<p>{user.email}</p>
{/if}
```
### 5.4 Using mutations in a component
```svelte
<script lang="ts">
import { convex } from '$lib/convex';
import { api } from '../convex/_generated/api';
let name = '';
let creating = false;
let error: string | null = null;
async function createUser() {
creating = true;
error = null;
try {
const userId = await convex.mutation(api.users.createUser, { name });
console.log('Created user', userId);
} catch (e) {
error = (e as Error).message;
} finally {
creating = false;
}
}
</script>
<input bind:value={name} placeholder="Name" />
<button on:click={createUser} disabled={creating}>Create</button>
{#if error}<p class="error">{error}</p>{/if}
```
### 5.5 Using **actions** (Nodeonly) from Svelte
Actions run in a Node environment, so they cannot be called directly from the browser. Use a **mutation** that internally calls the action, or expose a HTTP endpoint that triggers the action.
---
## 6. Scheduler / Cron (Backend)
> Same as original guide define `crons.ts` and export the default `crons` object.
---
## 7. File Storage (Backend)
> Same as original guide use `ctx.storage.getUrl()` and query `_storage` for metadata.
---
## 8. TypeScript Helpers (Backend)
> Keep using `Id<'table'>` from `./_generated/dataModel`.
---
## 9. SvelteSpecific Tips
| Topic | Recommendation |
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Storebased data** | If you need reactive data across many components, wrap `convex.query` in a Svelte store (`readable`, `writable`). |
| **Error handling** | Use `try / catch` around every client call; surface the error in the UI. |
| **SSR / SvelteKit** | Calls made in `load` functions run on the server; you can use `convex.query` there without worrying about the browser environment. |
| **Environment variables** | Prefix with `VITE_` for clientside access (`import.meta.env.VITE_CONVEX_URL`). |
| **Testing** | Use the Convex mock client (`createMockConvexClient`) provided by `@convex-dev/convex-svelte` for unit tests. |
---
## 10. Full Example (SvelteKit + Convex)
### 10.1 Backend (`convex/users.ts`)
```typescript
import { mutation, query } from './_generated/server';
import { v } from 'convex/values';
export const createUser = mutation({
args: { name: v.string() },
returns: v.id('users'),
handler: async (ctx, args) => {
return await ctx.db.insert('users', { name: args.name });
}
});
export const getUser = query({
args: { userId: v.id('users') },
returns: v.object({ name: v.string() }),
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) throw new Error('Not found');
return { name: user.name };
}
});
```
### 10.2 Frontend (`src/routes/+page.svelte`)
```svelte
<script lang="ts">
import { convex } from '$lib/convex';
import { api } from '$lib/convex/_generated/api';
import { onMount } from 'svelte';
let name = '';
let createdId: string | null = null;
let loading = false;
let error: string | null = null;
async function create() {
loading = true;
error = null;
try {
createdId = await convex.mutation(api.users.createUser, { name });
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
</script>
<input bind:value={name} placeholder="Your name" />
<button on:click={create} disabled={loading}>Create user</button>
{#if createdId}<p>Created user id: {createdId}</p>{/if}
{#if error}<p class="error">{error}</p>{/if}
```
---
## 11. Checklist for New Files
- ✅ All Convex functions use the **new syntax** (`query({ … })`).
- ✅ Every public function has **argument** and **return** validators.
- ✅ Svelte components import the generated `api` object from `convex/_generated/api`.
- ✅ All client calls use the `convex` instance from `$lib/convex`.
- ✅ Environment variable `VITE_CONVEX_URL` is defined in `.env`.
- ✅ Errors are caught and displayed in the UI.
- ✅ Types are imported from `convex/_generated/dataModel` when needed.
---
## 12. References
- Convex Docs [Functions](https://docs.convex.dev/functions)
- Convex Svelte SDK [`@convex-dev/convex-svelte`](https://github.com/convex-dev/convex-svelte)
- SvelteKit Docs [Loading Data](https://kit.svelte.dev/docs/loading)
---
_Keep these guidelines alongside the existing `svelte-rules.md` so that contributors have a single source of truth for both frontend and backend conventions._

View File

@@ -0,0 +1,69 @@
---
trigger: glob
description: Regras de tipagem para queries e mutations do Convex
globs: **/*.svelte.ts,**/*.svelte
---
# Regras de Tipagem do Convex
## Regra Principal
**NUNCA** crie anotações de tipo manuais para queries ou mutations do Convex. Os tipos já são inferidos automaticamente pelo Convex.
### ❌ Errado - Não faça isso:
```typescript
// NÃO crie tipos manuais para o retorno de queries
type Funcionario = {
_id: Id<'funcionarios'>;
nome: string;
email: string;
// ... outras propriedades
};
const funcionarios: Funcionario[] = useQuery(api.funcionarios.getAll) ?? [];
```
### ✅ Correto - Use inferência automática:
```typescript
// O tipo já vem inferido automaticamente
const funcionarios = useQuery(api.funcionarios.getAll);
```
---
## Quando Tipar É Necessário
Em situações onde você **realmente precisa** de um tipo explícito (ex: props de componentes, variáveis de estado, etc.), use `FunctionReturnType` para inferir o tipo:
```typescript
import { FunctionReturnType } from 'convex/server';
import { api } from '$convex/_generated/api';
// Infere o tipo de retorno da query
type FuncionariosQueryResult = FunctionReturnType<typeof api.funcionarios.getAll>;
// Agora pode usar em props de componentes
interface Props {
funcionarios: FuncionariosQueryResult;
}
```
### Casos de Uso Válidos para `FunctionReturnType`:
1. **Props de componentes** - quando um componente filho recebe dados de uma query
2. **Variáveis derivadas** - quando precisa tipar uma transformação dos dados
3. **Funções auxiliares** - quando cria funções que operam sobre os dados da query
4. **Stores/Estado global** - quando armazena dados em estado externo ao componente
---
## Resumo
| Situação | Abordagem |
| --------------------------- | ------------------------------------------------- |
| Usar `useQuery` diretamente | Deixe o tipo ser inferido automaticamente |
| Props de componentes | Use `FunctionReturnType<typeof api.module.query>` |
| Transformações de dados | Use `FunctionReturnType<typeof api.module.query>` |
| Anotações manuais de tipo | **NUNCA** - sempre infira do Convex |

27
.agent/rules/svelte.md Normal file
View File

@@ -0,0 +1,27 @@
---
trigger: always_on
---
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
## Available MCP Tools:
### 1. list-sections
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
### 2. get-documentation
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
### 3. svelte-autofixer
Analyzes Svelte code and returns problems and suggestions.
You MUST use this tool whenever you write Svelte code before submitting it to the user. Keep calling it until no problems or suggestions are returned. Remember that this does not eliminate all lint errors, so still keep checking for lint errors before proceeding.
### 4. playground-link
Generates a Svelte Playground link with the provided code.
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.

View File

@@ -14,6 +14,13 @@
"mcp", "mcp",
"start" "start"
] ]
},
"ark-ui": {
"command": "npx",
"args": [
"-y",
"@ark-ui/mcp"
]
} }
} }
} }

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = tab
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = true

36
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Build and Deploy Docker images
on:
push:
branches: ['master']
pull_request:
branches: ['master']
jobs:
build-and-push-dockerfile-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Log in to Docker Hub
# Only login if we are actually going to push
if: github.event_name == 'push'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./apps/web/Dockerfile
# Only push on 'push' event (merge to master), not on 'pull_request'
push: ${{ github.event_name == 'push' }}
tags: sgsedevs/sgse-app:latest
platforms: linux/amd64
build-args: |
PUBLIC_CONVEX_URL=${{ secrets.PUBLIC_CONVEX_URL }}
PUBLIC_CONVEX_SITE_URL=${{ secrets.PUBLIC_CONVEX_SITE_URL }}

2
.gitignore vendored
View File

@@ -49,3 +49,5 @@ coverage
tmp tmp
temp temp
.eslintcache .eslintcache
out

63
.vscode/settings.json vendored
View File

@@ -1,29 +1,38 @@
{ {
// "editor.formatOnSave": true, // "editor.formatOnSave": true,
// "editor.defaultFormatter": "biomejs.biome", // "editor.defaultFormatter": "biomejs.biome",
// "editor.codeActionsOnSave": { // "editor.codeActionsOnSave": {
// "source.fixAll.biome": "always" // "source.fixAll.biome": "always"
// }, // },
// "[typescript]": { // "[typescript]": {
// "editor.defaultFormatter": "biomejs.biome" // "editor.defaultFormatter": "biomejs.biome"
// }, // },
// "[svelte]": { // "[svelte]": {
// "editor.defaultFormatter": "biomejs.biome" // "editor.defaultFormatter": "biomejs.biome"
// }, // },
"eslint.useFlatConfig": true, "eslint.useFlatConfig": true,
"eslint.workingDirectories": [ "eslint.workingDirectories": [
{ "pattern": "apps/*" }, {
{ "pattern": "packages/*" } "pattern": "apps/*"
], },
"eslint.validate": [ {
"javascript", "pattern": "packages/*"
"javascriptreact", }
"typescript", ],
"typescriptreact", "eslint.validate": ["javascript", "typescript", "svelte"],
"svelte" "eslint.options": {
], "cache": true,
"eslint.options": { "cacheLocation": ".eslintcache"
"cache": true, },
"cacheLocation": ".eslintcache" "editor.formatOnSave": true,
} "[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[svelte]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.tabSize": 2
} }

68
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# Use the official Bun image
FROM oven/bun:1 AS base
# Set the working directory inside the container
WORKDIR /app
# ---
FROM base AS prepare
RUN bun add -g turbo@^2
COPY . .
RUN turbo prune web --docker
# ---
FROM base AS builder
# First install the dependencies (as they change less often)
COPY --from=prepare /app/out/json/ .
RUN bun install
# Build the project
COPY --from=prepare /app/out/full/ .
ARG PUBLIC_CONVEX_URL
ENV PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
ARG PUBLIC_CONVEX_SITE_URL
ENV PUBLIC_CONVEX_SITE_URL=$PUBLIC_CONVEX_SITE_URL
RUN bunx turbo build
# Production stage
FROM oven/bun:1-slim AS production
# Set working directory to match builder structure
WORKDIR /app
# Copy root node_modules (contains hoisted dependencies)
COPY --from=builder /app/node_modules ./node_modules
# Copy built application and workspace files
COPY --from=builder /app/apps/web/build ./apps/web/build
COPY --from=builder /app/apps/web/package.json ./apps/web/package.json
# Copy workspace node_modules (contains symlinks to root node_modules)
COPY --from=builder /app/apps/web/node_modules ./apps/web/node_modules
# Copy any additional files needed for runtime
COPY --from=builder /app/apps/web/static ./apps/web/static
# Switch to non-root user
USER sveltekit
# Set working directory to the app
WORKDIR /app/apps/web
# Expose the port that the app runs on
EXPOSE 5173
# Set environment variables
ENV NODE_ENV=production
ENV PORT=5173
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD bun --version || exit 1
# Start the application
CMD ["bun", "./build/index.js"]

View File

@@ -8,11 +8,7 @@
* @module * @module
*/ */
import type { import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server';
ApiFromModules,
FilterApi,
FunctionReference,
} from "convex/server";
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.
@@ -25,13 +21,10 @@ import type {
declare const fullApi: ApiFromModules<{}>; declare const fullApi: ApiFromModules<{}>;
declare const fullApiWithMounts: typeof fullApi; declare const fullApiWithMounts: typeof fullApi;
export declare const api: FilterApi< export declare const api: FilterApi<typeof fullApiWithMounts, FunctionReference<any, 'public'>>;
typeof fullApiWithMounts,
FunctionReference<any, "public">
>;
export declare const internal: FilterApi< export declare const internal: FilterApi<
typeof fullApiWithMounts, typeof fullApiWithMounts,
FunctionReference<any, "internal"> FunctionReference<any, 'internal'>
>; >;
export declare const components: {}; export declare const components: {};

View File

@@ -8,7 +8,7 @@
* @module * @module
*/ */
import { anyApi, componentsGeneric } from "convex/server"; import { anyApi, componentsGeneric } from 'convex/server';
/** /**
* A utility for referencing Convex functions in your app's API. * A utility for referencing Convex functions in your app's API.

View File

@@ -8,8 +8,8 @@
* @module * @module
*/ */
import { AnyDataModel } from "convex/server"; import { AnyDataModel } from 'convex/server';
import type { GenericId } from "convex/values"; import type { GenericId } from 'convex/values';
/** /**
* No `schema.ts` file found! * No `schema.ts` file found!
@@ -43,8 +43,7 @@ export type Doc = any;
* IDs are just strings at runtime, but this type can be used to distinguish them from other * IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking. * strings when type checking.
*/ */
export type Id<TableName extends TableNames = TableNames> = export type Id<TableName extends TableNames = TableNames> = GenericId<TableName>;
GenericId<TableName>;
/** /**
* A type describing your Convex data model. * A type describing your Convex data model.

View File

@@ -9,24 +9,24 @@
*/ */
import { import {
ActionBuilder, ActionBuilder,
AnyComponents, AnyComponents,
HttpActionBuilder, HttpActionBuilder,
MutationBuilder, MutationBuilder,
QueryBuilder, QueryBuilder,
GenericActionCtx, GenericActionCtx,
GenericMutationCtx, GenericMutationCtx,
GenericQueryCtx, GenericQueryCtx,
GenericDatabaseReader, GenericDatabaseReader,
GenericDatabaseWriter, GenericDatabaseWriter,
FunctionReference, FunctionReference
} from "convex/server"; } from 'convex/server';
import type { DataModel } from "./dataModel.js"; import type { DataModel } from './dataModel.js';
type GenericCtx = type GenericCtx =
| GenericActionCtx<DataModel> | GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel> | GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>; | GenericQueryCtx<DataModel>;
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.
@@ -36,7 +36,7 @@ type GenericCtx =
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const query: QueryBuilder<DataModel, "public">; export declare const query: QueryBuilder<DataModel, 'public'>;
/** /**
* Define a query that is only accessible from other Convex functions (but not from the client). * Define a query that is only accessible from other Convex functions (but not from the client).
@@ -46,7 +46,7 @@ export declare const query: QueryBuilder<DataModel, "public">;
* @param func - The query function. It receives a {@link QueryCtx} as its first argument. * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
* @returns The wrapped query. Include this as an `export` to name it and make it accessible. * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalQuery: QueryBuilder<DataModel, "internal">; export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
/** /**
* Define a mutation in this Convex app's public API. * Define a mutation in this Convex app's public API.
@@ -56,7 +56,7 @@ export declare const internalQuery: QueryBuilder<DataModel, "internal">;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const mutation: MutationBuilder<DataModel, "public">; export declare const mutation: MutationBuilder<DataModel, 'public'>;
/** /**
* Define a mutation that is only accessible from other Convex functions (but not from the client). * Define a mutation that is only accessible from other Convex functions (but not from the client).
@@ -66,7 +66,7 @@ export declare const mutation: MutationBuilder<DataModel, "public">;
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalMutation: MutationBuilder<DataModel, "internal">; export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
/** /**
* Define an action in this Convex app's public API. * Define an action in this Convex app's public API.
@@ -79,7 +79,7 @@ export declare const internalMutation: MutationBuilder<DataModel, "internal">;
* @param func - The action. It receives an {@link ActionCtx} as its first argument. * @param func - The action. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped action. Include this as an `export` to name it and make it accessible. * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
*/ */
export declare const action: ActionBuilder<DataModel, "public">; export declare const action: ActionBuilder<DataModel, 'public'>;
/** /**
* Define an action that is only accessible from other Convex functions (but not from the client). * Define an action that is only accessible from other Convex functions (but not from the client).
@@ -87,7 +87,7 @@ export declare const action: ActionBuilder<DataModel, "public">;
* @param func - The function. It receives an {@link ActionCtx} as its first argument. * @param func - The function. It receives an {@link ActionCtx} as its first argument.
* @returns The wrapped function. Include this as an `export` to name it and make it accessible. * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
*/ */
export declare const internalAction: ActionBuilder<DataModel, "internal">; export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
/** /**
* Define an HTTP action. * Define an HTTP action.

View File

@@ -9,15 +9,15 @@
*/ */
import { import {
actionGeneric, actionGeneric,
httpActionGeneric, httpActionGeneric,
queryGeneric, queryGeneric,
mutationGeneric, mutationGeneric,
internalActionGeneric, internalActionGeneric,
internalMutationGeneric, internalMutationGeneric,
internalQueryGeneric, internalQueryGeneric,
componentsGeneric, componentsGeneric
} from "convex/server"; } from 'convex/server';
/** /**
* Define a query in this Convex app's public API. * Define a query in this Convex app's public API.

View File

@@ -1,28 +1,28 @@
import { config as svelteConfigBase } from '@sgse-app/eslint-config/svelte'; import { config as svelteConfigBase } from '@sgse-app/eslint-config/svelte';
import svelteConfig from './svelte.config.js'; import { defineConfig } from 'eslint/config';
import ts from 'typescript-eslint'; import ts from 'typescript-eslint';
import { defineConfig } from "eslint/config"; import svelteConfig from './svelte.config.js';
/** @type {import("eslint").Linter.Config} */ /** @type {import("eslint").Linter.Config} */
export default defineConfig([ export default defineConfig([
...svelteConfigBase, ...svelteConfigBase,
{ {
files: ['**/*.svelte'], files: ['**/*.svelte'],
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
parser: ts.parser, parser: ts.parser,
extraFileExtensions: ['.svelte'], extraFileExtensions: ['.svelte'],
svelteConfig svelteConfig
} }
} }
}, },
{ {
ignores: [ ignores: [
'**/node_modules/**', '**/node_modules/**',
'**/.svelte-kit/**', '**/.svelte-kit/**',
'**/build/**', '**/build/**',
'**/dist/**', '**/dist/**',
'**/.turbo/**' '**/.turbo/**'
] ]
} }
]) ]);

View File

@@ -1,58 +1,70 @@
{ {
"name": "web", "name": "web",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "bunx --bun vite dev",
"build": "vite build", "build": "bunx --bun vite build",
"preview": "vite preview", "preview": "bunx --bun vite preview",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
}, "lint": "eslint .",
"devDependencies": { "format": "prettier --write ."
"@sgse-app/eslint-config": "*", },
"@sveltejs/adapter-auto": "^6.1.0", "devDependencies": {
"@sveltejs/kit": "^2.31.1", "@sgse-app/eslint-config": "*",
"@sveltejs/vite-plugin-svelte": "^6.1.2", "@sveltejs/adapter-auto": "^6.1.0",
"@tailwindcss/vite": "^4.1.12", "@sveltejs/kit": "^2.31.1",
"autoprefixer": "^10.4.21", "@sveltejs/vite-plugin-svelte": "^6.1.2",
"daisyui": "^5.3.8", "@tailwindcss/vite": "^4.1.12",
"esbuild": "^0.25.11", "autoprefixer": "^10.4.21",
"postcss": "^8.5.6", "daisyui": "^5.3.8",
"svelte": "^5.38.1", "esbuild": "^0.25.11",
"svelte-check": "^4.3.1", "postcss": "^8.5.6",
"tailwindcss": "^4.1.12", "svelte": "^5.38.1",
"typescript": "catalog:", "svelte-adapter-bun": "^1.0.1",
"vite": "^7.1.2" "svelte-check": "^4.3.1",
}, "svelte-dnd-action": "^0.9.67",
"dependencies": { "tailwindcss": "^4.1.12",
"eslint": "catalog:", "typescript": "catalog:",
"@convex-dev/better-auth": "^0.9.7", "vite": "^7.1.2"
"@dicebear/collection": "^9.2.4", },
"@dicebear/core": "^9.2.4", "dependencies": {
"@fullcalendar/core": "^6.1.19", "@ark-ui/svelte": "^5.15.0",
"@fullcalendar/daygrid": "^6.1.19", "@convex-dev/better-auth": "^0.9.7",
"@fullcalendar/interaction": "^6.1.19", "@dicebear/collection": "^9.2.4",
"@fullcalendar/list": "^6.1.19", "@dicebear/core": "^9.2.4",
"@fullcalendar/multimonth": "^6.1.19", "@fullcalendar/core": "^6.1.19",
"@internationalized/date": "^3.10.0", "@fullcalendar/daygrid": "^6.1.19",
"@mmailaender/convex-better-auth-svelte": "^0.2.0", "@fullcalendar/interaction": "^6.1.19",
"@sgse-app/backend": "*", "@fullcalendar/list": "^6.1.19",
"@tanstack/svelte-form": "^1.19.2", "@fullcalendar/multimonth": "^6.1.19",
"@types/papaparse": "^5.3.14", "@internationalized/date": "^3.10.0",
"better-auth": "catalog:", "@mmailaender/convex-better-auth-svelte": "^0.2.0",
"convex": "catalog:", "@sgse-app/backend": "*",
"convex-svelte": "^0.0.12", "@tanstack/svelte-form": "^1.19.2",
"date-fns": "^4.1.0", "@types/papaparse": "^5.3.14",
"emoji-picker-element": "^1.27.0", "better-auth": "catalog:",
"is-network-error": "^1.3.0", "convex": "catalog:",
"jspdf": "^3.0.3", "convex-svelte": "^0.0.12",
"jspdf-autotable": "^5.0.2", "date-fns": "^4.1.0",
"lucide-svelte": "^0.552.0", "emoji-picker-element": "^1.27.0",
"papaparse": "^5.4.1", "eslint": "catalog:",
"svelte-sonner": "^1.0.5", "exceljs": "^4.4.0",
"zod": "^4.1.12" "html5-qrcode": "^2.3.8",
} "is-network-error": "^1.3.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"lib-jitsi-meet": "^1.0.6",
"lucide-svelte": "^0.552.0",
"marked": "^17.0.1",
"papaparse": "^5.4.1",
"svelte-sonner": "^1.0.5",
"theme-change": "^2.5.0",
"xlsx": "^0.18.5",
"xlsx-js-style": "^1.2.0",
"zod": "^4.1.12"
}
} }

View File

@@ -1,77 +1,368 @@
@import "tailwindcss"; @import 'tailwindcss';
@plugin "daisyui"; @plugin 'daisyui';
/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */ /* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */
/* Estilo padrão dos botões - mesmo estilo do sidebar */ /* Estilo padrão dos botões - mesmo estilo do sidebar */
.btn-standard { .btn-standard {
@apply font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors; @apply border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content flex items-center justify-center gap-2 rounded-xl border p-3 text-center font-medium transition-colors hover:text-white active:text-white;
} }
/* Sobrescrever estilos DaisyUI para seguir o padrão */ /* Sobrescrever estilos DaisyUI para seguir o padrão */
.btn-primary { .btn-primary {
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors; @apply border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors hover:text-white active:text-white;
} }
.btn-ghost { .btn-ghost {
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-base-300 bg-base-100 hover:bg-base-200 active:bg-base-300 text-base-content transition-colors; @apply border-base-300 bg-base-100 hover:bg-base-200 active:bg-base-300 text-base-content flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors;
} }
.btn-error { .btn-error {
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-error bg-base-100 hover:bg-error/60 active:bg-error text-error hover:text-white active:text-white transition-colors; @apply border-error bg-base-100 hover:bg-error/60 active:bg-error text-error flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors hover:text-white active:text-white;
} }
:where(.card, .card-hover) { /* Tema Aqua (padrão roxo/azul) - redefinido como custom para garantir compatibilidade */
position: relative; @plugin 'daisyui/theme' {
overflow: hidden; name: 'aqua';
transform: translateY(0); default: true;
transition: transform 220ms ease, box-shadow 220ms ease; color-scheme: light;
/* Azul principal (ligeiramente mais escuro que o anterior) */
--color-primary: hsl(217 91% 55%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(217 91% 55%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(217 91% 55%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(217 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(217 20% 95%);
--color-base-300: hsl(217 20% 90%);
--color-base-content: hsl(217 20% 17%);
--color-info: hsl(217 91% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }
:where(.card, .card-hover)::before { /* Temas customizados para SGSE */
content: "";
position: absolute; /* Azul */
inset: -2px; @plugin 'daisyui/theme' {
border-radius: 1.15rem; name: 'sgse-blue';
box-shadow: color-scheme: light;
0 0 0 1px rgba(15, 23, 42, 0.04), --color-primary: hsl(217 91% 55%);
0 14px 32px -22px rgba(15, 23, 42, 0.45), --color-primary-content: hsl(0 0% 100%);
0 6px 18px -16px rgba(102, 126, 234, 0.35); --color-secondary: hsl(217 91% 55%);
opacity: 0.55; --color-secondary-content: hsl(0 0% 100%);
transition: opacity 220ms ease, transform 220ms ease; --color-accent: hsl(217 91% 55%);
pointer-events: none; --color-accent-content: hsl(0 0% 100%);
z-index: 0; --color-neutral: hsl(217 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(217 20% 95%);
--color-base-300: hsl(217 20% 90%);
--color-base-content: hsl(217 20% 17%);
--color-info: hsl(217 91% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }
:where(.card, .card-hover)::after { /* Verde */
content: ""; @plugin 'daisyui/theme' {
position: absolute; name: 'sgse-green';
inset: 0; color-scheme: light;
border-radius: 1rem; --color-primary: hsl(142 76% 36%);
background: linear-gradient(135deg, rgba(102, 126, 234, 0.12), rgba(118, 75, 162, 0.12)); --color-primary-content: hsl(0 0% 100%);
opacity: 0; --color-secondary: hsl(142 76% 36%);
transform: scale(0.96); --color-secondary-content: hsl(0 0% 100%);
transition: opacity 220ms ease, transform 220ms ease; --color-accent: hsl(142 76% 36%);
pointer-events: none; --color-accent-content: hsl(0 0% 100%);
z-index: 1; --color-neutral: hsl(142 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(142 20% 95%);
--color-base-300: hsl(142 20% 90%);
--color-base-content: hsl(142 20% 17%);
--color-info: hsl(142 76% 36%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }
:where(.card, .card-hover):hover { /* Laranja */
transform: translateY(-6px); @plugin 'daisyui/theme' {
box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.35); name: 'sgse-orange';
color-scheme: light;
--color-primary: hsl(25 95% 53%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(25 95% 53%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(25 95% 53%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(25 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(25 20% 95%);
--color-base-300: hsl(25 20% 90%);
--color-base-content: hsl(25 20% 17%);
--color-info: hsl(25 95% 53%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }
:where(.card, .card-hover):hover::before { /* Vermelho */
opacity: 0.9; @plugin 'daisyui/theme' {
transform: scale(1); name: 'sgse-red';
color-scheme: light;
--color-primary: hsl(0 84% 60%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(0 84% 60%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(0 84% 60%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(0 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(0 20% 95%);
--color-base-300: hsl(0 20% 90%);
--color-base-content: hsl(0 20% 17%);
--color-info: hsl(0 84% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }
:where(.card, .card-hover):hover::after { /* Rosa */
opacity: 1; @plugin 'daisyui/theme' {
transform: scale(1); name: 'sgse-pink';
color-scheme: light;
--color-primary: hsl(330 81% 60%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(330 81% 60%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(330 81% 60%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(330 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(330 20% 95%);
--color-base-300: hsl(330 20% 90%);
--color-base-content: hsl(330 20% 17%);
--color-info: hsl(330 81% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }
:where(.card, .card-hover) > * { /* Teal */
position: relative; @plugin 'daisyui/theme' {
z-index: 2; name: 'sgse-teal';
color-scheme: light;
--color-primary: hsl(173 80% 40%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(173 80% 40%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(173 80% 40%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(173 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(173 20% 95%);
--color-base-300: hsl(173 20% 90%);
--color-base-content: hsl(173 20% 17%);
--color-info: hsl(173 80% 40%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* Corporativo (Dark Blue) */
@plugin 'daisyui/theme' {
name: 'sgse-corporate';
color-scheme: dark;
--color-primary: hsl(217 91% 55%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(217 91% 55%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(217 91% 55%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(217 30% 15%);
--color-neutral-content: hsl(0 0% 100%);
/* Aproxima do fundo do login (Tailwind slate-900 = #0f172a) */
--color-base-100: hsl(222 47% 11%);
/* Escala de contraste (slate-800 / slate-700 aproximados) */
--color-base-200: hsl(215 28% 17%);
--color-base-300: hsl(215 25% 23%);
--color-base-content: hsl(217 10% 90%);
--color-info: hsl(217 91% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* Light */
@plugin 'daisyui/theme' {
name: 'light';
color-scheme: light;
--color-primary: hsl(217 91% 55%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(217 91% 55%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(217 91% 55%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(217 20% 17%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(0 0% 100%);
--color-base-200: hsl(217 20% 95%);
--color-base-300: hsl(217 20% 90%);
--color-base-content: hsl(217 20% 17%);
--color-info: hsl(217 91% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
}
/* Dark */
@plugin 'daisyui/theme' {
name: 'dark';
color-scheme: dark;
--color-primary: hsl(217 91% 55%);
--color-primary-content: hsl(0 0% 100%);
--color-secondary: hsl(217 91% 55%);
--color-secondary-content: hsl(0 0% 100%);
--color-accent: hsl(217 91% 55%);
--color-accent-content: hsl(0 0% 100%);
--color-neutral: hsl(217 30% 15%);
--color-neutral-content: hsl(0 0% 100%);
--color-base-100: hsl(217 30% 10%);
--color-base-200: hsl(217 30% 15%);
--color-base-300: hsl(217 30% 20%);
--color-base-content: hsl(217 10% 90%);
--color-info: hsl(217 91% 60%);
--color-info-content: hsl(0 0% 100%);
--color-success: hsl(142 76% 36%);
--color-success-content: hsl(0 0% 100%);
--color-warning: hsl(38 92% 50%);
--color-warning-content: hsl(0 0% 100%);
--color-error: hsl(0 84% 60%);
--color-error-content: hsl(0 0% 100%);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 1rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 1;
--noise: 0;
} }

10
apps/web/src/app.d.ts vendored
View File

@@ -1,9 +1,9 @@
declare global { declare global {
namespace App { namespace App {
interface Locals { interface Locals {
token: string | undefined; token: string | undefined;
} }
} }
} }
export {}; export {};

View File

@@ -1,10 +1,131 @@
<!doctype html> <!doctype html>
<html lang="en" data-theme="aqua"> <html lang="en" id="html-theme">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
<!-- Polyfill BlobBuilder ANTES de qualquer código JavaScript -->
<!-- IMPORTANTE: Este script DEVE ser executado antes de qualquer módulo JavaScript -->
<script>
// Executar IMEDIATAMENTE, de forma síncrona e bloqueante
// Não usar IIFE assíncrona, executar direto no escopo global
(function () {
'use strict';
// Implementar BlobBuilder usando Blob moderno
function BlobBuilderPolyfill() {
if (!(this instanceof BlobBuilderPolyfill)) {
return new BlobBuilderPolyfill();
}
this.parts = [];
}
BlobBuilderPolyfill.prototype.append = function (data) {
if (data instanceof Blob) {
this.parts.push(data);
} else if (typeof data === 'string') {
this.parts.push(data);
} else {
this.parts.push(new Blob([data]));
}
};
BlobBuilderPolyfill.prototype.getBlob = function (contentType) {
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
};
// Função para aplicar o polyfill em todos os contextos possíveis
function aplicarPolyfillBlobBuilder() {
// Aplicar no window (se disponível)
if (typeof window !== 'undefined') {
if (!window.BlobBuilder) {
window.BlobBuilder = BlobBuilderPolyfill;
}
if (!window.WebKitBlobBuilder) {
window.WebKitBlobBuilder = BlobBuilderPolyfill;
}
if (!window.MozBlobBuilder) {
window.MozBlobBuilder = BlobBuilderPolyfill;
}
if (!window.MSBlobBuilder) {
window.MSBlobBuilder = BlobBuilderPolyfill;
}
}
// Aplicar no globalThis (se disponível)
if (typeof globalThis !== 'undefined') {
if (!globalThis.BlobBuilder) {
globalThis.BlobBuilder = BlobBuilderPolyfill;
}
if (!globalThis.WebKitBlobBuilder) {
globalThis.WebKitBlobBuilder = BlobBuilderPolyfill;
}
if (!globalThis.MozBlobBuilder) {
globalThis.MozBlobBuilder = BlobBuilderPolyfill;
}
}
// Aplicar no self (para workers)
if (typeof self !== 'undefined') {
if (!self.BlobBuilder) {
self.BlobBuilder = BlobBuilderPolyfill;
}
if (!self.WebKitBlobBuilder) {
self.WebKitBlobBuilder = BlobBuilderPolyfill;
}
if (!self.MozBlobBuilder) {
self.MozBlobBuilder = BlobBuilderPolyfill;
}
}
// Aplicar no global (Node.js)
if (typeof global !== 'undefined') {
if (!global.BlobBuilder) {
global.BlobBuilder = BlobBuilderPolyfill;
}
if (!global.WebKitBlobBuilder) {
global.WebKitBlobBuilder = BlobBuilderPolyfill;
}
if (!global.MozBlobBuilder) {
global.MozBlobBuilder = BlobBuilderPolyfill;
}
}
}
// Aplicar imediatamente
aplicarPolyfillBlobBuilder();
// Aplicar também quando o DOM estiver pronto (caso window não esteja disponível ainda)
if (typeof document !== 'undefined' && document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', aplicarPolyfillBlobBuilder, { once: true });
}
// Log apenas se console está disponível
if (typeof console !== 'undefined' && console.log) {
console.log('✅ Polyfill BlobBuilder adicionado globalmente (via app.html)');
}
})();
// Aplicar tema padrão imediatamente se não houver tema definido
(function () {
if (typeof document !== 'undefined') {
var html = document.documentElement;
if (html && !html.getAttribute('data-theme')) {
var tema = null;
try {
// theme-change usa por padrão a chave "theme"
tema = localStorage.getItem('theme');
} catch (e) {
tema = null;
}
// Fallback para o tema padrão se não houver persistência
html.setAttribute('data-theme', tema || 'aqua');
}
}
})();
</script>
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>

View File

@@ -1,9 +1,177 @@
import type { Handle } from "@sveltejs/kit"; import type { Handle, HandleServerError } from '@sveltejs/kit';
import { createAuth } from "@sgse-app/backend/convex/auth"; import { createAuth } from '@sgse-app/backend/convex/auth';
import { getToken } from "@mmailaender/convex-better-auth-svelte/sveltekit"; import { getToken, createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
event.locals.token = await getToken(createAuth, event.cookies); event.locals.token = await getToken(createAuth, event.cookies);
return resolve(event); // Enforcement para endpoints sensíveis (antes de chegar nas rotas)
// - Foco: /api/auth/* (login, logout, etc.)
// - Aplica blacklist + rate limit configuráveis via Convex
const pathname = event.url.pathname;
if (pathname.startsWith('/api/auth/')) {
const token = event.locals.token;
const client = createConvexHttpClient({ token: token || undefined });
// Preferir X-Forwarded-For quando existir (proxy), senão fallback do adapter
const forwardedFor = event.request.headers.get('x-forwarded-for');
const ip =
forwardedFor?.split(',')[0]?.trim() ||
event.request.headers.get('x-real-ip') ||
event.getClientAddress();
try {
// 1) Enforcement básico (blacklist + rate limit)
const enforcement = await client.mutation(api.security.enforceRequest, {
ip,
path: pathname,
method: event.request.method
});
if (!enforcement.allowed) {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (enforcement.retryAfterMs) {
headers.set('Retry-After', String(Math.ceil(enforcement.retryAfterMs / 1000)));
}
return new Response(JSON.stringify(enforcement), { status: enforcement.status, headers });
}
// 2) Análise de ataques e bloqueio automático
// Extrair dados da requisição para análise
const headers: Record<string, string> = {};
event.request.headers.forEach((value, key) => {
headers[key] = value;
});
const queryParams: Record<string, string> = {};
event.url.searchParams.forEach((value, key) => {
queryParams[key] = value;
});
let body: string | undefined;
try {
// Tentar ler body apenas se for POST/PUT/PATCH
if (['POST', 'PUT', 'PATCH'].includes(event.request.method)) {
const clonedRequest = event.request.clone();
body = await clonedRequest.text();
}
} catch {
// Ignorar erros ao ler body
}
const analise = await client.mutation(api.security.analisarRequisicaoHTTP, {
url: pathname + event.url.search,
method: event.request.method,
headers,
body,
queryParams,
ipOrigem: ip,
userAgent: event.request.headers.get('user-agent') ?? undefined
});
// Se ataque detectado e bloqueio automático aplicado, retornar 403
if (analise.ataqueDetectado && analise.bloqueadoAutomatico) {
return new Response(
JSON.stringify({
error: 'Acesso negado',
reason: 'ataque_detectado',
tipoAtaque: analise.tipoAtaque,
severidade: analise.severidade
}),
{
status: 403,
headers: { 'Content-Type': 'application/json' }
}
);
}
} catch (err) {
// Se o enforcement falhar, não bloquear login (fail-open),
// mas registrar erro para observabilidade via handleError (se ocorrer)
console.error('❌ Falha no enforcement de segurança:', err);
}
}
return resolve(event);
};
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
// Notificar erros 404 e 500+ (erros internos do servidor)
if (status === 404 || status === 500 || status >= 500) {
// Evitar loop infinito: não registrar erros relacionados à própria página de erros
const urlPath = event.url.pathname;
if (urlPath.includes('/ti/erros-servidor')) {
console.warn(
`⚠️ Erro na página de erros do servidor (${status}): Não será registrado para evitar loop.`
);
} else {
try {
// Obter token do usuário (se autenticado)
const token = event.locals.token;
// Criar cliente Convex para chamar a action
const client = createConvexHttpClient({
token: token || undefined
});
// Extrair informações do erro
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
const url = event.url.toString();
const method = event.request.method;
const ipAddress = event.getClientAddress();
const userAgent = event.request.headers.get('user-agent') || undefined;
// Log para debug
console.log(`📝 Registrando erro ${status} no servidor:`, {
url,
method,
mensagem: errorMessage.substring(0, 100)
});
// Chamar action para registrar e notificar erro
// Aguardar a promise mas não bloquear a resposta se falhar
try {
// Usar Promise.race com timeout para evitar bloquear a resposta
const actionPromise = client.action(api.errosServidor.registrarErroServidor, {
statusCode: status,
mensagem: errorMessage,
stack: errorStack,
url,
method,
ipAddress,
userAgent,
usuarioId: undefined // Pode ser implementado depois para obter do token
});
// Timeout de 3 segundos
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timeout ao registrar erro')), 3000);
});
const resultado = await Promise.race([actionPromise, timeoutPromise]);
console.log(`✅ Erro ${status} registrado com sucesso:`, resultado);
} catch (actionError) {
// Log do erro de notificação, mas não falhar a resposta
console.error(
`❌ Erro ao registrar notificação de erro ${status}:`,
actionError instanceof Error ? actionError.message : actionError
);
}
} catch (err) {
// Se falhar ao criar cliente ou chamar action, apenas logar
// Não queremos que falhas na notificação quebrem a resposta de erro
console.error(
`❌ Erro ao tentar notificar equipe técnica sobre erro ${status}:`,
err instanceof Error ? err.message : err
);
}
}
}
// Retornar mensagem de erro padrão
return {
message: message || 'Erro interno do servidor',
status
};
}; };

View File

@@ -5,9 +5,14 @@
* Este cliente será usado para autenticação quando Better Auth estiver ativo. * Este cliente será usado para autenticação quando Better Auth estiver ativo.
*/ */
import { createAuthClient } from "better-auth/svelte"; import { createAuthClient } from 'better-auth/svelte';
import { convexClient } from "@convex-dev/better-auth/client/plugins"; import { convexClient } from '@convex-dev/better-auth/client/plugins';
// O baseURL deve apontar para o frontend (SvelteKit), não para o Convex diretamente
// O Better Auth usa as rotas HTTP do Convex que são acessadas via proxy do SvelteKit
// ou diretamente se configurado. Com o plugin convexClient, o token é gerenciado automaticamente.
export const authClient = createAuthClient({ export const authClient = createAuthClient({
plugins: [convexClient()], // baseURL padrão é window.location.origin, que é o correto para SvelteKit
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts
plugins: [convexClient()]
}); });

View File

@@ -1,73 +0,0 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { loginModalStore } from "$lib/stores/loginModal.svelte";
import { TriangleAlert } from "lucide-svelte";
interface Props {
recurso: string;
acao: string;
children?: any;
}
let { recurso, acao, children }: Props = $props();
let verificando = $state(true);
let permitido = $state(false);
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
const permissaoQuery = $derived(
currentUser?.data
? useQuery(api.permissoesAcoes.verificarAcao, {
usuarioId: currentUser.data._id as Id<"usuarios">,
recurso,
acao,
})
: null,
);
$effect(() => {
if (!currentUser?.data) {
verificando = false;
permitido = false;
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
return;
}
if (permissaoQuery?.error) {
verificando = false;
permitido = false;
} else if (permissaoQuery && !permissaoQuery.isLoading) {
// Backend retorna null quando permitido
verificando = false;
permitido = true;
}
});
</script>
{#if verificando}
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
</div>
</div>
{:else if permitido}
{@render children?.()}
{:else}
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<div class="p-4 bg-error/10 rounded-full inline-block mb-4">
<TriangleAlert class="h-16 w-16 text-error" strokeWidth={2} />
</div>
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Negado</h2>
<p class="text-base-content/70">
Você não tem permissão para acessar esta ação.
</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { Info, X } from 'lucide-svelte';
interface Props {
open: boolean;
title?: string;
message: string;
buttonText?: string;
onClose: () => void;
}
let {
open = $bindable(false),
title = 'Atenção',
message,
buttonText = 'OK',
onClose
}: Props = $props();
function handleClose() {
open = false;
onClose();
}
</script>
{#if open}
<div
class="pointer-events-none fixed inset-0 z-[9999]"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-alert-title"
>
<!-- Backdrop -->
<div
class="pointer-events-auto absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-200"
onclick={handleClose}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="border-base-300 from-info/10 to-info/5 flex flex-shrink-0 items-center justify-between border-b bg-linear-to-r px-6 py-4"
>
<h2 id="modal-alert-title" class="text-info flex items-center gap-2 text-xl font-bold">
<Info class="h-6 w-6" strokeWidth={2.5} />
{title}
</h2>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleClose}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<p class="text-base-content text-base leading-relaxed">{message}</p>
</div>
<!-- Footer -->
<div class="border-base-300 flex flex-shrink-0 justify-end border-t px-6 py-4">
<button class="btn btn-primary" onclick={handleClose}>{buttonText}</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -40%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Scrollbar customizada */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } from 'convex-svelte';
import { XCircle, AlertTriangle, X, Clock } from 'lucide-svelte';
type PeriodoFerias = Doc<'ferias'> & { type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null; funcionario?: Doc<'funcionarios'> | null;
@@ -16,7 +17,7 @@
onCancelar?: () => void; onCancelar?: () => void;
} }
let { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props(); const { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
@@ -29,7 +30,8 @@
aprovado: 'badge-success', aprovado: 'badge-success',
reprovado: 'badge-error', reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info', data_ajustada_aprovada: 'badge-info',
EmFérias: 'badge-info' EmFérias: 'badge-info',
Cancelado_RH: 'badge-error'
}; };
return badges[status] || 'badge-neutral'; return badges[status] || 'badge-neutral';
} }
@@ -40,19 +42,20 @@
aprovado: 'Aprovado', aprovado: 'Aprovado',
reprovado: 'Reprovado', reprovado: 'Reprovado',
data_ajustada_aprovada: 'Data Ajustada e Aprovada', data_ajustada_aprovada: 'Data Ajustada e Aprovada',
EmFérias: 'Em Férias' EmFérias: 'Em Férias',
Cancelado_RH: 'Cancelado RH'
}; };
return textos[status] || status; return textos[status] || status;
} }
async function voltarParaAguardando() { async function cancelarPorRH() {
try { try {
processando = true; processando = true;
erro = ''; erro = '';
await client.mutation(api.ferias.atualizarStatus, { await client.mutation(api.ferias.atualizarStatus, {
feriasId: solicitacao._id, feriasId: solicitacao._id,
novoStatus: 'aguardando_aprovacao', novoStatus: 'Cancelado_RH',
usuarioId: usuarioId usuarioId: usuarioId
}); });
@@ -127,20 +130,7 @@
<div class="space-y-1"> <div class="space-y-1">
{#each solicitacao.historicoAlteracoes as hist (hist.data)} {#each solicitacao.historicoAlteracoes as hist (hist.data)}
<div class="text-base-content/70 flex items-center gap-2 text-xs"> <div class="text-base-content/70 flex items-center gap-2 text-xs">
<svg <Clock class="h-3 w-3" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{formatarData(hist.data)}</span> <span>{formatarData(hist.data)}</span>
<span>-</span> <span>-</span>
<span>{hist.acao}</span> <span>{hist.acao}</span>
@@ -150,28 +140,16 @@
</div> </div>
{/if} {/if}
<!-- Ação: Voltar para Aguardando Aprovação --> <!-- Ação: Cancelar por RH -->
{#if solicitacao.status !== 'aguardando_aprovacao'} {#if solicitacao.status !== 'Cancelado_RH'}
<div class="divider mt-6"></div> <div class="divider mt-6"></div>
<div class="alert alert-info"> <div class="alert alert-warning">
<svg <AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div> <div>
<h3 class="font-bold">Alterar Status</h3> <h3 class="font-bold">Cancelar Férias</h3>
<div class="text-sm"> <div class="text-sm">
Ao voltar para "Aguardando Aprovação", a solicitação ficará disponível para aprovação ou Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não
reprovação pelo gestor. poderá mais ser processada.
</div> </div>
</div> </div>
</div> </div>
@@ -179,63 +157,26 @@
<div class="card-actions mt-4 justify-end"> <div class="card-actions mt-4 justify-end">
<button <button
type="button" type="button"
class="btn btn-warning gap-2" class="btn btn-error gap-2"
onclick={voltarParaAguardando} onclick={cancelarPorRH}
disabled={processando} disabled={processando}
> >
<svg <X class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg" Cancelar Férias (RH)
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
Voltar para Aguardando Aprovação
</button> </button>
</div> </div>
{:else} {:else}
<div class="divider mt-6"></div> <div class="divider mt-6"></div>
<div class="alert"> <div class="alert alert-error">
<svg <XCircle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg" <span>Esta solicitação já foi cancelada pelo RH.</span>
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Esta solicitação já está aguardando aprovação.</span>
</div> </div>
{/if} {/if}
<!-- Motivo Reprovação (se reprovado) --> <!-- Motivo Reprovação (se reprovado) -->
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao} {#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<svg <XCircle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div> <div>
<div class="font-bold">Motivo da Reprovação:</div> <div class="font-bold">Motivo da Reprovação:</div>
<div class="text-sm">{solicitacao.motivoReprovacao}</div> <div class="text-sm">{solicitacao.motivoReprovacao}</div>
@@ -246,19 +187,7 @@
<!-- Erro --> <!-- Erro -->
{#if erro} {#if erro}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<svg <XCircle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{erro}</span> <span>{erro}</span>
</div> </div>
{/if} {/if}

View File

@@ -0,0 +1,16 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<div class={['absolute inset-0 h-full w-full', className]}>
<div
class="bg-primary/20 absolute top-[-10%] left-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px]"
></div>
<div
class="bg-secondary/20 absolute right-[-10%] bottom-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px] delay-700"
></div>
</div>

View File

@@ -1,8 +1,11 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } from 'convex-svelte';
import ErrorModal from './ErrorModal.svelte'; import ErrorModal from './ErrorModal.svelte';
import UserAvatar from './chat/UserAvatar.svelte';
import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte';
import { parseLocalDate } from '$lib/utils/datas';
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & { type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
funcionario?: Doc<'funcionarios'> | null; funcionario?: Doc<'funcionarios'> | null;
@@ -17,7 +20,7 @@
onCancelar?: () => void; onCancelar?: () => void;
} }
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props(); const { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
@@ -28,13 +31,13 @@
let mensagemErroModal = $state(''); let mensagemErroModal = $state('');
function calcularDias(dataInicio: string, dataFim: string): number { function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio); const inicio = parseLocalDate(dataInicio);
const fim = new Date(dataFim); const fim = parseLocalDate(dataFim);
const diff = fim.getTime() - inicio.getTime(); const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1; return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
} }
const totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim)); let totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim));
async function aprovar() { async function aprovar() {
try { try {
@@ -132,47 +135,48 @@
<div class="aprovar-ausencia"> <div class="aprovar-ausencia">
<!-- Header --> <!-- Header -->
<div class="mb-6"> <div class="mb-4">
<h2 class="text-primary mb-2 text-3xl font-bold">Aprovar/Reprovar Ausência</h2> <h2 class="text-primary mb-1 text-2xl font-bold">Aprovar/Reprovar Ausência</h2>
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p> <p class="text-base-content/70 text-sm">Analise a solicitação e tome uma decisão</p>
</div> </div>
<!-- Card Principal --> <!-- Card Principal -->
<div class="card bg-base-100 border-t-4 border-orange-500 shadow-2xl"> <div class="card bg-base-100 border-primary border-t-4 shadow-2xl">
<div class="card-body"> <div class="card-body p-4 md:p-6">
<!-- Informações do Funcionário --> <!-- Informações do Funcionário -->
<div class="mb-6"> <div class="mb-4">
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold"> <h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<svg <div class="bg-primary/10 rounded-lg p-1.5">
xmlns="http://www.w3.org/2000/svg" <User class="text-primary h-5 w-5" strokeWidth={2} />
class="text-primary h-6 w-6" </div>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
Funcionário Funcionário
</h3> </h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
<p class="text-base-content/70 text-sm">Nome</p> <p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
<p class="text-lg font-bold"> Nome
{solicitacao.funcionario?.nome || 'N/A'}
</p> </p>
<div class="flex items-center gap-2">
<UserAvatar
fotoPerfilUrl={solicitacao.funcionario?.fotoPerfilUrl}
nome={solicitacao.funcionario?.nome || 'N/A'}
size="sm"
/>
<p class="text-base-content text-base font-bold truncate">
{solicitacao.funcionario?.nome || 'N/A'}
</p>
</div>
</div> </div>
{#if solicitacao.time} {#if solicitacao.time}
<div> <div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
<p class="text-base-content/70 text-sm">Time</p> <p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
Time
</p>
<div <div
class="badge badge-lg font-semibold" class="badge badge-sm font-semibold max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
.cor}; color: {solicitacao.time.cor}" .cor}; color: {solicitacao.time.cor}"
title={solicitacao.time.nome}
> >
{solicitacao.time.nome} {solicitacao.time.nome}
</div> </div>
@@ -181,166 +185,184 @@
</div> </div>
</div> </div>
<div class="divider"></div> <div class="divider my-4"></div>
<!-- Período da Ausência --> <!-- Período da Ausência -->
<div class="mb-6"> <div class="mb-4">
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold"> <h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<svg <div class="bg-primary/10 rounded-lg p-1.5">
xmlns="http://www.w3.org/2000/svg" <Calendar class="text-primary h-5 w-5" strokeWidth={2} />
class="text-primary h-6 w-6" </div>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Período da Ausência Período da Ausência
</h3> </h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div <div
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950" class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
> >
<div class="stat-title">Data Início</div> <div class="stat-title text-base-content/70 text-xs">Data Início</div>
<div class="stat-value text-2xl text-orange-600 dark:text-orange-400"> <div class="stat-value text-primary text-lg font-bold">
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')} {parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
</div> </div>
</div> </div>
<div <div
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950" class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
> >
<div class="stat-title">Data Fim</div> <div class="stat-title text-base-content/70 text-xs">Data Fim</div>
<div class="stat-value text-2xl text-orange-600 dark:text-orange-400"> <div class="stat-value text-primary text-lg font-bold">
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')} {parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
</div> </div>
</div> </div>
<div <div
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950" class="stat border-primary/30 from-primary/10 to-primary/15 hover:border-primary/40 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
> >
<div class="stat-title">Total de Dias</div> <div class="stat-title text-base-content/70 text-xs">Total de Dias</div>
<div class="stat-value text-3xl text-orange-600 dark:text-orange-400"> <div class="stat-value text-primary text-2xl font-bold">
{totalDias} {totalDias}
</div> </div>
<div class="stat-desc">dias corridos</div> <div class="stat-desc text-base-content/60 text-xs">dias corridos</div>
</div> </div>
</div> </div>
</div> </div>
<div class="divider"></div> <div class="divider my-4"></div>
<!-- Motivo --> <!-- Motivo -->
<div class="mb-6"> <div class="mb-4">
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold"> <h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<svg <div class="bg-primary/10 rounded-lg p-1.5">
xmlns="http://www.w3.org/2000/svg" <FileText class="text-primary h-5 w-5" strokeWidth={2} />
class="text-primary h-6 w-6" </div>
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Motivo da Ausência Motivo da Ausência
</h3> </h3>
<div class="card bg-base-200"> <div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
<div class="card-body"> <div class="card-body p-3">
<p class="whitespace-pre-wrap">{solicitacao.motivo}</p> <p class="text-base-content text-sm leading-relaxed whitespace-pre-wrap">
{solicitacao.motivo}
</p>
</div> </div>
</div> </div>
</div> </div>
<!-- Status Atual --> <!-- Status Atual -->
<div class="mb-6"> <div class="bg-base-200/30 mb-4 rounded-lg p-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-sm font-semibold">Status:</span> <span class="text-base-content/70 text-xs font-semibold tracking-wide uppercase"
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}> >Status:</span
>
<div class={`badge badge-sm ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)} {getStatusTexto(solicitacao.status)}
</div> </div>
</div> </div>
</div> </div>
<!-- Informações de Aprovação/Reprovação -->
{#if solicitacao.status === 'aprovado'}
<div class="alert alert-success mb-4 shadow-lg py-3">
<Check class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
<div class="flex-1">
<div class="font-bold text-sm">Aprovado</div>
{#if solicitacao.gestor}
<div class="text-xs mt-1">
Por: <strong>{solicitacao.gestor.nome}</strong>
</div>
{/if}
{#if solicitacao.dataAprovacao}
<div class="text-xs mt-1 opacity-80">
Em: {new Date(solicitacao.dataAprovacao).toLocaleString('pt-BR')}
</div>
{/if}
</div>
</div>
{/if}
{#if solicitacao.status === 'reprovado'}
<div class="alert alert-error mb-4 shadow-lg py-3">
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
<div class="flex-1">
<div class="font-bold text-sm">Reprovado</div>
{#if solicitacao.gestor}
<div class="text-xs mt-1">
Por: <strong>{solicitacao.gestor.nome}</strong>
</div>
{/if}
{#if solicitacao.dataReprovacao}
<div class="text-xs mt-1 opacity-80">
Em: {new Date(solicitacao.dataReprovacao).toLocaleString('pt-BR')}
</div>
{/if}
{#if solicitacao.motivoReprovacao}
<div class="mt-2">
<div class="text-xs font-semibold">Motivo:</div>
<div class="text-xs">{solicitacao.motivoReprovacao}</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Histórico de Alterações -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
<div class="mb-4">
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<div class="bg-primary/10 rounded-lg p-1.5">
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
</div>
Histórico de Alterações
</h3>
<div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
<div class="card-body p-3">
<div class="space-y-2">
{#each solicitacao.historicoAlteracoes as hist}
<div class="border-base-300 flex items-start gap-2 border-b pb-2 last:border-0 last:pb-0">
<Clock class="text-primary mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={2} />
<div class="flex-1">
<div class="text-base-content text-xs font-semibold">{hist.acao}</div>
<div class="text-base-content/60 text-xs">
{new Date(hist.data).toLocaleString('pt-BR')}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<!-- Erro --> <!-- Erro -->
{#if erro} {#if erro}
<div class="alert alert-error mb-4"> <div class="alert alert-error mb-4 shadow-lg py-3">
<svg <XCircle class="h-5 w-5 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg" <span class="text-sm">{erro}</span>
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{erro}</span>
</div> </div>
{/if} {/if}
<!-- Ações --> <!-- Ações -->
{#if solicitacao.status === 'aguardando_aprovacao'} {#if solicitacao.status === 'aguardando_aprovacao'}
<div class="card-actions mt-6 justify-end gap-4"> <div class="card-actions mt-4 justify-end gap-2 flex-wrap">
<button <button
type="button" type="button"
class="btn btn-error btn-lg gap-2" class="btn btn-error btn-sm md:btn-md gap-2"
onclick={reprovar} onclick={reprovar}
disabled={processando} disabled={processando}
> >
{#if processando} {#if processando}
<span class="loading loading-spinner"></span> <span class="loading loading-spinner loading-sm"></span>
{:else} {:else}
<svg <X class="h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
{/if} {/if}
Reprovar Reprovar
</button> </button>
<button <button
type="button" type="button"
class="btn btn-success btn-lg gap-2" class="btn btn-success btn-sm md:btn-md gap-2"
onclick={aprovar} onclick={aprovar}
disabled={processando} disabled={processando}
> >
{#if processando} {#if processando}
<span class="loading loading-spinner"></span> <span class="loading loading-spinner loading-sm"></span>
{:else} {:else}
<svg <Check class="h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{/if} {/if}
Aprovar Aprovar
</button> </button>
@@ -348,14 +370,14 @@
<!-- Modal de Reprovação --> <!-- Modal de Reprovação -->
{#if motivoReprovacao !== undefined} {#if motivoReprovacao !== undefined}
<div class="mt-4"> <div class="border-error/20 bg-error/5 mt-4 rounded-lg border-2 p-3">
<div class="form-control"> <div class="form-control">
<label class="label" for="motivo-reprovacao"> <label class="label py-1" for="motivo-reprovacao">
<span class="label-text font-bold">Motivo da Reprovação</span> <span class="label-text text-error text-sm font-bold">Motivo da Reprovação</span>
</label> </label>
<textarea <textarea
id="motivo-reprovacao" id="motivo-reprovacao"
class="textarea textarea-bordered h-24" class="textarea textarea-bordered textarea-sm focus:border-error focus:outline-error h-20"
placeholder="Informe o motivo da reprovação..." placeholder="Informe o motivo da reprovação..."
bind:value={motivoReprovacao} bind:value={motivoReprovacao}
></textarea> ></textarea>
@@ -363,21 +385,9 @@
</div> </div>
{/if} {/if}
{:else} {:else}
<div class="alert alert-info"> <div class="alert alert-info shadow-lg py-3">
<svg <Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg" <span class="text-sm">Esta solicitação já foi processada.</span>
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<span>Esta solicitação já foi processada.</span>
</div> </div>
{/if} {/if}
@@ -385,7 +395,7 @@
<div class="mt-4 text-center"> <div class="mt-4 text-center">
<button <button
type="button" type="button"
class="btn" class="btn btn-ghost btn-sm"
onclick={() => { onclick={() => {
if (onCancelar) onCancelar(); if (onCancelar) onCancelar();
}} }}
@@ -408,7 +418,7 @@
<style> <style>
.aprovar-ausencia { .aprovar-ausencia {
max-width: 900px; max-width: 100%;
margin: 0 auto; margin: 0 auto;
} }
</style> </style>

View File

@@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import UserAvatar from './chat/UserAvatar.svelte';
import { Clock, Check, Edit, X, XCircle } from 'lucide-svelte';
import { useConvexClient } from 'convex-svelte';
type PeriodoFerias = Doc<'ferias'> & { type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null; funcionario?: Doc<'funcionarios'> | null;
@@ -16,7 +18,7 @@
onCancelar?: () => void; onCancelar?: () => void;
} }
let { periodo, gestorId, onSucesso, onCancelar }: Props = $props(); const { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
@@ -28,7 +30,7 @@
let erro = $state(''); let erro = $state('');
// Calcular dias do período ajustado // Calcular dias do período ajustado
const diasAjustados = $derived.by(() => { let diasAjustados = $derived.by(() => {
if (!novaDataInicio || !novaDataFim) return 0; if (!novaDataInicio || !novaDataFim) return 0;
const inicio = new Date(novaDataInicio); const inicio = new Date(novaDataInicio);
const fim = new Date(novaDataFim); const fim = new Date(novaDataFim);
@@ -69,10 +71,13 @@
const validacao = await client.query(api.saldoFerias.validarSolicitacao, { const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
funcionarioId: periodo.funcionario._id, funcionarioId: periodo.funcionario._id,
anoReferencia: periodo.anoReferencia, anoReferencia: periodo.anoReferencia,
periodos: [{ periodos: [
dataInicio: periodo.dataInicio, {
dataFim: periodo.dataFim dataInicio: periodo.dataInicio,
}] dataFim: periodo.dataFim
}
],
feriasIdExcluir: periodo._id // Excluir este período do cálculo de saldo pendente
}); });
if (!validacao.valido) { if (!validacao.valido) {
@@ -140,10 +145,12 @@
const validacao = await client.query(api.saldoFerias.validarSolicitacao, { const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
funcionarioId: periodo.funcionario._id, funcionarioId: periodo.funcionario._id,
anoReferencia: periodo.anoReferencia, anoReferencia: periodo.anoReferencia,
periodos: [{ periodos: [
dataInicio: novaDataInicio, {
dataFim: novaDataFim dataInicio: novaDataInicio,
}], dataFim: novaDataFim
}
],
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
}); });
@@ -215,13 +222,20 @@
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="mb-4 flex items-start justify-between"> <div class="mb-4 flex items-start justify-between">
<div> <div class="flex items-center gap-3">
<h2 class="card-title text-2xl"> <UserAvatar
{periodo.funcionario?.nome || 'Funcionário'} fotoPerfilUrl={periodo.funcionario?.fotoPerfilUrl}
</h2> nome={periodo.funcionario?.nome || 'Funcionário'}
<p class="text-base-content/70 mt-1 text-sm"> size="md"
Ano de Referência: {periodo.anoReferencia} />
</p> <div>
<h2 class="card-title text-2xl">
{periodo.funcionario?.nome || 'Funcionário'}
</h2>
<p class="text-base-content/70 mt-1 text-sm">
Ano de Referência: {periodo.anoReferencia}
</p>
</div>
</div> </div>
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}> <div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
{getStatusTexto(periodo.status)} {getStatusTexto(periodo.status)}
@@ -235,15 +249,11 @@
<div class="grid grid-cols-3 gap-4 text-sm"> <div class="grid grid-cols-3 gap-4 text-sm">
<div> <div>
<span class="text-base-content/70">Início:</span> <span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold" <span class="ml-1 font-semibold">{formatarDataString(periodo.dataInicio)}</span>
>{formatarDataString(periodo.dataInicio)}</span
>
</div> </div>
<div> <div>
<span class="text-base-content/70">Fim:</span> <span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold" <span class="ml-1 font-semibold">{formatarDataString(periodo.dataFim)}</span>
>{formatarDataString(periodo.dataFim)}</span
>
</div> </div>
<div> <div>
<span class="text-base-content/70">Dias:</span> <span class="text-base-content/70">Dias:</span>
@@ -270,20 +280,7 @@
<div class="space-y-1"> <div class="space-y-1">
{#each periodo.historicoAlteracoes as hist} {#each periodo.historicoAlteracoes as hist}
<div class="text-base-content/70 flex items-center gap-2 text-xs"> <div class="text-base-content/70 flex items-center gap-2 text-xs">
<svg <Clock class="h-3 w-3" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{formatarData(hist.data)}</span> <span>{formatarData(hist.data)}</span>
<span>-</span> <span>-</span>
<span>{hist.acao}</span> <span>{hist.acao}</span>
@@ -307,20 +304,7 @@
onclick={aprovar} onclick={aprovar}
disabled={processando} disabled={processando}
> >
<svg <Check class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Aprovar Aprovar
</button> </button>
@@ -330,20 +314,7 @@
onclick={() => (modoAjuste = true)} onclick={() => (modoAjuste = true)}
disabled={processando} disabled={processando}
> >
<svg <Edit class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Ajustar Datas e Aprovar Ajustar Datas e Aprovar
</button> </button>
</div> </div>
@@ -364,20 +335,7 @@
onclick={reprovar} onclick={reprovar}
disabled={processando || !motivoReprovacao.trim()} disabled={processando || !motivoReprovacao.trim()}
> >
<svg <X class="h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Reprovar Reprovar
</button> </button>
</div> </div>
@@ -445,20 +403,7 @@
onclick={ajustarEAprovar} onclick={ajustarEAprovar}
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0} disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0}
> >
<svg <Check class="h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Confirmar e Aprovar Confirmar e Aprovar
</button> </button>
</div> </div>
@@ -466,25 +411,48 @@
{/if} {/if}
{/if} {/if}
<!-- Informações de Aprovação/Reprovação -->
{#if periodo.status === 'aprovado' || periodo.status === 'data_ajustada_aprovada' || periodo.status === 'EmFérias'}
<div class="alert alert-success mt-4">
<Check class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
<div class="flex-1">
<div class="font-bold">Aprovado</div>
{#if periodo.gestor}
<div class="text-sm mt-1">
Por: <strong>{periodo.gestor.nome}</strong>
</div>
{/if}
{#if periodo.dataAprovacao}
<div class="text-xs mt-1 opacity-80">
Em: {formatarData(periodo.dataAprovacao)}
</div>
{/if}
</div>
</div>
{/if}
<!-- Motivo Reprovação (se reprovado) --> <!-- Motivo Reprovação (se reprovado) -->
{#if periodo.status === 'reprovado' && periodo.motivoReprovacao} {#if periodo.status === 'reprovado'}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<svg <XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg" <div class="flex-1">
class="h-6 w-6 shrink-0 stroke-current" <div class="font-bold">Reprovado</div>
fill="none" {#if periodo.gestor}
viewBox="0 0 24 24" <div class="text-sm mt-1">
> Por: <strong>{periodo.gestor.nome}</strong>
<path </div>
stroke-linecap="round" {/if}
stroke-linejoin="round" {#if periodo.dataReprovacao}
stroke-width="2" <div class="text-xs mt-1 opacity-80">
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" Em: {formatarData(periodo.dataReprovacao)}
/> </div>
</svg> {/if}
<div> {#if periodo.motivoReprovacao}
<div class="font-bold">Motivo da Reprovação:</div> <div class="mt-2">
<div class="text-sm font-semibold">Motivo:</div>
<div class="text-sm">{periodo.motivoReprovacao}</div> <div class="text-sm">{periodo.motivoReprovacao}</div>
</div>
{/if}
</div> </div>
</div> </div>
{/if} {/if}
@@ -492,19 +460,7 @@
<!-- Erro --> <!-- Erro -->
{#if erro} {#if erro}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<svg <XCircle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{erro}</span> <span>{erro}</span>
</div> </div>
{/if} {/if}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,135 @@
<script lang="ts">
import { AlertTriangle, X } from 'lucide-svelte';
interface Props {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
}
let {
open = $bindable(false),
title = 'Confirmar ação',
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
onConfirm,
onCancel
}: Props = $props();
function handleConfirm() {
open = false;
onConfirm();
}
function handleCancel() {
open = false;
onCancel();
}
</script>
{#if open}
<div
class="pointer-events-none fixed inset-0 z-[9999]"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-confirm-title"
>
<!-- Backdrop -->
<div
class="pointer-events-auto absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-200"
onclick={handleCancel}
></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div
class="border-base-300 from-warning/10 to-warning/5 flex flex-shrink-0 items-center justify-between border-b bg-linear-to-r px-6 py-4"
>
<h2 id="modal-confirm-title" class="text-warning flex items-center gap-2 text-xl font-bold">
<AlertTriangle class="h-6 w-6" strokeWidth={2.5} />
{title}
</h2>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleCancel}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<p class="text-base-content text-base leading-relaxed">{message}</p>
</div>
<!-- Footer -->
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4">
<button class="btn btn-ghost" onclick={handleCancel}>{cancelText}</button>
<button class="btn btn-warning" onclick={handleConfirm}>{confirmText}</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translate(-50%, -40%) scale(0.95);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Scrollbar customizada */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -0,0 +1,133 @@
<script lang="ts">
import { AlertTriangle, X } from 'lucide-svelte';
interface Props {
open: boolean;
title?: string;
message: string;
confirmText?: string;
cancelText?: string;
isDestructive?: boolean;
onConfirm: () => void;
onClose: () => void;
}
let {
open = $bindable(false),
title = 'Confirmar Ação',
message,
confirmText = 'Confirmar',
cancelText = 'Cancelar',
isDestructive = false,
onConfirm,
onClose
}: Props = $props();
// Tenta centralizar, mas se tiver um contexto específico pode ser ajustado
// Por padrão, centralizado.
function getModalStyle() {
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 500px;';
}
function handleClose() {
open = false;
onClose();
}
function handleConfirm() {
open = false;
onConfirm();
}
</script>
{#if open}
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
role="dialog"
aria-modal="true"
aria-labelledby="modal-confirm-title"
>
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
onclick={handleClose}
aria-hidden="true"
></div>
<!-- Modal Box -->
<div
class="pointer-events-auto absolute z-10 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-white shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex shrink-0 items-center justify-between border-b border-gray-100 px-6 py-4">
<h2
id="modal-confirm-title"
class="flex items-center gap-2 text-xl font-bold {isDestructive
? 'text-red-600'
: 'text-gray-900'}"
>
{#if isDestructive}
<AlertTriangle class="h-6 w-6" strokeWidth={2.5} />
{/if}
{title}
</h2>
<button
type="button"
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
onclick={handleClose}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6 py-6">
<p class="text-base leading-relaxed font-medium text-gray-700">{message}</p>
</div>
<!-- Footer -->
<div class="flex shrink-0 justify-end gap-3 border-t border-gray-100 bg-gray-50 px-6 py-4">
<button
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-200"
onclick={handleClose}
>
{cancelText}
</button>
<button
class="rounded-lg px-4 py-2 text-sm font-medium text-white shadow-sm {isDestructive
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'}"
onclick={handleConfirm}
>
{confirmText}
</button>
</div>
</div>
</div>
{/if}
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
</style>

View File

@@ -0,0 +1,14 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<div
class={[
'via-primary absolute top-0 left-0 h-1 w-full bg-linear-to-r from-transparent to-transparent opacity-50',
className
]}
></div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import { XCircle } from 'lucide-svelte';
interface Props {
message?: string | null;
class?: string;
}
let { message = null, class: className = '' }: Props = $props();
</script>
{#if message}
<div
class={[
'border-error/20 bg-error/10 text-error-content/90 mb-6 flex items-center gap-3 rounded-lg border p-4 backdrop-blur-md',
className
]}
>
<XCircle class="h-5 w-5 shrink-0" />
<span class="text-sm font-medium">{message}</span>
</div>
{/if}

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { AlertCircle, X } from 'lucide-svelte'; import { AlertCircle, HelpCircle, X } from 'lucide-svelte';
interface Props { interface Props {
open: boolean; open: boolean;
@@ -11,38 +11,132 @@
let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props(); let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props();
let modalRef: HTMLDialogElement; let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição baseada no card de registro de ponto
function calcularPosicaoModal() {
// Procurar pelo elemento do card de registro de ponto
const cardRef = document.getElementById('card-registro-ponto-ref');
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
const top = rect.top;
// Garantir que o modal não saia da viewport
// Considerar uma altura mínima do modal (aproximadamente 300px)
const minTop = 20;
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
const finalTop = Math.max(minTop, Math.min(top, maxTop));
// Centralizar horizontalmente
return {
top: finalTop,
left: window.innerWidth / 2
};
}
// Se não encontrar, usar posição padrão (centro da tela)
return null;
}
$effect(() => {
if (open) {
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
const updatePosition = () => {
requestAnimationFrame(() => {
const pos = calcularPosicaoModal();
if (pos) {
modalPosition = pos;
}
});
};
// Aguardar um pouco mais para garantir que o DOM está atualizado
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
const handleScroll = () => {
updatePosition();
};
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
} else {
// Limpar posição quando o modal for fechado
modalPosition = null;
}
});
// Função para obter estilo do modal baseado na posição calculada
function getModalStyle() {
if (modalPosition) {
// Posicionar na altura do card, centralizado horizontalmente
// position: fixed já é relativo à viewport, então podemos usar diretamente
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
}
// Se não houver posição calculada, centralizar na tela
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
}
// Verificar se details contém instruções ou apenas detalhes técnicos
let temInstrucoes = $derived.by(() => {
if (!details) return false;
// Se contém palavras-chave de instruções, é uma instrução
return (
details.includes('Por favor') ||
details.includes('aguarde') ||
details.includes('recarregue') ||
details.includes('Verifique') ||
details.includes('tente novamente') ||
details.match(/^\d+\./)
); // Começa com número (lista numerada)
});
function handleClose() { function handleClose() {
open = false; open = false;
onClose(); onClose();
} }
$effect(() => {
if (open && modalRef) {
modalRef.showModal();
} else if (!open && modalRef) {
modalRef.close();
}
});
</script> </script>
{#if open} {#if open}
<dialog <div
bind:this={modalRef} class="pointer-events-none fixed inset-0 z-50"
class="modal" style="animation: fadeIn 0.2s ease-out;"
onclick={(e) => e.target === e.currentTarget && handleClose()} role="dialog"
aria-modal="true"
aria-labelledby="modal-error-title"
> >
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}> <!-- Backdrop leve -->
<!-- Header --> <div
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4"> class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
<h2 id="modal-title" class="text-error flex items-center gap-2 text-xl font-bold"> onclick={handleClose}
<AlertCircle class="h-5 w-5" strokeWidth={2} /> ></div>
<!-- Modal Box -->
<div
class="bg-base-100 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header fixo -->
<div
class="border-base-300 flex flex-shrink-0 items-center justify-between border-b px-6 py-4"
>
<h2 id="modal-error-title" class="text-error flex items-center gap-2 text-xl font-bold">
<AlertCircle class="h-6 w-6" strokeWidth={2.5} />
{title} {title}
</h2> </h2>
<button <button
type="button" type="button"
class="btn btn-sm btn-circle" class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
onclick={handleClose} onclick={handleClose}
aria-label="Fechar" aria-label="Fechar"
> >
@@ -50,24 +144,102 @@
</button> </button>
</div> </div>
<!-- Content --> <!-- Content com rolagem -->
<div class="px-6 py-6"> <div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
<p class="text-base-content mb-4">{message}</p> <!-- Mensagem principal -->
<div class="mb-6">
<p class="text-base-content text-base leading-relaxed font-medium">{message}</p>
</div>
<!-- Instruções ou detalhes (se houver) -->
{#if details} {#if details}
<div class="bg-base-200 mb-4 rounded-lg p-4"> <div class="bg-info/10 border-info/30 mb-4 rounded-lg border-l-4 p-4">
<p class="text-base-content/70 text-sm whitespace-pre-line">{details}</p> <div class="flex items-start gap-3">
<HelpCircle class="text-info mt-0.5 h-5 w-5 shrink-0" strokeWidth={2} />
<div class="flex-1">
<p class="text-base-content/90 mb-2 text-sm font-semibold">
{temInstrucoes ? 'Como resolver:' : 'Informação adicional:'}
</p>
<div class="text-base-content/80 space-y-2 text-sm">
{#each details
.split('\n')
.filter((line) => line.trim().length > 0) as linha (linha)}
{#if linha.trim().match(/^\d+\./)}
<div class="flex items-start gap-2">
<span class="text-info shrink-0 font-semibold"
>{linha.trim().split('.')[0]}.</span
>
<span class="flex-1 leading-relaxed"
>{linha
.trim()
.substring(linha.trim().indexOf('.') + 1)
.trim()}</span
>
</div>
{:else}
<p class="leading-relaxed">{linha.trim()}</p>
{/if}
{/each}
</div>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
<!-- Footer --> <!-- Footer fixo -->
<div class="modal-action px-6 pb-6"> <div class="border-base-300 flex flex-shrink-0 justify-end border-t px-6 py-4">
<button class="btn btn-primary" onclick={handleClose}> Fechar </button> <button class="btn btn-primary" onclick={handleClose}>Entendi, obrigado</button>
</div> </div>
</div> </div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={handleClose}>fechar</button>
</form>
</dialog>
{/if} {/if}
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Scrollbar customizada para os modais */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from 'convex-svelte'; import { useConvexClient } from 'convex-svelte';
import { resolve } from '$app/paths';
import { import {
ExternalLink, ExternalLink,
FileText, FileText,

View File

@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
const currentYear = new Date().getFullYear();
</script>
<footer class="bg-base-200 text-base-content border-base-300 mt-16 border-t">
<div class="container mx-auto px-4 py-8">
<div class="grid grid-cols-1 gap-8 text-center md:grid-cols-3 md:text-left">
<div>
<h3 class="text-primary mb-4 text-lg font-bold">SGSE</h3>
<p class="mx-auto max-w-xs text-sm opacity-75 md:mx-0">
Sistema de Gestão de Secretaria<br />
Simplificando processos e conectando pessoas.
</p>
</div>
<div>
<h3 class="mb-4 text-lg font-bold">Links Úteis</h3>
<ul class="space-y-2 text-sm opacity-75">
<li>
<a
href="https://www.pe.gov.br/"
target="_blank"
class="hover:text-primary transition-colors">Portal do Governo</a
>
</li>
<li>
<a href={resolve('/privacidade')} class="hover:text-primary transition-colors"
>Política de Privacidade</a
>
</li>
<li>
<a href={resolve('/abrir-chamado')} class="hover:text-primary transition-colors"
>Suporte</a
>
</li>
</ul>
</div>
<div>
<h3 class="mb-4 text-lg font-bold">Contato</h3>
<p class="text-sm opacity-75">
Secretaria de Educação<br />
Recife - PE
</p>
</div>
</div>
<div class="divider mt-8 mb-4"></div>
<div class="flex flex-col items-center justify-between text-sm opacity-60 md:flex-row">
<p>&copy; {currentYear} Governo de Pernambuco. Todos os direitos reservados.</p>
<p class="mt-2 md:mt-0">Desenvolvido com tecnologia de ponta.</p>
</div>
</div>
</footer>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
interface Props {
value?: string; // Matrícula do funcionário
placeholder?: string;
disabled?: boolean;
}
let {
value = $bindable(''),
placeholder = 'Digite a matrícula do funcionário',
disabled = false
}: Props = $props();
// Usar value diretamente como busca para evitar conflitos de sincronização
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
// Filtrar funcionários baseado na busca (por matrícula ou nome)
let funcionariosFiltrados = $derived.by(() => {
if (!value || !value.trim()) return funcionarios.slice(0, 10); // Limitar a 10 quando vazio
const termo = value.toLowerCase().trim();
return funcionarios
.filter((f) => {
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
const nomeMatch = f.nome?.toLowerCase().includes(termo);
return matriculaMatch || nomeMatch;
})
.slice(0, 20); // Limitar resultados
});
function selecionarFuncionario(matricula: string) {
value = matricula;
mostrarDropdown = false;
}
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
function handleInput() {
mostrarDropdown = true;
}
</script>
<div class="relative w-full">
<input
type="text"
bind:value
oninput={handleInput}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
>
{#each funcionariosFiltrados as funcionario}
<button
type="button"
onclick={() => selecionarFuncionario(funcionario.matricula || '')}
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
>
<div class="font-medium">
{#if funcionario.matricula}
Matrícula: {funcionario.matricula}
{:else}
Sem matrícula
{/if}
</div>
<div class="text-base-content/60 text-sm">
{funcionario.nome}
{#if funcionario.descricaoCargo}
{funcionario.nome ? ' • ' : ''}
{funcionario.descricaoCargo}
{/if}
</div>
</button>
{/each}
</div>
{/if}
{#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0}
<div
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
>
<div class="text-sm">Nenhum funcionário encontrado</div>
<div class="mt-1 text-xs opacity-70">
Você pode continuar digitando para buscar livremente
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,127 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
interface Props {
value?: string; // Nome do funcionário
placeholder?: string;
disabled?: boolean;
}
let {
value = $bindable(''),
placeholder = 'Digite o nome do funcionário',
disabled = false
}: Props = $props();
// Usar value diretamente como busca para evitar conflitos de sincronização
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
// Filtrar funcionários baseado na busca (por nome ou matrícula)
let funcionariosFiltrados = $derived.by(() => {
if (!value || !value.trim()) return funcionarios.slice(0, 10); // Limitar a 10 quando vazio
const termo = value.toLowerCase().trim();
return funcionarios
.filter((f) => {
const nomeMatch = f.nome?.toLowerCase().includes(termo);
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
return nomeMatch || matriculaMatch;
})
.slice(0, 20); // Limitar resultados
});
function selecionarFuncionario(nome: string) {
value = nome;
mostrarDropdown = false;
}
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
function handleInput() {
mostrarDropdown = true;
}
</script>
<div class="relative w-full">
<input
type="text"
bind:value
oninput={handleInput}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
>
{#each funcionariosFiltrados as funcionario}
<button
type="button"
onclick={() => selecionarFuncionario(funcionario.nome || '')}
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
>
<div class="font-medium">{funcionario.nome}</div>
<div class="text-base-content/60 text-sm">
{#if funcionario.matricula}
Matrícula: {funcionario.matricula}
{/if}
{#if funcionario.descricaoCargo}
{funcionario.matricula ? ' • ' : ''}
{funcionario.descricaoCargo}
{/if}
</div>
</button>
{/each}
</div>
{/if}
{#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0}
<div
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
>
<div class="text-sm">Nenhum funcionário encontrado</div>
<div class="mt-1 text-xs opacity-70">
Você pode continuar digitando para buscar livremente
</div>
</div>
{/if}
</div>

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
interface Props { interface Props {
value?: string; // Id do funcionário selecionado value?: string; // Id do funcionário selecionado
@@ -23,10 +23,10 @@
// Buscar funcionários // Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []); let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
// Filtrar funcionários baseado na busca // Filtrar funcionários baseado na busca
const funcionariosFiltrados = $derived.by(() => { let funcionariosFiltrados = $derived.by(() => {
if (!busca.trim()) return funcionarios; if (!busca.trim()) return funcionarios;
const termo = busca.toLowerCase().trim(); const termo = busca.toLowerCase().trim();
@@ -39,7 +39,7 @@
}); });
// Funcionário selecionado // Funcionário selecionado
const funcionarioSelecionado = $derived.by(() => { let funcionarioSelecionado = $derived.by(() => {
if (!value) return null; if (!value) return null;
return funcionarios.find((f) => f._id === value); return funcionarios.find((f) => f._id === value);
}); });

View File

@@ -0,0 +1,19 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<div
class={[
'border-base-content/10 bg-base-content/5 ring-base-content/10 relative overflow-hidden rounded-2xl border p-8 shadow-2xl ring-1 backdrop-blur-xl transition-all duration-300',
className
]}
>
{@render children?.()}
</div>

View File

@@ -1,7 +1,101 @@
<script lang="ts"> <script lang="ts">
import logo from "$lib/assets/logo_governo_PE.png"; import { resolve } from '$app/paths';
import logo from '$lib/assets/logo_governo_PE.png';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
type HeaderProps = {
left?: Snippet;
right?: Snippet;
};
const { left, right }: HeaderProps = $props();
let themeSelectEl: HTMLSelectElement | null = null;
function safeGetThemeLS(): string | null {
try {
const t = localStorage.getItem('theme');
return t && t.trim() ? t : null;
} catch {
return null;
}
}
onMount(() => {
const persisted = safeGetThemeLS();
if (persisted) {
// Sincroniza UI + HTML com o valor persistido (evita select ficar "aqua" indevido)
if (themeSelectEl && themeSelectEl.value !== persisted) {
themeSelectEl.value = persisted;
}
aplicarTemaDaisyUI(persisted);
}
});
function onThemeChange(e: Event) {
const nextValue = (e.currentTarget as HTMLSelectElement | null)?.value ?? null;
// Se o theme-change não atualizar (caso comum após login/logout),
// garantimos aqui a persistência + aplicação imediata.
if (nextValue) {
try {
localStorage.setItem('theme', nextValue);
} catch {
// ignore
}
aplicarTemaDaisyUI(nextValue);
}
}
</script> </script>
<div class="navbar bg-base-200 shadow-sm p-4 w-76"> <header
<img src={logo} alt="Logo" class="" /> class="bg-base-200 border-base-100 sticky top-0 z-50 w-full border-b py-3 shadow-sm backdrop-blur-md transition-all duration-300"
</div> >
<div class=" flex h-16 w-full items-center justify-between px-4">
<div class="flex items-center gap-3">
{#if left}
{@render left()}
{/if}
<a
href={resolve('/')}
class="group flex items-center gap-3 transition-transform hover:scale-[1.02]"
>
<img src={logo} alt="Logo Governo PE" class="h-10 w-auto object-contain drop-shadow-sm" />
<div class="hidden flex-col sm:flex">
<span class="text-primary text-2xl font-bold tracking-wider uppercase">SGSE</span>
<span class="text-base-content -mt-1 text-lg leading-none font-extrabold tracking-tight"
>Sistema de Gestão da Secretaria de Esportes</span
>
</div>
</a>
</div>
<div class="flex items-center gap-2">
<select
bind:this={themeSelectEl}
class="select select-sm bg-base-100 border-base-300 w-40"
aria-label="Selecionar tema"
data-choose-theme
onchange={onThemeChange}
>
<option value="aqua">Aqua</option>
<option value="sgse-blue">Azul</option>
<option value="sgse-green">Verde</option>
<option value="sgse-orange">Laranja</option>
<option value="sgse-red">Vermelho</option>
<option value="sgse-pink">Rosa</option>
<option value="sgse-teal">Verde-água</option>
<option value="sgse-corporate">Corporativo</option>
<option value="light">Claro</option>
<option value="dark">Escuro</option>
</select>
{#if right}
{@render right()}
{/if}
</div>
</div>
</header>

View File

@@ -1,168 +0,0 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { loginModalStore } from "$lib/stores/loginModal.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
interface MenuProtectionProps {
menuPath: string;
requireGravar?: boolean;
children?: any;
redirectTo?: string;
}
let {
menuPath,
requireGravar = false,
children,
redirectTo = "/",
}: MenuProtectionProps = $props();
let verificando = $state(true);
let temPermissao = $state(false);
let motivoNegacao = $state("");
// Query para verificar permissões (só executa se o usuário estiver autenticado)
const currentUser = useQuery(api.auth.getCurrentUser, {});
const permissaoQuery = $derived(
currentUser?.data
? useQuery(api.menuPermissoes.verificarAcesso, {
usuarioId: currentUser.data._id as Id<"usuarios">,
menuPath: menuPath,
})
: null,
);
onMount(() => {
verificarPermissoes();
});
$effect(() => {
// Re-verificar quando o status do usuário atual mudar
verificarPermissoes();
});
$effect(() => {
// Re-verificar quando a query carregar
if (permissaoQuery?.data) {
verificarPermissoes();
}
});
function verificarPermissoes() {
// Dashboard e Solicitar Acesso são públicos
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
verificando = false;
temPermissao = true;
return;
}
// Se não está autenticado
if (!currentUser?.data) {
verificando = false;
temPermissao = false;
motivoNegacao = "auth_required";
// Abrir modal de login e salvar rota de redirecionamento
const currentPath = window.location.pathname;
loginModalStore.open(currentPath);
// NÃO redirecionar, apenas mostrar o modal
// O usuário verá a mensagem "Verificando permissões..." enquanto o modal está aberto
return;
}
// Se está autenticado, verificar permissões
if (permissaoQuery?.data) {
const permissao = permissaoQuery.data;
// Se não pode acessar
if (!permissao.podeAcessar) {
verificando = false;
temPermissao = false;
motivoNegacao = "access_denied";
return;
}
// Se requer gravação mas não tem permissão
if (requireGravar && !permissao.podeGravar) {
verificando = false;
temPermissao = false;
motivoNegacao = "write_denied";
return;
}
// Tem permissão!
verificando = false;
temPermissao = true;
} else if (permissaoQuery?.error) {
verificando = false;
temPermissao = false;
motivoNegacao = "error";
}
}
</script>
{#if verificando}
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
{#if motivoNegacao === "auth_required"}
<div class="p-4 bg-warning/10 rounded-full inline-block mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-warning"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 class="text-2xl font-bold text-base-content mb-2">
Acesso Restrito
</h2>
<p class="text-base-content/70 mb-4">
Esta área requer autenticação.<br />
Por favor, faça login para continuar.
</p>
{:else}
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
{/if}
</div>
</div>
{:else if temPermissao}
{@render children?.()}
{:else}
<div class="flex items-center justify-center min-h-screen">
<div class="text-center">
<div class="p-4 bg-error/10 rounded-full inline-block mb-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-16 w-16 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Negado</h2>
<p class="text-base-content/70">
Você não tem permissão para acessar esta página.
</p>
</div>
</div>
{/if}

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import { prefersReducedMotion, Spring } from 'svelte/motion';
interface Props {
open: boolean;
class?: string;
stroke?: number;
}
let { open, class: className = '', stroke = 2 }: Props = $props();
const progress = Spring.of(() => (open ? 1 : 0), {
stiffness: 0.25,
damping: 0.65,
precision: 0.001
});
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
let t = $derived(prefersReducedMotion.current ? (open ? 1 : 0) : progress.current);
let tFast = $derived(clamp01(t * 1.15));
// Fechado: hambúrguer. Aberto: "outro menu" (linhas deslocadas + comprimentos diferentes).
// Continua sendo ícone de menu (não vira X).
let topY = $derived(lerp(-6, -7, tFast));
let botY = $derived(lerp(6, 7, tFast));
let topX = $derived(lerp(0, 3.25, t));
let midX = $derived(lerp(0, -2.75, t));
let botX = $derived(lerp(0, 1.75, t));
// micro-inclinação só pra dar “vida”, sem cruzar em X
let topR = $derived(lerp(0, 2.5, tFast));
let botR = $derived(lerp(0, -2.5, tFast));
let topScaleX = $derived(lerp(1, 0.62, tFast));
let midScaleX = $derived(lerp(1, 0.92, tFast));
let botScaleX = $derived(lerp(1, 0.72, tFast));
let topOpacity = $derived(1);
let midOpacity = $derived(1);
let botOpacity = $derived(1);
</script>
<span class="menu-toggle-icon {className}" aria-hidden="true" style="--stroke: {stroke}px">
<span
class="line"
style="--x: {topX}px; --y: {topY}px; --r: {topR}deg; --o: {topOpacity}; --sx: {topScaleX}"
></span>
<span
class="line"
style="--x: {midX}px; --y: 0px; --r: 0deg; --o: {midOpacity}; --sx: {midScaleX}"
></span>
<span
class="line"
style="--x: {botX}px; --y: {botY}px; --r: {botR}deg; --o: {botOpacity}; --sx: {botScaleX}"
></span>
</span>
<style>
.menu-toggle-icon {
position: relative;
display: inline-block;
width: 1.25rem;
height: 1.25rem;
color: currentColor;
}
.line {
position: absolute;
left: 0;
right: 0;
top: 50%;
margin-top: calc(var(--stroke) / -2);
height: var(--stroke);
border-radius: 9999px;
background: currentColor;
opacity: var(--o, 1);
transform-origin: center;
transform: translateX(var(--x, 0px)) translateY(var(--y, 0px)) rotate(var(--r, 0deg))
scaleX(var(--sx, 1));
will-change: transform, opacity;
}
</style>

View File

@@ -1,207 +1,201 @@
<script lang="ts"> <script lang="ts">
import { modelosDeclaracoes } from "$lib/utils/modelosDeclaracoes"; import { modelosDeclaracoes } from '$lib/utils/modelosDeclaracoes';
import { import {
gerarDeclaracaoAcumulacaoCargo, gerarDeclaracaoAcumulacaoCargo,
gerarDeclaracaoDependentesIR, gerarDeclaracaoDependentesIR,
gerarDeclaracaoIdoneidade, gerarDeclaracaoIdoneidade,
gerarTermoNepotismo, gerarTermoNepotismo,
gerarTermoOpcaoRemuneracao, gerarTermoOpcaoRemuneracao,
downloadBlob, downloadBlob
} from "$lib/utils/declaracoesGenerator"; } from '$lib/utils/declaracoesGenerator';
import { FileText, Info } from "lucide-svelte"; import { FileText, Info } from 'lucide-svelte';
interface Props { interface Props {
funcionario?: any; funcionario?: any;
showPreencherButton?: boolean; showPreencherButton?: boolean;
} }
let { funcionario, showPreencherButton = false }: Props = $props(); let { funcionario, showPreencherButton = false }: Props = $props();
let generating = $state(false); let generating = $state(false);
function baixarModelo(arquivoUrl: string, nomeModelo: string) { function baixarModelo(arquivoUrl: string, nomeModelo: string) {
const link = document.createElement("a"); const link = document.createElement('a');
link.href = arquivoUrl; link.href = arquivoUrl;
link.download = nomeModelo + ".pdf"; link.download = nomeModelo + '.pdf';
link.target = "_blank"; link.target = '_blank';
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
} }
async function gerarPreenchido(modeloId: string) { async function gerarPreenchido(modeloId: string) {
if (!funcionario) { if (!funcionario) {
alert("Dados do funcionário não disponíveis"); alert('Dados do funcionário não disponíveis');
return; return;
} }
try { try {
generating = true; generating = true;
let blob: Blob; let blob: Blob;
let nomeArquivo: string; let nomeArquivo: string;
switch (modeloId) { switch (modeloId) {
case "acumulacao_cargo": case 'acumulacao_cargo':
blob = await gerarDeclaracaoAcumulacaoCargo(funcionario); blob = await gerarDeclaracaoAcumulacaoCargo(funcionario);
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`; nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
break; break;
case "dependentes_ir": case 'dependentes_ir':
blob = await gerarDeclaracaoDependentesIR(funcionario); blob = await gerarDeclaracaoDependentesIR(funcionario);
nomeArquivo = `Declaracao_Dependentes_IR_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`; nomeArquivo = `Declaracao_Dependentes_IR_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
break; break;
case "idoneidade": case 'idoneidade':
blob = await gerarDeclaracaoIdoneidade(funcionario); blob = await gerarDeclaracaoIdoneidade(funcionario);
nomeArquivo = `Declaracao_Idoneidade_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`; nomeArquivo = `Declaracao_Idoneidade_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
break; break;
case "nepotismo": case 'nepotismo':
blob = await gerarTermoNepotismo(funcionario); blob = await gerarTermoNepotismo(funcionario);
nomeArquivo = `Termo_Nepotismo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`; nomeArquivo = `Termo_Nepotismo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
break; break;
case "opcao_remuneracao": case 'opcao_remuneracao':
blob = await gerarTermoOpcaoRemuneracao(funcionario); blob = await gerarTermoOpcaoRemuneracao(funcionario);
nomeArquivo = `Termo_Opcao_Remuneracao_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`; nomeArquivo = `Termo_Opcao_Remuneracao_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
break; break;
default: default:
alert("Modelo não encontrado"); alert('Modelo não encontrado');
return; return;
} }
downloadBlob(blob, nomeArquivo); downloadBlob(blob, nomeArquivo);
} catch (error) { } catch (error) {
console.error("Erro ao gerar declaração:", error); console.error('Erro ao gerar declaração:', error);
alert("Erro ao gerar declaração preenchida"); alert('Erro ao gerar declaração preenchida');
} finally { } finally {
generating = false; generating = false;
} }
} }
</script> </script>
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<h2 class="card-title text-xl border-b pb-3"> <h2 class="card-title border-b pb-3 text-xl">
<FileText class="h-5 w-5" strokeWidth={2} /> <FileText class="h-5 w-5" strokeWidth={2} />
Modelos de Declarações Modelos de Declarações
</h2> </h2>
<div class="alert alert-info shadow-sm mb-4"> <div class="alert alert-info mb-4 shadow-sm">
<Info class="stroke-current shrink-0 h-5 w-5" strokeWidth={2} /> <Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
<div class="text-sm"> <div class="text-sm">
<p class="font-semibold"> <p class="font-semibold">Baixe os modelos, preencha, assine e faça upload no sistema</p>
Baixe os modelos, preencha, assine e faça upload no sistema <p class="mt-1 text-xs opacity-80">
</p> Estes documentos são necessários para completar o cadastro do funcionário
<p class="text-xs opacity-80 mt-1"> </p>
Estes documentos são necessários para completar o cadastro do </div>
funcionário </div>
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{#each modelosDeclaracoes as modelo} {#each modelosDeclaracoes as modelo}
<div <div class="card bg-base-200 shadow-sm transition-shadow hover:shadow-md">
class="card bg-base-200 shadow-sm hover:shadow-md transition-shadow" <div class="card-body p-4">
> <div class="flex items-start gap-3">
<div class="card-body p-4"> <!-- Ícone PDF -->
<div class="flex items-start gap-3"> <div
<!-- Ícone PDF --> class="bg-error/10 flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
<div >
class="shrink-0 w-12 h-12 bg-error/10 rounded-lg flex items-center justify-center" <svg
> xmlns="http://www.w3.org/2000/svg"
<svg class="text-error h-6 w-6"
xmlns="http://www.w3.org/2000/svg" fill="none"
class="h-6 w-6 text-error" viewBox="0 0 24 24"
fill="none" stroke="currentColor"
viewBox="0 0 24 24" >
stroke="currentColor" <path
> stroke-linecap="round"
<path stroke-linejoin="round"
stroke-linecap="round" stroke-width="2"
stroke-linejoin="round" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
stroke-width="2" />
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" </svg>
/> </div>
</svg>
</div>
<!-- Conteúdo --> <!-- Conteúdo -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<h3 class="font-semibold text-sm mb-1 line-clamp-2"> <h3 class="mb-1 line-clamp-2 text-sm font-semibold">
{modelo.nome} {modelo.nome}
</h3> </h3>
<p class="text-xs text-base-content/70 mb-3 line-clamp-2"> <p class="text-base-content/70 mb-3 line-clamp-2 text-xs">
{modelo.descricao} {modelo.descricao}
</p> </p>
<!-- Ações --> <!-- Ações -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<button <button
type="button" type="button"
class="btn btn-primary btn-xs gap-1" class="btn btn-primary btn-xs gap-1"
onclick={() => baixarModelo(modelo.arquivo, modelo.nome)} onclick={() => baixarModelo(modelo.arquivo, modelo.nome)}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3" class="h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/> />
</svg> </svg>
Baixar Modelo Baixar Modelo
</button> </button>
{#if showPreencherButton && modelo.podePreencherAutomaticamente && funcionario} {#if showPreencherButton && modelo.podePreencherAutomaticamente && funcionario}
<button <button
type="button" type="button"
class="btn btn-outline btn-xs gap-1" class="btn btn-outline btn-xs gap-1"
onclick={() => gerarPreenchido(modelo.id)} onclick={() => gerarPreenchido(modelo.id)}
disabled={generating} disabled={generating}
> >
{#if generating} {#if generating}
<span class="loading loading-spinner loading-xs"></span> <span class="loading loading-spinner loading-xs"></span>
Gerando... Gerando...
{:else} {:else}
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-3 w-3" class="h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/> />
</svg> </svg>
Gerar Preenchido Gerar Preenchido
{/if} {/if}
</button> </button>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/each} {/each}
</div> </div>
<div class="mt-4 text-xs text-base-content/60 text-center"> <div class="text-base-content/60 mt-4 text-center text-xs">
<p> <p>
💡 Dica: Após preencher e assinar os documentos, faça upload na seção 💡 Dica: Após preencher e assinar os documentos, faça upload na seção "Documentação Anexa"
"Documentação Anexa" </p>
</p> </div>
</div> </div>
</div>
</div> </div>

View File

@@ -1,24 +1,24 @@
<script lang="ts"> <script lang="ts">
import jsPDF from 'jspdf'; import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable'; import autoTable from 'jspdf-autotable';
import { maskCPF, maskCEP, maskPhone } from '$lib/utils/masks'; import { CheckCircle2, Printer, X } from 'lucide-svelte';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { import {
SEXO_OPTIONS, APOSENTADO_OPTIONS,
ESTADO_CIVIL_OPTIONS, ESTADO_CIVIL_OPTIONS,
FATOR_RH_OPTIONS,
GRAU_INSTRUCAO_OPTIONS, GRAU_INSTRUCAO_OPTIONS,
GRUPO_SANGUINEO_OPTIONS, GRUPO_SANGUINEO_OPTIONS,
FATOR_RH_OPTIONS, SEXO_OPTIONS
APOSENTADO_OPTIONS
} from '$lib/utils/constants'; } from '$lib/utils/constants';
import logoGovPE from '$lib/assets/logo_governo_PE.png'; import { maskCEP, maskCPF, maskPhone } from '$lib/utils/masks';
import { CheckCircle2, X, Printer } from 'lucide-svelte';
interface Props { interface Props {
funcionario: any; funcionario: any;
onClose: () => void; onClose: () => void;
} }
let { funcionario, onClose }: Props = $props(); const { funcionario, onClose }: Props = $props();
let modalRef: HTMLDialogElement; let modalRef: HTMLDialogElement;
let generating = $state(false); let generating = $state(false);
@@ -113,7 +113,9 @@
// Título da ficha // Título da ficha
doc.setFontSize(18); doc.setFontSize(18);
doc.setFont('helvetica', 'bold'); doc.setFont('helvetica', 'bold');
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, { align: 'center' }); doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, {
align: 'center'
});
yPosition += 8; yPosition += 8;
doc.setFontSize(10); doc.setFontSize(10);
@@ -445,7 +447,7 @@
doc.setFontSize(9); doc.setFontSize(9);
doc.setFont('helvetica', 'normal'); doc.setFont('helvetica', 'normal');
doc.setTextColor(128, 128, 128); doc.setTextColor(128, 128, 128);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center' align: 'center'
}); });
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' }); doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });

View File

@@ -1,45 +1,66 @@
<script lang="ts"> <script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { onMount } from 'svelte'; import { useQuery } from 'convex-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
let { const {
children, children,
requireAuth = true, requireAuth = true,
allowedRoles = [], allowedRoles = [],
maxLevel = 3,
redirectTo = '/' redirectTo = '/'
}: { }: {
children: Snippet; children: Snippet;
requireAuth?: boolean; requireAuth?: boolean;
allowedRoles?: string[]; allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string; redirectTo?: string;
} = $props(); } = $props();
let isChecking = $state(true); let isChecking = $state(true);
let hasAccess = $state(false); let hasAccess = $state(false);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let hasCheckedOnce = $state(false);
let lastUserState = $state<typeof currentUser | undefined>(undefined);
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
onMount(() => { // Usar $effect para reagir apenas às mudanças na query currentUser
checkAccess(); $effect(() => {
// Não verificar novamente se já tem acesso concedido e usuário está autenticado
if (hasAccess && currentUser?.data) {
lastUserState = currentUser;
return;
}
// Evitar loop: só verificar se currentUser realmente mudou
// Comparar dados, não o objeto proxy
const currentData = currentUser?.data;
const lastData = lastUserState?.data;
if (currentData !== lastData || (currentUser === undefined) !== (lastUserState === undefined)) {
lastUserState = currentUser;
checkAccess();
}
}); });
function checkAccess() { function checkAccess() {
isChecking = true; // Limpar timeout anterior se existir
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
// Aguardar um pouco para o authStore carregar do localStorage // Se a query ainda está carregando (undefined), aguardar
setTimeout(() => { if (currentUser === undefined) {
// Verificar autenticação isChecking = true;
if (requireAuth && !currentUser?.data) { hasAccess = false;
const currentPath = window.location.pathname; return;
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`; }
return;
}
// Marcar que já verificou pelo menos uma vez
hasCheckedOnce = true;
// Se a query retornou dados, verificar autenticação
if (currentUser?.data) {
// Verificar roles // Verificar roles
if (allowedRoles.length > 0 && currentUser?.data) { if (allowedRoles.length > 0) {
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? ''); const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
if (!hasRole) { if (!hasRole) {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
@@ -48,20 +69,43 @@
} }
} }
// Verificar nível // Se chegou aqui, permitir acesso
if ( hasAccess = true;
currentUser?.data && isChecking = false;
currentUser.data.role?.nivel && return;
currentUser.data.role.nivel > maxLevel }
) {
// Se não tem dados e requer autenticação
if (requireAuth && !currentUser?.data) {
// Se a query já retornou (não está mais undefined), finalizar estado
if (currentUser !== undefined) {
const currentPath = window.location.pathname; const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`; // Evitar redirecionamento em loop - verificar se já está na URL de erro
const urlParams = new URLSearchParams(window.location.search);
if (!urlParams.has('error')) {
// Só redirecionar se não estiver em loop
if (!hasCheckedOnce || currentUser === null) {
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
return;
}
}
// Se já tem erro na URL, permitir renderização para mostrar o alerta
isChecking = false;
hasAccess = true;
return; return;
} }
// Se ainda está carregando (undefined), aguardar
isChecking = true;
hasAccess = false;
return;
}
// Se não requer autenticação, permitir acesso
if (!requireAuth) {
hasAccess = true; hasAccess = true;
isChecking = false; isChecking = false;
}, 100); }
} }
</script> </script>

View File

@@ -1,157 +1,136 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from 'svelte';
import { useConvexClient } from "convex-svelte"; import { useConvexClient } from 'convex-svelte';
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from "convex-svelte"; import { useQuery } from 'convex-svelte';
import { import {
registrarServiceWorker, solicitarPushSubscription,
solicitarPushSubscription, subscriptionToJSON,
subscriptionToJSON, removerPushSubscription
removerPushSubscription, } from '$lib/utils/notifications';
} from "$lib/utils/notifications";
const client = useConvexClient(); const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
// Capturar erros de Promise não tratados relacionados a message channel // Capturar erros de Promise não tratados relacionados a message channel
// Este erro geralmente vem de extensões do Chrome ou comunicação com Service Worker // Este erro geralmente vem de extensões do Chrome ou comunicação com Service Worker
if (typeof window !== "undefined") { if (typeof window !== 'undefined') {
window.addEventListener( window.addEventListener(
"unhandledrejection", 'unhandledrejection',
(event: PromiseRejectionEvent) => { (event: PromiseRejectionEvent) => {
const reason = event.reason; const reason = event.reason;
const errorMessage = reason?.message || reason?.toString() || ""; const errorMessage = reason?.message || reason?.toString() || '';
// Filtrar apenas erros relacionados a message channel fechado // Filtrar apenas erros relacionados a message channel fechado
if ( if (
errorMessage.includes("message channel closed") || errorMessage.includes('message channel closed') ||
errorMessage.includes("asynchronous response") || errorMessage.includes('asynchronous response') ||
(errorMessage.includes("message channel") && (errorMessage.includes('message channel') && errorMessage.includes('closed'))
errorMessage.includes("closed")) ) {
) { // Prevenir que o erro apareça no console
// Prevenir que o erro apareça no console event.preventDefault();
event.preventDefault(); // Silenciar o erro - é geralmente causado por extensões do Chrome
// Silenciar o erro - é geralmente causado por extensões do Chrome return false;
return false; }
} },
}, { capture: true }
{ capture: true }, );
); }
}
onMount(async () => { onMount(async () => {
let checkAuth: ReturnType<typeof setInterval> | null = null; let checkAuth: ReturnType<typeof setInterval> | null = null;
let mounted = true; let mounted = true;
// Aguardar usuário estar autenticado // Aguardar usuário estar autenticado
checkAuth = setInterval(async () => { checkAuth = setInterval(async () => {
if (currentUser?.data && mounted) { if (currentUser?.data && mounted) {
clearInterval(checkAuth!); clearInterval(checkAuth!);
checkAuth = null; checkAuth = null;
try { try {
await registrarPushSubscription(); await registrarPushSubscription();
} catch (error) { } catch (error) {
// Silenciar erros de push subscription para evitar spam no console // Silenciar erros de push subscription para evitar spam no console
if ( if (error instanceof Error && !error.message.includes('message channel')) {
error instanceof Error && console.error('Erro ao configurar push notifications:', error);
!error.message.includes("message channel") }
) { }
console.error("Erro ao configurar push notifications:", error); }
} }, 500);
}
}
}, 500);
// Limpar intervalo após 30 segundos (timeout) // Limpar intervalo após 30 segundos (timeout)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (checkAuth) { if (checkAuth) {
clearInterval(checkAuth); clearInterval(checkAuth);
checkAuth = null; checkAuth = null;
} }
}, 30000); }, 30000);
return () => { return () => {
mounted = false; mounted = false;
if (checkAuth) { if (checkAuth) {
clearInterval(checkAuth); clearInterval(checkAuth);
} }
clearTimeout(timeout); clearTimeout(timeout);
}; };
}); });
async function registrarPushSubscription() { async function registrarPushSubscription() {
try { try {
// Verificar se Service Worker está disponível antes de tentar // Verificar se Service Worker está disponível antes de tentar
if (!("serviceWorker" in navigator) || !("PushManager" in window)) { if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
return; return;
} }
// Solicitar subscription com timeout para evitar travamentos // Solicitar subscription com timeout para evitar travamentos
const subscriptionPromise = solicitarPushSubscription(); const subscriptionPromise = solicitarPushSubscription();
const timeoutPromise = new Promise<null>((resolve) => const timeoutPromise = new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000));
setTimeout(() => resolve(null), 5000),
);
const subscription = await Promise.race([ const subscription = await Promise.race([subscriptionPromise, timeoutPromise]);
subscriptionPromise,
timeoutPromise,
]);
if (!subscription) { if (!subscription) {
// Não logar para evitar spam no console quando VAPID key não está configurada // Não logar para evitar spam no console quando VAPID key não está configurada
return; return;
} }
// Converter para formato serializável // Converter para formato serializável
const subscriptionData = subscriptionToJSON(subscription); const subscriptionData = subscriptionToJSON(subscription);
// Registrar no backend com timeout // Registrar no backend com timeout
const mutationPromise = client.mutation( const mutationPromise = client.mutation(api.pushNotifications.registrarPushSubscription, {
api.pushNotifications.registrarPushSubscription, endpoint: subscriptionData.endpoint,
{ keys: subscriptionData.keys,
endpoint: subscriptionData.endpoint, userAgent: navigator.userAgent
keys: subscriptionData.keys, });
userAgent: navigator.userAgent,
},
);
const timeoutMutationPromise = new Promise<{ const timeoutMutationPromise = new Promise<{
sucesso: false; sucesso: false;
erro: string; erro: string;
}>((resolve) => }>((resolve) => setTimeout(() => resolve({ sucesso: false, erro: 'Timeout' }), 5000));
setTimeout(() => resolve({ sucesso: false, erro: "Timeout" }), 5000),
);
const resultado = await Promise.race([ const resultado = await Promise.race([mutationPromise, timeoutMutationPromise]);
mutationPromise,
timeoutMutationPromise,
]);
if (resultado.sucesso) { if (resultado.sucesso) {
console.log("✅ Push subscription registrada com sucesso"); console.log('✅ Push subscription registrada com sucesso');
} else if (resultado.erro && !resultado.erro.includes("Timeout")) { } else if (resultado.erro && !resultado.erro.includes('Timeout')) {
console.error( console.error('❌ Erro ao registrar push subscription:', resultado.erro);
"❌ Erro ao registrar push subscription:", }
resultado.erro, } catch (error) {
); // Ignorar erros relacionados a message channel fechado
} if (error instanceof Error && error.message.includes('message channel')) {
} catch (error) { return;
// Ignorar erros relacionados a message channel fechado }
if (error instanceof Error && error.message.includes("message channel")) { console.error('❌ Erro ao configurar push notifications:', error);
return; }
} }
console.error("❌ Erro ao configurar push notifications:", error);
}
}
// Remover subscription ao fazer logout // Remover subscription ao fazer logout
$effect(() => { $effect(() => {
if (!currentUser?.data) { if (!currentUser?.data) {
removerPushSubscription().then(() => { removerPushSubscription().then(() => {
console.log("Push subscription removida"); console.log('Push subscription removida');
}); });
} }
}); });
</script> </script>
<!-- Componente invisível - apenas lógica --> <!-- Componente invisível - apenas lógica -->

View File

@@ -0,0 +1,121 @@
<script lang="ts">
const { dueDate, startedAt, finishedAt, status, expectedDuration } = $props<{
dueDate: number | undefined;
startedAt: number | undefined;
finishedAt: number | undefined;
status: 'pending' | 'in_progress' | 'completed' | 'blocked';
expectedDuration: number | undefined;
}>();
let now = $state(Date.now());
// Atualizar a cada minuto
$effect(() => {
const interval = setInterval(() => {
now = Date.now();
}, 60000); // Atualizar a cada minuto
return () => clearInterval(interval);
});
let tempoInfo = $derived.by(() => {
// Para etapas concluídas
if (status === 'completed' && finishedAt && startedAt) {
const tempoExecucao = finishedAt - startedAt;
const diasExecucao = Math.floor(tempoExecucao / (1000 * 60 * 60 * 24));
const horasExecucao = Math.floor((tempoExecucao % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
// Verificar se foi dentro ou fora do prazo
const dentroDoPrazo = dueDate ? finishedAt <= dueDate : true;
const diasAtrasado =
!dentroDoPrazo && dueDate ? Math.floor((finishedAt - dueDate) / (1000 * 60 * 60 * 24)) : 0;
return {
tipo: 'concluida',
dias: diasExecucao,
horas: horasExecucao,
dentroDoPrazo,
diasAtrasado
};
}
// Para etapas em andamento
if (status === 'in_progress' && startedAt && expectedDuration) {
// Calcular prazo baseado em startedAt + expectedDuration
const prazoCalculado = startedAt + expectedDuration * 24 * 60 * 60 * 1000;
const diff = prazoCalculado - now;
const dias = Math.floor(Math.abs(diff) / (1000 * 60 * 60 * 24));
const horas = Math.floor((Math.abs(diff) % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
return {
tipo: 'andamento',
atrasado: diff < 0,
dias,
horas
};
}
// Para etapas pendentes ou bloqueadas, não mostrar nada
return null;
});
</script>
{#if tempoInfo}
{@const info = tempoInfo}
<div class="flex items-center gap-2">
{#if info.tipo === 'concluida'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 {info.dentroDoPrazo ? 'text-info' : 'text-error'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm font-medium {info.dentroDoPrazo ? 'text-info' : 'text-error'}">
Concluída em {info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
{info.horas}
{info.horas === 1 ? 'hora' : 'horas'}
{#if !info.dentroDoPrazo && info.diasAtrasado > 0}
<span>
({info.diasAtrasado} {info.diasAtrasado === 1 ? 'dia' : 'dias'} fora do prazo)</span
>
{/if}
</span>
{:else if info.tipo === 'andamento'}
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 {info.atrasado ? 'text-error' : 'text-success'}"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span class="text-sm font-medium {info.atrasado ? 'text-error' : 'text-success'}">
{#if info.atrasado}
{info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
{info.horas}
{info.horas === 1 ? 'hora' : 'horas'} atrasado
{:else}
{info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
{info.horas}
{info.horas === 1 ? 'hora' : 'horas'} para concluir
{/if}
</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,14 @@
<script lang="ts">
interface Props {
class?: string;
}
let { class: className = '' }: Props = $props();
</script>
<div
class={[
'via-base-content/20 absolute inset-0 -translate-x-full bg-linear-to-r from-transparent to-transparent transition-transform duration-1000 group-hover:translate-x-full',
className
]}
></div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient } from 'convex-svelte';
interface Periodo { interface Periodo {
id: string; id: string;
@@ -15,7 +15,7 @@
onCancelar?: () => void; onCancelar?: () => void;
} }
let { funcionarioId, onSucesso, onCancelar }: Props = $props(); const { funcionarioId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();

View File

@@ -0,0 +1,89 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { AlertTriangle, Package } from 'lucide-svelte';
interface Props {
alerta: {
_id: Id<'alertasEstoque'>;
materialId: Id<'materiais'>;
tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria';
quantidadeAtual: number;
quantidadeMinima: number;
status: 'ativo' | 'resolvido' | 'ignorado';
criadoEm: number;
};
materialNome?: string;
materialCodigo?: string;
}
let { alerta, materialNome = 'Carregando...', materialCodigo = '' }: Props = $props();
function getTipoBadge(tipo: string) {
switch (tipo) {
case 'estoque_zerado':
return 'badge-error';
case 'estoque_minimo':
return 'badge-warning';
case 'reposicao_necessaria':
return 'badge-info';
default:
return 'badge-ghost';
}
}
function getTipoLabel(tipo: string) {
switch (tipo) {
case 'estoque_zerado':
return 'Estoque Zerado';
case 'estoque_minimo':
return 'Estoque Mínimo';
case 'reposicao_necessaria':
return 'Reposição Necessária';
default:
return tipo;
}
}
{@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual}
</script>
<div class="card bg-base-100 shadow-lg border-2 {alerta.status === 'ativo' ? 'border-warning' : 'border-base-300'}">
<div class="card-body">
<div class="flex items-start justify-between mb-2">
<div class="flex-1">
<div class="flex items-center gap-2 mb-1">
<AlertTriangle class="h-5 w-5 text-warning" />
<h3 class="card-title text-lg">{materialNome}</h3>
</div>
{#if materialCodigo}
<p class="text-sm text-base-content/60 font-mono mb-2">Código: {materialCodigo}</p>
{/if}
</div>
<span class="badge {getTipoBadge(alerta.tipo)}">{getTipoLabel(alerta.tipo)}</span>
</div>
<div class="divider my-2"></div>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-xs text-base-content/60 mb-1">Quantidade Atual</p>
<p class="text-2xl font-bold text-error">{alerta.quantidadeAtual}</p>
</div>
<div>
<p class="text-xs text-base-content/60 mb-1">Quantidade Mínima</p>
<p class="text-xl font-medium">{alerta.quantidadeMinima}</p>
</div>
</div>
<div class="mt-2">
<p class="text-xs text-base-content/60 mb-1">Faltam</p>
<p class="text-lg font-bold text-warning">{diferenca} unidades</p>
</div>
<div class="mt-2 text-xs text-base-content/60">
Criado em: {new Date(alerta.criadoEm).toLocaleString('pt-BR')}
</div>
</div>
</div>

View File

@@ -0,0 +1,349 @@
<script lang="ts">
import { onMount, onDestroy, tick } from 'svelte';
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
import { Camera, X, Scan } from 'lucide-svelte';
interface Props {
onScan: (code: string) => void;
onError?: (error: string) => void;
enabled?: boolean;
}
let { onScan, onError, enabled = $bindable(false) }: Props = $props();
let scanner: Html5Qrcode | null = $state(null);
let scanning = $state(false);
let error = $state<string | null>(null);
let scannerElement = $state<HTMLDivElement | null>(null);
let inputBuffer = $state('');
let inputTimeout: ReturnType<typeof setTimeout> | null = null;
const scannerId = `barcode-scanner-${Math.random().toString(36).substring(7)}`;
// Configuração do scanner
const config = {
fps: 10,
qrbox: { width: 250, height: 250 },
aspectRatio: 1.0
// A biblioteca html5-qrcode suporta automaticamente vários formatos:
// EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, QR Code, etc.
};
async function startScanning() {
// Aguardar o DOM ser atualizado
await tick();
// Verificar se o elemento existe no DOM
const element = document.getElementById(scannerId);
if (!element) {
// Tentar novamente após um pequeno delay
setTimeout(async () => {
const retryElement = document.getElementById(scannerId);
if (!retryElement) {
const errorMsg = 'Elemento do scanner não encontrado no DOM';
error = errorMsg;
scanning = false;
if (onError) {
onError(errorMsg);
}
return;
}
await startScanningInternal();
}, 100);
return;
}
await startScanningInternal();
}
async function startScanningInternal() {
const element = document.getElementById(scannerId);
if (!element) {
const errorMsg = 'Elemento do scanner não encontrado';
error = errorMsg;
scanning = false;
if (onError) {
onError(errorMsg);
}
return;
}
try {
error = null;
scanning = true;
scanner = new Html5Qrcode(scannerId);
// Tentar primeiro com câmera traseira (environment), depois frontal (user)
let cameraConfig = { facingMode: 'environment' as const };
try {
await scanner.start(
cameraConfig,
config,
(decodedText: string, decodedResult: Html5QrcodeResult) => {
handleScannedCode(decodedText);
},
(errorMessage: string) => {
// Ignorar erros de leitura contínua
if (errorMessage.includes('No MultiFormat Readers')) {
return;
}
}
);
} catch (cameraError) {
// Se falhar com câmera traseira, tentar com frontal (útil para PC)
if (cameraConfig.facingMode === 'environment') {
console.log('Tentando câmera frontal...');
cameraConfig = { facingMode: 'user' };
await scanner.start(
cameraConfig,
config,
(decodedText: string, decodedResult: Html5QrcodeResult) => {
handleScannedCode(decodedText);
},
(errorMessage: string) => {
if (errorMessage.includes('No MultiFormat Readers')) {
return;
}
}
);
} else {
throw cameraError;
}
}
} catch (err) {
let errorMessage = 'Erro ao iniciar scanner';
if (err instanceof Error) {
errorMessage = err.message;
// Mensagens de erro mais amigáveis
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
errorMessage = 'Permissão de câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No camera found')) {
errorMessage = 'Nenhuma câmera encontrada. Verifique se há uma câmera conectada ao dispositivo.';
} else if (errorMessage.includes('NotReadableError') || errorMessage.includes('TrackStartError')) {
errorMessage = 'Câmera está sendo usada por outro aplicativo. Feche outros aplicativos que possam estar usando a câmera.';
} else if (errorMessage.includes('OverconstrainedError')) {
errorMessage = 'Câmera não suporta as configurações necessárias.';
}
}
error = errorMessage;
scanning = false;
// Limpar scanner em caso de erro
if (scanner) {
try {
await scanner.clear();
} catch (clearErr) {
console.error('Erro ao limpar scanner:', clearErr);
}
scanner = null;
}
if (onError) {
onError(errorMessage);
}
console.error('Erro ao iniciar scanner:', err);
}
}
async function stopScanning() {
if (scanner) {
try {
await scanner.stop();
await scanner.clear();
} catch (err) {
console.error('Erro ao parar scanner:', err);
}
scanner = null;
}
scanning = false;
error = null;
}
function handleScannedCode(code: string) {
if (code && code.trim()) {
stopScanning();
enabled = false;
onScan(code.trim());
}
}
// Suporte para leitores USB/Bluetooth (captura de eventos de teclado)
function handleKeyPress(event: KeyboardEvent) {
// Ignorar se estiver digitando em um input
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
event.target instanceof HTMLSelectElement
) {
return;
}
// Leitores de código de barras geralmente enviam caracteres rapidamente
if (event.key === 'Enter' && inputBuffer.trim()) {
event.preventDefault();
handleScannedCode(inputBuffer.trim());
inputBuffer = '';
if (inputTimeout) {
clearTimeout(inputTimeout);
inputTimeout = null;
}
return;
}
// Acumular caracteres digitados rapidamente
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
inputBuffer += event.key;
if (inputTimeout) {
clearTimeout(inputTimeout);
}
inputTimeout = setTimeout(() => {
inputBuffer = '';
}, 100);
}
}
function toggleScanner() {
if (scanning) {
stopScanning();
enabled = false;
} else {
enabled = true;
}
}
$effect(() => {
if (enabled && !scanning) {
// Aguardar um pouco para garantir que o DOM foi atualizado
setTimeout(() => {
if (enabled && !scanning) {
startScanning();
}
}, 50);
} else if (!enabled && scanning) {
stopScanning();
}
});
onMount(() => {
window.addEventListener('keydown', handleKeyPress);
});
onDestroy(() => {
window.removeEventListener('keydown', handleKeyPress);
if (inputTimeout) {
clearTimeout(inputTimeout);
}
stopScanning();
});
</script>
<div class="barcode-scanner">
{#if enabled}
<div class="card bg-base-100 border border-base-300 shadow-xl">
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold flex items-center gap-2">
<Scan class="h-5 w-5" />
Leitor de Código de Barras
</h3>
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => {
enabled = false;
}}
aria-label="Fechar scanner"
>
<X class="h-5 w-5" />
</button>
</div>
{#if error}
<div class="alert alert-error mb-4">
<span>{error}</span>
<button
type="button"
class="btn btn-sm btn-ghost mt-2"
onclick={async () => {
error = null;
scanning = false;
// Limpar scanner anterior se existir
if (scanner) {
try {
await scanner.clear();
} catch (err) {
console.error('Erro ao limpar scanner:', err);
}
scanner = null;
}
// Aguardar um pouco antes de tentar novamente
await tick();
setTimeout(() => {
if (enabled && !scanning) {
startScanning();
}
}, 100);
}}
>
Tentar novamente
</button>
</div>
{/if}
<!-- Sempre renderizar o elemento quando enabled for true -->
<div class="relative">
<div id={scannerId} bind:this={scannerElement}></div>
{#if scanning}
<div class="mt-4 text-center">
<p class="text-sm text-base-content/70">
Posicione o código de barras dentro da área de leitura
</p>
<p class="text-xs text-base-content/50 mt-2">
Ou use um leitor USB/Bluetooth para escanear
</p>
</div>
{:else if !error}
<div class="text-center py-8">
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
<p class="text-base-content/70">Iniciando scanner...</p>
</div>
{/if}
</div>
<div class="card-actions justify-end mt-4">
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
Cancelar
</button>
</div>
</div>
</div>
{:else}
<button
type="button"
class="btn btn-outline btn-primary"
onclick={toggleScanner}
aria-label="Abrir leitor de código de barras"
>
<Scan class="h-5 w-5" />
Ler Código de Barras
</button>
{/if}
</div>
<style>
.barcode-scanner {
position: relative;
}
[id^='barcode-scanner-'] {
width: 100%;
max-width: 500px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
estoqueAtual: number;
estoqueMinimo: number;
estoqueMaximo?: number;
unidadeMedida: string;
}
let { estoqueAtual, estoqueMinimo, estoqueMaximo, unidadeMedida }: Props = $props();
{@const porcentagem = estoqueMaximo
? Math.min(100, (estoqueAtual / estoqueMaximo) * 100)
: estoqueAtual > estoqueMinimo
? 100
: Math.max(0, (estoqueAtual / estoqueMinimo) * 100)}
{@const cor = estoqueAtual <= estoqueMinimo
? 'text-error'
: estoqueMaximo && estoqueAtual >= estoqueMaximo * 0.8
? 'text-warning'
: 'text-success'}
{@const corBarra = estoqueAtual <= estoqueMinimo
? 'bg-error'
: estoqueMaximo && estoqueAtual >= estoqueMaximo * 0.8
? 'bg-warning'
: 'bg-success'}
</script>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-sm font-medium">Estoque</span>
<span class="text-sm font-bold {cor}">
{estoqueAtual} {unidadeMedida}
</span>
</div>
<div class="w-full bg-base-300 rounded-full h-3 overflow-hidden">
<div
class="h-full {corBarra} transition-all duration-500"
style="width: {porcentagem}%"
></div>
</div>
<div class="flex items-center justify-between text-xs text-base-content/60">
<span>Mín: {estoqueMinimo}</span>
{#if estoqueMaximo}
<span>Máx: {estoqueMaximo}</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import { Clock, User, FileText } from 'lucide-svelte';
interface HistoricoItem {
acao: string;
usuarioId: string;
usuarioNome?: string;
timestamp: number;
observacoes?: string;
dadosAnteriores?: string;
dadosNovos?: string;
}
interface Props {
historico: HistoricoItem[];
}
let { historico }: Props = $props();
function getAcaoLabel(acao: string) {
switch (acao) {
case 'criacao':
return 'Criação';
case 'edicao':
return 'Edição';
case 'exclusao':
return 'Exclusão';
case 'movimentacao':
return 'Movimentação';
default:
return acao;
}
}
function getAcaoColor(acao: string) {
switch (acao) {
case 'criacao':
return 'text-success';
case 'edicao':
return 'text-info';
case 'exclusao':
return 'text-error';
case 'movimentacao':
return 'text-warning';
default:
return 'text-base-content';
}
}
</script>
<div class="space-y-4">
{#each historico as item, index}
<div class="flex gap-4">
<!-- Linha vertical -->
{#if index < historico.length - 1}
<div class="flex flex-col items-center">
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<Clock class="h-6 w-6 text-primary" />
</div>
<div class="w-0.5 h-full bg-base-300 my-2"></div>
</div>
{:else}
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
<Clock class="h-6 w-6 text-primary" />
</div>
{/if}
<!-- Conteúdo -->
<div class="flex-1 pb-4">
<div class="card bg-base-100 shadow">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<User class="h-4 w-4 text-base-content/60" />
<span class="font-medium">{item.usuarioNome || 'Usuário'}</span>
</div>
<span class="badge {getAcaoColor(item.acao)} badge-outline">
{getAcaoLabel(item.acao)}
</span>
</div>
<p class="text-sm text-base-content/60 mb-2">
{new Date(item.timestamp).toLocaleString('pt-BR')}
</p>
{#if item.observacoes}
<div class="flex items-start gap-2 mt-2">
<FileText class="h-4 w-4 text-base-content/60 mt-0.5" />
<p class="text-sm text-base-content/70">{item.observacoes}</p>
</div>
{/if}
</div>
</div>
</div>
</div>
{/each}
</div>

View File

@@ -0,0 +1,442 @@
<script lang="ts">
import { Image as ImageIcon, Upload, X, Camera } from 'lucide-svelte';
interface Props {
value?: string | null;
onChange?: (base64: string | null) => void;
maxSizeMB?: number;
maxWidth?: number;
maxHeight?: number;
}
let {
value = $bindable(null),
onChange,
maxSizeMB = 5,
maxWidth = 1200,
maxHeight = 1200
}: Props = $props();
let preview = $state<string | null>(value);
let error = $state<string | null>(null);
let inputElement: HTMLInputElement | null = null;
let showCamera = $state(false);
let videoElement: HTMLVideoElement | null = null;
let stream: MediaStream | null = null;
let capturing = $state(false);
function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
error = null;
// Validar tamanho
if (file.size > maxSizeMB * 1024 * 1024) {
error = `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`;
return;
}
// Validar tipo
if (!file.type.startsWith('image/')) {
error = 'Por favor, selecione um arquivo de imagem';
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result as string;
if (result) {
// Redimensionar imagem se necessário
resizeImage(result, maxWidth, maxHeight)
.then((resized) => {
preview = resized;
value = resized;
if (onChange) {
onChange(resized);
}
})
.catch((err) => {
error = err instanceof Error ? err.message : 'Erro ao processar imagem';
});
}
};
reader.onerror = () => {
error = 'Erro ao ler arquivo';
};
reader.readAsDataURL(file);
}
function resizeImage(dataUrl: string, maxWidth: number, maxHeight: number): Promise<string> {
// Verificar se estamos no browser (não durante SSR)
if (typeof window === 'undefined') {
return Promise.reject(new Error('resizeImage não pode ser executada durante SSR'));
}
return new Promise((resolve, reject) => {
const img = new window.Image();
img.onload = () => {
let width = img.width;
let height = img.height;
// Calcular novas dimensões mantendo proporção
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width = width * ratio;
height = height * ratio;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('Não foi possível criar contexto do canvas'));
return;
}
ctx.drawImage(img, 0, 0, width, height);
const resizedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
resolve(resizedDataUrl);
};
img.onerror = () => {
reject(new Error('Erro ao carregar imagem'));
};
img.src = dataUrl;
});
}
function removeImage() {
preview = null;
value = null;
if (inputElement) {
inputElement.value = '';
}
if (onChange) {
onChange(null);
}
}
function triggerFileInput() {
inputElement?.click();
}
async function openCamera() {
// Se já estiver inicializando ou já tiver stream, não fazer nada
if (stream) {
return;
}
// Primeiro, abrir o modal
showCamera = true;
capturing = false;
error = null;
// Aguardar o próximo tick para garantir que o DOM foi atualizado
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
try {
// Verificar se a API está disponível
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
error = 'Câmera não disponível neste dispositivo ou navegador não suporta acesso à câmera';
showCamera = false;
return;
}
// Aguardar o elemento de vídeo estar disponível no DOM
let attempts = 0;
while (!videoElement && attempts < 30) {
await new Promise((resolve) => setTimeout(resolve, 50));
attempts++;
}
if (!videoElement) {
throw new Error('Elemento de vídeo não encontrado no DOM');
}
// Solicitar acesso à câmera
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment', // Câmera traseira por padrão
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
// Atribuir stream ao vídeo
videoElement.srcObject = stream;
// Aguardar o vídeo estar pronto e começar a reproduzir
await videoElement.play();
// Aguardar metadata estar carregado
if (videoElement.readyState < 2) {
await new Promise<void>((resolve, reject) => {
if (!videoElement) {
reject(new Error('Elemento de vídeo não encontrado'));
return;
}
const onLoadedMetadata = () => {
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('error', onError);
resolve();
};
const onError = () => {
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('error', onError);
reject(new Error('Erro ao carregar vídeo'));
};
videoElement.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
videoElement.addEventListener('error', onError, { once: true });
});
}
capturing = true;
} catch (err) {
console.error('Erro ao acessar câmera:', err);
let errorMessage = 'Erro ao acessar câmera';
if (err instanceof Error) {
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
errorMessage =
'Permissão de acesso à câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
errorMessage = 'Nenhuma câmera encontrada no dispositivo.';
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
errorMessage = 'Câmera está sendo usada por outro aplicativo.';
} else {
errorMessage = err.message || errorMessage;
}
}
error = errorMessage;
showCamera = false;
capturing = false;
stopCamera();
}
}
function stopCamera() {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
if (videoElement) {
videoElement.srcObject = null;
}
capturing = false;
}
function closeCamera() {
stopCamera();
showCamera = false;
error = null;
}
async function capturePhoto() {
if (!videoElement) return;
try {
// Criar canvas para capturar o frame
const canvas = document.createElement('canvas');
canvas.width = videoElement.videoWidth;
canvas.height = videoElement.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
error = 'Não foi possível criar contexto do canvas';
return;
}
// Desenhar o frame atual do vídeo no canvas
ctx.drawImage(videoElement, 0, 0);
// Converter para base64
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
// Redimensionar e processar
const resized = await resizeImage(dataUrl, maxWidth, maxHeight);
preview = resized;
value = resized;
if (onChange) {
onChange(resized);
}
// Fechar câmera
closeCamera();
} catch (err) {
error = err instanceof Error ? err.message : 'Erro ao capturar foto';
console.error('Erro ao capturar foto:', err);
}
}
// Sincronizar preview com value sempre que value mudar
$effect(() => {
// Acessar value para criar dependência reativa
const currentValue = value;
// Sempre sincronizar quando value mudar
if (currentValue !== preview) {
preview = currentValue;
}
});
// Limpar stream quando o componente for desmontado
$effect(() => {
return () => {
stopCamera();
};
});
</script>
<div class="image-upload">
<input
type="file"
accept="image/*"
class="hidden"
bind:this={inputElement}
onchange={handleFileSelect}
aria-label="Selecionar imagem do produto"
/>
{#if preview}
<div class="relative inline-block">
<img
src={preview}
alt="Preview da imagem do produto"
class="max-h-64 max-w-full rounded-lg"
/>
<button
type="button"
class="btn btn-sm btn-circle btn-error absolute top-2 right-2"
onclick={removeImage}
aria-label="Remover imagem"
>
<X class="h-4 w-4" />
</button>
</div>
{:else}
<div class="flex flex-col gap-4">
<div
class="border-base-300 hover:border-primary cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors"
onclick={triggerFileInput}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
triggerFileInput();
}
}}
>
<Upload class="text-base-content/40 mx-auto mb-4 h-12 w-12" />
<p class="text-base-content/70 mb-2 font-medium">Clique para fazer upload da imagem</p>
<p class="text-base-content/50 text-sm">
PNG, JPG ou GIF até {maxSizeMB}MB
</p>
</div>
<div class="divider text-sm">ou</div>
<button type="button" class="btn btn-outline btn-primary w-full" onclick={openCamera}>
<Camera class="h-5 w-5" />
Capturar da Câmera
</button>
</div>
{/if}
{#if error}
<div class="alert alert-error mt-4">
<span>{error}</span>
</div>
{/if}
{#if preview}
<div class="mt-4 flex gap-2">
<button
type="button"
class="btn btn-sm btn-outline btn-primary flex-1"
onclick={triggerFileInput}
>
<ImageIcon class="h-4 w-4" />
Alterar Imagem
</button>
<button type="button" class="btn btn-sm btn-outline btn-primary flex-1" onclick={openCamera}>
<Camera class="h-4 w-4" />
Capturar Foto
</button>
</div>
{/if}
</div>
<!-- Modal da Câmera -->
{#if showCamera}
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
onclick={closeCamera}
>
<div
class="bg-base-100 mx-4 w-full max-w-2xl rounded-lg p-6 shadow-2xl"
onclick={(e) => e.stopPropagation()}
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-xl font-bold">Capturar Foto</h3>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={closeCamera}
aria-label="Fechar câmera"
>
<X class="h-5 w-5" />
</button>
</div>
<div
class="relative mb-4 overflow-hidden rounded-lg bg-black"
style="aspect-ratio: 4/3; min-height: 300px;"
>
{#if showCamera}
<video
bind:this={videoElement}
autoplay
playsinline
muted
class="h-full w-full object-cover"
style="transform: scaleX(-1); opacity: {capturing
? '1'
: '0'}; transition: opacity 0.3s;"
></video>
{/if}
{#if !capturing}
<div class="absolute inset-0 z-10 flex h-full items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary mb-2"></span>
<p class="text-base-content/70 text-sm">Iniciando câmera...</p>
</div>
</div>
{/if}
</div>
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-ghost" onclick={closeCamera}> Cancelar </button>
<button type="button" class="btn btn-primary" onclick={capturePhoto} disabled={!capturing}>
<Camera class="h-5 w-5" />
Capturar Foto
</button>
</div>
</div>
</div>
{/if}
<style>
.image-upload {
width: 100%;
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { Package, AlertTriangle } from 'lucide-svelte';
interface Props {
material: {
_id: Id<'materiais'>;
codigo: string;
nome: string;
descricao?: string;
categoria: string;
estoqueAtual: number;
estoqueMinimo: number;
unidadeMedida: string;
ativo: boolean;
};
}
let { material }: Props = $props();
</script>
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<div class="flex items-center gap-2 mb-2">
<Package class="h-5 w-5 text-primary" />
<h3 class="card-title text-lg">{material.nome}</h3>
</div>
<p class="text-sm text-base-content/60 font-mono mb-1">Código: {material.codigo}</p>
{#if material.descricao}
<p class="text-sm text-base-content/70 mb-2">{material.descricao}</p>
{/if}
<div class="flex items-center gap-2 mb-2">
<span class="badge badge-outline">{material.categoria}</span>
{#if material.ativo}
<span class="badge badge-success">Ativo</span>
{:else}
<span class="badge badge-error">Inativo</span>
{/if}
</div>
</div>
</div>
<div class="divider my-2"></div>
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-base-content/60">Estoque Atual</p>
<div class="flex items-center gap-2">
<p class="text-2xl font-bold {material.estoqueAtual <= material.estoqueMinimo ? 'text-error' : 'text-success'}">
{material.estoqueAtual}
</p>
<span class="text-sm text-base-content/60">{material.unidadeMedida}</span>
{#if material.estoqueAtual <= material.estoqueMinimo}
<AlertTriangle class="h-5 w-5 text-warning" />
{/if}
</div>
</div>
<div class="text-right">
<p class="text-xs text-base-content/60">Mínimo</p>
<p class="text-lg font-medium">{material.estoqueMinimo} {material.unidadeMedida}</p>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,219 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { ArrowDown, ArrowUp, Settings } from 'lucide-svelte';
interface Props {
tipo: 'entrada' | 'saida' | 'ajuste';
materialId?: Id<'materiais'> | '';
onSubmit: (data: {
materialId: Id<'materiais'>;
quantidade: number;
motivo: string;
documento?: string;
funcionarioId?: Id<'funcionarios'>;
setorId?: Id<'setores'>;
observacoes?: string;
quantidadeNova?: number;
}) => Promise<void>;
materiais?: Array<{
_id: Id<'materiais'>;
codigo: string;
nome: string;
estoqueAtual: number;
unidadeMedida: string;
}>;
funcionarios?: Array<{
_id: Id<'funcionarios'>;
nome: string;
}>;
setores?: Array<{
_id: Id<'setores'>;
nome: string;
}>;
loading?: boolean;
}
let {
tipo,
materialId = '',
onSubmit,
materiais = [],
funcionarios = [],
setores = [],
loading = false
}: Props = $props();
let quantidade = $state(0);
let quantidadeNova = $state(0);
let motivo = $state('');
let documento = $state('');
let funcionarioId = $state<Id<'funcionarios'> | ''>('');
let setorId = $state<Id<'setores'> | ''>('');
let observacoes = $state('');
async function handleSubmit() {
if (!materialId || !motivo.trim()) {
return;
}
if (tipo === 'ajuste') {
if (quantidadeNova < 0) {
return;
}
await onSubmit({
materialId: materialId as Id<'materiais'>,
quantidadeNova,
motivo: motivo.trim(),
observacoes: observacoes.trim() || undefined
});
} else {
if (quantidade <= 0) {
return;
}
await onSubmit({
materialId: materialId as Id<'materiais'>,
quantidade,
motivo: motivo.trim(),
documento: documento.trim() || undefined,
funcionarioId: funcionarioId ? (funcionarioId as Id<'funcionarios'>) : undefined,
setorId: setorId ? (setorId as Id<'setores'>) : undefined,
observacoes: observacoes.trim() || undefined
});
}
// Limpar formulário
quantidade = 0;
quantidadeNova = 0;
motivo = '';
documento = '';
funcionarioId = '';
setorId = '';
observacoes = '';
}
</script>
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Material *</span>
</label>
<select class="select select-bordered" bind:value={materialId} required>
<option value="">Selecione um material</option>
{#each materiais as material}
<option value={material._id}>
{material.codigo} - {material.nome} (Estoque: {material.estoqueAtual}{material.unidadeMedida})
</option>
{/each}
</select>
</div>
{#if tipo === 'ajuste'}
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Nova Quantidade *</span>
</label>
<input
type="number"
class="input input-bordered"
min="0"
bind:value={quantidadeNova}
required
/>
</div>
{:else}
<div class="form-control">
<label class="label">
<span class="label-text font-bold">Quantidade *</span>
</label>
<input
type="number"
class="input input-bordered"
min="0.01"
step="0.01"
bind:value={quantidade}
required
/>
</div>
{/if}
{#if tipo === 'entrada'}
<div class="form-control">
<label class="label">
<span class="label-text">Documento (NF, etc.)</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder="Número da nota fiscal"
bind:value={documento}
/>
</div>
{:else if tipo === 'saida'}
<div class="form-control">
<label class="label">
<span class="label-text">Funcionário</span>
</label>
<select class="select select-bordered" bind:value={funcionarioId}>
<option value="">Selecione (opcional)</option>
{#each funcionarios as funcionario}
<option value={funcionario._id}>{funcionario.nome}</option>
{/each}
</select>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Setor</span>
</label>
<select class="select select-bordered" bind:value={setorId}>
<option value="">Selecione (opcional)</option>
{#each setores as setor}
<option value={setor._id}>{setor.nome}</option>
{/each}
</select>
</div>
{/if}
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text font-bold">Motivo *</span>
</label>
<input
type="text"
class="input input-bordered"
placeholder={tipo === 'entrada' ? 'Ex: Compra, Doação' : tipo === 'saida' ? 'Ex: Uso interno' : 'Ex: Inventário físico'}
bind:value={motivo}
required
/>
</div>
<div class="form-control md:col-span-2">
<label class="label">
<span class="label-text">Observações</span>
</label>
<textarea
class="textarea textarea-bordered"
placeholder="Observações adicionais (opcional)"
bind:value={observacoes}
rows="3"
></textarea>
</div>
</div>
<div class="card-actions mt-6 justify-end">
<button type="submit" class="btn {tipo === 'ajuste' ? 'btn-warning' : tipo === 'entrada' ? 'btn-success' : 'btn-error'}" disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
{:else if tipo === 'entrada'}
<ArrowDown class="h-5 w-5" />
{:else if tipo === 'saida'}
<ArrowUp class="h-5 w-5" />
{:else}
<Settings class="h-5 w-5" />
{/if}
Registrar {tipo === 'entrada' ? 'Entrada' : tipo === 'saida' ? 'Saída' : 'Ajuste'}
</button>
</div>
</form>

View File

@@ -1,10 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import CalendarioAusencias from './CalendarioAusencias.svelte';
import ErrorModal from '../ErrorModal.svelte';
import { toast } from 'svelte-sonner';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { SvelteDate } from 'svelte/reactivity';
import { Check, ChevronLeft, ChevronRight, Calendar, AlertTriangle, CheckCircle } from 'lucide-svelte';
import { parseLocalDate } from '$lib/utils/datas';
import type { toast } from 'svelte-sonner';
import ErrorModal from '../ErrorModal.svelte';
import CalendarioAusencias from './CalendarioAusencias.svelte';
interface Props { interface Props {
funcionarioId: Id<'funcionarios'>; funcionarioId: Id<'funcionarios'>;
@@ -12,7 +15,7 @@
onCancelar?: () => void; onCancelar?: () => void;
} }
let { funcionarioId, onSucesso, onCancelar }: Props = $props(); const { funcionarioId, onSucesso, onCancelar }: Props = $props();
// Cliente Convex // Cliente Convex
const client = useConvexClient(); const client = useConvexClient();
@@ -38,7 +41,7 @@
}); });
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações) // Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
const ausenciasExistentes = $derived( let ausenciasExistentes = $derived(
(ausenciasExistentesQuery?.data || []) (ausenciasExistentesQuery?.data || [])
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao') .filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
.map((a) => ({ .map((a) => ({
@@ -57,7 +60,7 @@
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
} }
const totalDias = $derived(calcularDias(dataInicio, dataFim)); let totalDias = $derived(calcularDias(dataInicio, dataFim));
// Funções de navegação // Funções de navegação
function proximoPasso() { function proximoPasso() {
@@ -67,16 +70,16 @@
return; return;
} }
const hoje = new Date(); const hoje = new SvelteDate();
hoje.setHours(0, 0, 0, 0); hoje.setHours(0, 0, 0, 0);
const inicio = new Date(dataInicio); const inicio = parseLocalDate(dataInicio);
if (inicio < hoje) { if (inicio < hoje) {
toast.error('A data de início não pode ser no passado'); toast.error('A data de início não pode ser no passado');
return; return;
} }
if (new Date(dataFim) < new Date(dataInicio)) { if (parseLocalDate(dataFim) < parseLocalDate(dataInicio)) {
toast.error('A data de fim deve ser maior ou igual à data de início'); toast.error('A data de fim deve ser maior ou igual à data de início');
return; return;
} }
@@ -132,7 +135,7 @@
mensagemErro.includes('solicitação aprovada ou pendente') mensagemErro.includes('solicitação aprovada ou pendente')
) { ) {
mensagemErroModal = 'Não é possível criar esta solicitação.'; mensagemErroModal = 'Não é possível criar esta solicitação.';
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString('pt-BR')} até ${new Date(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`; detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até ${parseLocalDate(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
mostrarModalErro = true; mostrarModalErro = true;
} else { } else {
// Outros erros continuam usando toast // Outros erros continuam usando toast
@@ -167,20 +170,7 @@
<div class="step-item"> <div class="step-item">
<div class="step-marker"> <div class="step-marker">
{#if passoAtual > 1} {#if passoAtual > 1}
<svg <Check class="h-6 w-6" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else} {:else}
{passoAtual} {passoAtual}
{/if} {/if}
@@ -195,20 +185,7 @@
<div class="step-item"> <div class="step-item">
<div class="step-marker"> <div class="step-marker">
{#if passoAtual > 2} {#if passoAtual > 2}
<svg <Check class="h-6 w-6" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
{:else} {:else}
2 2
{/if} {/if}
@@ -250,24 +227,12 @@
{#if dataInicio && dataFim} {#if dataInicio && dataFim}
<div class="alert alert-success shadow-lg"> <div class="alert alert-success shadow-lg">
<svg <CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div> <div>
<h4 class="font-bold">Período selecionado!</h4> <h4 class="font-bold">Período selecionado!</h4>
<p> <p>
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até{' '} De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias) {parseLocalDate(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
</p> </p>
</div> </div>
</div> </div>
@@ -285,38 +250,23 @@
<!-- Resumo do período --> <!-- Resumo do período -->
{#if dataInicio && dataFim} {#if dataInicio && dataFim}
<div <div class="card border-base-content/20 border-2">
class="card border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
>
<div class="card-body"> <div class="card-body">
<h4 class="card-title text-orange-700 dark:text-orange-400"> <h4 class="card-title text-orange-700 dark:text-orange-400">
<svg <Calendar class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Resumo do Período Resumo do Período
</h4> </h4>
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3"> <div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
<div> <div>
<p class="text-base-content/70 text-sm">Data Início</p> <p class="text-base-content/70 text-sm">Data Início</p>
<p class="font-bold"> <p class="font-bold">
{new Date(dataInicio).toLocaleDateString('pt-BR')} {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
</p> </p>
</div> </div>
<div> <div>
<p class="text-base-content/70 text-sm">Data Fim</p> <p class="text-base-content/70 text-sm">Data Fim</p>
<p class="font-bold"> <p class="font-bold">
{new Date(dataFim).toLocaleDateString('pt-BR')} {parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
</p> </p>
</div> </div>
<div> <div>
@@ -345,7 +295,7 @@
bind:value={motivo} bind:value={motivo}
maxlength={500} maxlength={500}
></textarea> ></textarea>
<label class="label"> <label class="label" for="motivo">
<span class="label-text-alt text-base-content/70"> <span class="label-text-alt text-base-content/70">
Mínimo 10 caracteres. Seja claro e objetivo. Mínimo 10 caracteres. Seja claro e objetivo.
</span> </span>
@@ -354,19 +304,7 @@
{#if motivo.trim().length > 0 && motivo.trim().length < 10} {#if motivo.trim().length > 0 && motivo.trim().length < 10}
<div class="alert alert-warning shadow-lg"> <div class="alert alert-warning shadow-lg">
<svg <AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>O motivo deve ter no mínimo 10 caracteres</span> <span>O motivo deve ter no mínimo 10 caracteres</span>
</div> </div>
{/if} {/if}
@@ -381,20 +319,7 @@
onclick={passoAnterior} onclick={passoAnterior}
disabled={passoAtual === 1 || processando} disabled={passoAtual === 1 || processando}
> >
<svg <ChevronLeft class="mr-2 h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
Voltar Voltar
</button> </button>
@@ -406,20 +331,7 @@
disabled={processando} disabled={processando}
> >
Próximo Próximo
<svg <ChevronRight class="ml-2 h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="ml-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button> </button>
{:else} {:else}
<button <button
@@ -432,20 +344,7 @@
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
Enviando... Enviando...
{:else} {:else}
<svg <Check class="mr-2 h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="mr-2 h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Enviar Solicitação Enviar Solicitação
{/if} {/if}
</button> </button>

View File

@@ -0,0 +1,141 @@
<script lang="ts">
import {
Mic,
MicOff,
Video,
VideoOff,
Radio,
Square,
Settings,
PhoneOff,
Circle
} from 'lucide-svelte';
interface Props {
audioHabilitado: boolean;
videoHabilitado: boolean;
gravando: boolean;
ehAnfitriao: boolean;
duracaoSegundos: number;
onToggleAudio: () => void;
onToggleVideo: () => void;
onIniciarGravacao: () => void;
onPararGravacao: () => void;
onAbrirConfiguracoes: () => void;
onEncerrar: () => void;
}
const {
audioHabilitado,
videoHabilitado,
gravando,
ehAnfitriao,
duracaoSegundos,
onToggleAudio,
onToggleVideo,
onIniciarGravacao,
onPararGravacao,
onAbrirConfiguracoes,
onEncerrar
}: Props = $props();
// Formatar duração para HH:MM:SS
function formatarDuracao(segundos: number): string {
const horas = Math.floor(segundos / 3600);
const minutos = Math.floor((segundos % 3600) / 60);
const segs = segundos % 60;
if (horas > 0) {
return `${horas.toString().padStart(2, '0')}:${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
}
return `${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
}
let duracaoFormatada = $derived(formatarDuracao(duracaoSegundos));
</script>
<div class="bg-base-200 flex items-center justify-between gap-2 px-4 py-3">
<!-- Contador de duração -->
<div class="text-base-content flex items-center gap-2 font-mono text-sm">
<Circle class="text-error h-2 w-2 fill-current" />
<span>{duracaoFormatada}</span>
</div>
<!-- Controles principais -->
<div class="flex items-center gap-2">
<!-- Toggle Áudio -->
<button
type="button"
class="btn btn-circle btn-sm"
class:btn-primary={audioHabilitado}
class:btn-error={!audioHabilitado}
onclick={onToggleAudio}
title={audioHabilitado ? 'Desabilitar áudio' : 'Habilitar áudio'}
aria-label={audioHabilitado ? 'Desabilitar áudio' : 'Habilitar áudio'}
>
{#if audioHabilitado}
<Mic class="h-4 w-4" />
{:else}
<MicOff class="h-4 w-4" />
{/if}
</button>
<!-- Toggle Vídeo -->
<button
type="button"
class="btn btn-circle btn-sm"
class:btn-primary={videoHabilitado}
class:btn-error={!videoHabilitado}
onclick={onToggleVideo}
title={videoHabilitado ? 'Desabilitar vídeo' : 'Habilitar vídeo'}
aria-label={videoHabilitado ? 'Desabilitar vídeo' : 'Habilitar vídeo'}
>
{#if videoHabilitado}
<Video class="h-4 w-4" />
{:else}
<VideoOff class="h-4 w-4" />
{/if}
</button>
<!-- Gravação (apenas anfitrião) -->
{#if ehAnfitriao}
<button
type="button"
class="btn btn-circle btn-sm"
class:btn-primary={!gravando}
class:btn-error={gravando}
onclick={gravando ? onPararGravacao : onIniciarGravacao}
title={gravando ? 'Parar gravação' : 'Iniciar gravação'}
aria-label={gravando ? 'Parar gravação' : 'Iniciar gravação'}
>
{#if gravando}
<Square class="h-4 w-4" />
{:else}
<Radio class="h-4 w-4 fill-current" />
{/if}
</button>
{/if}
<!-- Configurações -->
<button
type="button"
class="btn btn-circle btn-sm btn-ghost"
onclick={onAbrirConfiguracoes}
title="Configurações"
aria-label="Configurações"
>
<Settings class="h-4 w-4" />
</button>
<!-- Encerrar chamada -->
<button
type="button"
class="btn btn-circle btn-sm btn-error"
onclick={onEncerrar}
title="Encerrar chamada"
aria-label="Encerrar chamada"
>
<PhoneOff class="h-4 w-4" />
</button>
</div>
</div>

View File

@@ -0,0 +1,301 @@
<script lang="ts">
import { Check, Volume2, X } from 'lucide-svelte';
import { onMount } from 'svelte';
import type { DispositivoMedia } from '$lib/utils/jitsi';
import { obterDispositivosDisponiveis, solicitarPermissaoMidia } from '$lib/utils/jitsi';
interface Props {
open: boolean;
dispositivoAtual: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
};
onClose: () => void;
onAplicar: (dispositivos: {
microphoneId: string | null;
cameraId: string | null;
speakerId: string | null;
}) => void;
}
let { open, dispositivoAtual, onClose, onAplicar }: Props = $props();
let dispositivos = $state<{
microphones: DispositivoMedia[];
speakers: DispositivoMedia[];
cameras: DispositivoMedia[];
}>({
microphones: [],
speakers: [],
cameras: []
});
let selecionados = $state({
microphoneId: dispositivoAtual.microphoneId || null,
cameraId: dispositivoAtual.cameraId || null,
speakerId: dispositivoAtual.speakerId || null
});
let carregando = $state(false);
let previewStream: MediaStream | null = $state(null);
let previewVideo: HTMLVideoElement | null = $state(null);
let erro = $state<string | null>(null);
// Carregar dispositivos disponíveis
async function carregarDispositivos(): Promise<void> {
carregando = true;
erro = null;
try {
dispositivos = await obterDispositivosDisponiveis();
if (dispositivos.microphones.length === 0 && dispositivos.cameras.length === 0) {
erro = 'Nenhum dispositivo de mídia encontrado. Verifique as permissões do navegador.';
}
} catch (error) {
console.error('Erro ao carregar dispositivos:', error);
erro = 'Erro ao carregar dispositivos de mídia.';
} finally {
carregando = false;
}
}
// Atualizar preview quando mudar dispositivos
async function atualizarPreview(): Promise<void> {
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
previewStream = null;
}
if (!previewVideo) return;
try {
const audio = selecionados.microphoneId !== null;
const video = selecionados.cameraId !== null;
if (audio || video) {
const constraints: MediaStreamConstraints = {
audio: audio
? {
deviceId: selecionados.microphoneId
? { exact: selecionados.microphoneId }
: undefined
}
: false,
video: video
? {
deviceId: selecionados.cameraId ? { exact: selecionados.cameraId } : undefined
}
: false
};
previewStream = await solicitarPermissaoMidia(audio, video);
if (previewStream && previewVideo) {
previewVideo.srcObject = previewStream;
}
} else {
previewVideo.srcObject = null;
}
} catch (error) {
console.error('Erro ao atualizar preview:', error);
erro = 'Erro ao acessar dispositivo de mídia.';
}
}
// Testar áudio
async function testarAudio(): Promise<void> {
if (!selecionados.microphoneId) {
erro = 'Selecione um microfone primeiro.';
return;
}
try {
const stream = await solicitarPermissaoMidia(true, false);
if (stream) {
// Criar elemento de áudio temporário para teste
const audio = new Audio();
const audioTracks = stream.getAudioTracks();
if (audioTracks.length > 0) {
// O áudio será reproduzido automaticamente se conectado
setTimeout(() => {
stream.getTracks().forEach((track) => track.stop());
}, 3000);
}
}
} catch (error) {
console.error('Erro ao testar áudio:', error);
erro = 'Erro ao testar microfone.';
}
}
function handleFechar(): void {
// Parar preview ao fechar
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
previewStream = null;
}
if (previewVideo) {
previewVideo.srcObject = null;
}
erro = null;
onClose();
}
function handleAplicar(): void {
onAplicar({
microphoneId: selecionados.microphoneId,
cameraId: selecionados.cameraId,
speakerId: selecionados.speakerId
});
handleFechar();
}
// Carregar dispositivos quando abrir
$effect(() => {
if (typeof window === 'undefined') return;
if (open) {
carregarDispositivos();
} else {
// Limpar preview ao fechar
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
previewStream = null;
}
}
});
// Atualizar preview quando mudar seleção
$effect(() => {
if (typeof window === 'undefined') return;
if (open && (selecionados.microphoneId || selecionados.cameraId)) {
atualizarPreview();
}
});
onMount(() => {
return () => {
// Cleanup ao desmontar
if (previewStream) {
previewStream.getTracks().forEach((track) => track.stop());
}
};
});
</script>
{#if open}
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && handleFechar()}
role="dialog"
aria-labelledby="modal-title"
>
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 id="modal-title" class="text-xl font-semibold">Configurações de Mídia</h2>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={handleFechar}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="max-h-[70vh] space-y-6 overflow-y-auto p-6">
{#if erro}
<div class="alert alert-error">
<span>{erro}</span>
</div>
{/if}
{#if carregando}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Seleção de Microfone -->
<div>
<label class="text-base-content mb-2 block text-sm font-medium"> Microfone </label>
<select
class="select select-bordered w-full"
bind:value={selecionados.microphoneId}
onchange={atualizarPreview}
>
<option value={null}>Padrão do Sistema</option>
{#each dispositivos.microphones as microfone}
<option value={microfone.deviceId}>{microfone.label}</option>
{/each}
</select>
{#if selecionados.microphoneId}
<button type="button" class="btn btn-sm btn-ghost mt-2" onclick={testarAudio}>
<Volume2 class="h-4 w-4" />
Testar
</button>
{/if}
</div>
<!-- Seleção de Câmera -->
<div>
<label class="text-base-content mb-2 block text-sm font-medium"> Câmera </label>
<select
class="select select-bordered w-full"
bind:value={selecionados.cameraId}
onchange={atualizarPreview}
>
<option value={null}>Padrão do Sistema</option>
{#each dispositivos.cameras as camera}
<option value={camera.deviceId}>{camera.label}</option>
{/each}
</select>
</div>
<!-- Preview de Vídeo -->
{#if selecionados.cameraId}
<div>
<label class="text-base-content mb-2 block text-sm font-medium"> Preview </label>
<div class="bg-base-300 aspect-video w-full overflow-hidden rounded-lg">
<video
bind:this={previewVideo}
autoplay
muted
playsinline
class="h-full w-full object-cover"
></video>
</div>
</div>
{/if}
<!-- Seleção de Alto-falante (se disponível) -->
{#if dispositivos.speakers.length > 0}
<div>
<label class="text-base-content mb-2 block text-sm font-medium"> Alto-falante </label>
<select class="select select-bordered w-full" bind:value={selecionados.speakerId}>
<option value={null}>Padrão do Sistema</option>
{#each dispositivos.speakers as speaker}
<option value={speaker.deviceId}>{speaker.label}</option>
{/each}
</select>
</div>
{/if}
{/if}
</div>
<!-- Footer -->
<div class="modal-action border-base-300 border-t px-6 py-4">
<button type="button" class="btn btn-ghost" onclick={handleFechar}> Cancelar </button>
<button type="button" class="btn btn-primary" onclick={handleAplicar} disabled={carregando}>
<Check class="h-4 w-4" />
Aplicar
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={handleFechar}>fechar</button>
</form>
</dialog>
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,99 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { Mic, MicOff, Shield, User, Video, VideoOff } from 'lucide-svelte';
import UserAvatar from '../chat/UserAvatar.svelte';
interface ParticipanteHost {
usuarioId: Id<'usuarios'>;
nome: string;
avatar?: string;
audioHabilitado: boolean;
videoHabilitado: boolean;
forcadoPeloAnfitriao?: boolean;
}
interface Props {
participantes: ParticipanteHost[];
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
}
const { participantes, onToggleParticipanteAudio, onToggleParticipanteVideo }: Props = $props();
</script>
<div class="bg-base-200 border-base-300 flex flex-col border-t">
<div class="bg-base-300 border-base-300 flex items-center gap-2 border-b px-4 py-2">
<Shield class="text-primary h-4 w-4" />
<span class="text-base-content text-sm font-semibold">Controles do Anfitrião</span>
</div>
<div class="max-h-64 space-y-2 overflow-y-auto p-4">
{#if participantes.length === 0}
<div class="text-base-content/70 flex items-center justify-center py-8 text-sm">
Nenhum participante na chamada
</div>
{:else}
{#each participantes as participante}
<div class="bg-base-100 flex items-center justify-between rounded-lg p-3 shadow-sm">
<!-- Informações do participante -->
<div class="flex items-center gap-3">
<UserAvatar usuarioId={participante.usuarioId} avatar={participante.avatar} />
<div class="flex flex-col">
<span class="text-base-content text-sm font-medium">
{participante.nome}
</span>
{#if participante.forcadoPeloAnfitriao}
<span class="text-base-content/60 text-xs"> Controlado pelo anfitrião </span>
{/if}
</div>
</div>
<!-- Controles do participante -->
<div class="flex items-center gap-2">
<!-- Toggle Áudio -->
<button
type="button"
class="btn btn-circle btn-xs"
class:btn-primary={participante.audioHabilitado}
class:btn-error={!participante.audioHabilitado}
onclick={() => onToggleParticipanteAudio(participante.usuarioId)}
title={participante.audioHabilitado
? `Desabilitar áudio de ${participante.nome}`
: `Habilitar áudio de ${participante.nome}`}
aria-label={participante.audioHabilitado
? `Desabilitar áudio de ${participante.nome}`
: `Habilitar áudio de ${participante.nome}`}
>
{#if participante.audioHabilitado}
<Mic class="h-3 w-3" />
{:else}
<MicOff class="h-3 w-3" />
{/if}
</button>
<!-- Toggle Vídeo -->
<button
type="button"
class="btn btn-circle btn-xs"
class:btn-primary={participante.videoHabilitado}
class:btn-error={!participante.videoHabilitado}
onclick={() => onToggleParticipanteVideo(participante.usuarioId)}
title={participante.videoHabilitado
? `Desabilitar vídeo de ${participante.nome}`
: `Habilitar vídeo de ${participante.nome}`}
aria-label={participante.videoHabilitado
? `Desabilitar vídeo de ${participante.nome}`
: `Habilitar vídeo de ${participante.nome}`}
>
{#if participante.videoHabilitado}
<Video class="h-3 w-3" />
{:else}
<VideoOff class="h-3 w-3" />
{/if}
</button>
</div>
</div>
{/each}
{/if}
</div>
</div>

View File

@@ -0,0 +1,23 @@
<script lang="ts">
interface Props {
gravando: boolean;
iniciadoPor?: string;
}
const { gravando, iniciadoPor }: Props = $props();
</script>
{#if gravando}
<div
class="bg-error/90 text-error-content flex items-center gap-2 px-4 py-2 text-sm font-semibold"
role="alert"
aria-live="polite"
>
<div class="animate-pulse">
<div class="bg-error-content h-3 w-3 rounded-full"></div>
</div>
<span>
{iniciadoPor ? `Gravando iniciada por ${iniciadoPor}` : 'Chamada está sendo gravada'}
</span>
</div>
{/if}

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
dadosSla: {
statusSla: {
dentroPrazo: number;
proximoVencimento: number;
vencido: number;
semPrazo: number;
};
porPrioridade: {
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
media: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
alta: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
};
taxaCumprimento: number;
totalComPrazo: number;
atualizadoEm: number;
};
height?: number;
};
let { dadosSla, height = 400 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
function prepararDados() {
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
const cores = {
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
vencido: 'rgba(239, 68, 68, 0.8)' // vermelho
};
return {
labels: prioridades,
datasets: [
{
label: 'Dentro do Prazo',
data: [
dadosSla.porPrioridade.baixa.dentroPrazo,
dadosSla.porPrioridade.media.dentroPrazo,
dadosSla.porPrioridade.alta.dentroPrazo,
dadosSla.porPrioridade.critica.dentroPrazo
],
backgroundColor: cores.dentroPrazo,
borderColor: 'rgba(34, 197, 94, 1)',
borderWidth: 2
},
{
label: 'Próximo ao Vencimento',
data: [
dadosSla.porPrioridade.baixa.proximoVencimento,
dadosSla.porPrioridade.media.proximoVencimento,
dadosSla.porPrioridade.alta.proximoVencimento,
dadosSla.porPrioridade.critica.proximoVencimento
],
backgroundColor: cores.proximoVencimento,
borderColor: 'rgba(251, 191, 36, 1)',
borderWidth: 2
},
{
label: 'Vencido',
data: [
dadosSla.porPrioridade.baixa.vencido,
dadosSla.porPrioridade.media.vencido,
dadosSla.porPrioridade.alta.vencido,
dadosSla.porPrioridade.critica.vencido
],
backgroundColor: cores.vencido,
borderColor: 'rgba(239, 68, 68, 1)',
borderWidth: 2
}
]
};
}
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
const chartData = prepararDados();
chart = new Chart(ctx, {
type: 'bar',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif"
},
usePointStyle: true,
padding: 15
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
callbacks: {
label: function (context) {
const label = context.dataset.label || '';
const value = context.parsed.y;
const prioridade = context.label;
return `${label}: ${value} chamado(s)`;
}
}
}
},
scales: {
x: {
stacked: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
weight: '500'
}
}
},
y: {
stacked: true,
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)'
},
ticks: {
color: '#a6adbb',
font: {
size: 11
},
stepSize: 1
}
}
},
animation: {
duration: 800,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && dadosSla) {
const chartData = prepararDados();
chart.data = chartData;
chart.update('active');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px; position: relative;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,106 @@
<script lang="ts">
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import {
corPrazo,
formatarData,
getStatusBadge,
getStatusDescription,
getStatusLabel,
prazoRestante
} from '$lib/utils/chamados';
import { createEventDispatcher } from 'svelte';
type Ticket = Doc<'tickets'>;
interface Props {
ticket: Ticket;
selected?: boolean;
}
const dispatch = createEventDispatcher<{ select: { ticketId: Id<'tickets'> } }>();
const props: Props = $props();
const ticket = $derived(props.ticket);
const selected = $derived(props.selected ?? false);
const prioridadeClasses: Record<string, string> = {
baixa: 'badge badge-sm bg-base-200 text-base-content/70',
media: 'badge badge-sm badge-info badge-outline',
alta: 'badge badge-sm badge-warning',
critica: 'badge badge-sm badge-error'
};
function handleSelect() {
dispatch('select', { ticketId: ticket._id });
}
function getPrazoBadges() {
const badges: Array<{ label: string; classe: string }> = [];
if (ticket.prazoResposta) {
const cor = corPrazo(ticket.prazoResposta);
badges.push({
label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ''}`,
classe: `badge badge-xs ${
cor === 'error' ? 'badge-error' : cor === 'warning' ? 'badge-warning' : 'badge-success'
}`
});
}
if (ticket.prazoConclusao) {
const cor = corPrazo(ticket.prazoConclusao);
badges.push({
label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ''}`,
classe: `badge badge-xs ${
cor === 'error' ? 'badge-error' : cor === 'warning' ? 'badge-warning' : 'badge-success'
}`
});
}
return badges;
}
</script>
<article
class={`rounded-2xl border p-4 transition-all duration-200 ${
selected
? 'border-primary bg-primary/5 shadow-lg'
: 'border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md'
}`}
>
<button class="w-full text-left" type="button" onclick={handleSelect}>
<div class="flex items-start justify-between gap-3">
<div>
<p class="text-base-content/50 text-xs tracking-wide uppercase">
Ticket {ticket.numero}
</p>
<h3 class="text-base-content text-lg font-semibold">{ticket.titulo}</h3>
</div>
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
</div>
<p class="text-base-content/60 mt-2 line-clamp-2 text-sm">{ticket.descricao}</p>
<div class="text-base-content/60 mt-3 flex flex-wrap items-center gap-2 text-xs">
<span class={prioridadeClasses[ticket.prioridade] ?? 'badge badge-sm'}>
Prioridade {ticket.prioridade}
</span>
<span class="badge badge-xs badge-outline">
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
</span>
{#if ticket.setorResponsavel}
<span class="badge badge-xs badge-outline badge-ghost">
{ticket.setorResponsavel}
</span>
{/if}
</div>
<div class="text-base-content/50 mt-4 space-y-1 text-xs">
<p>
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
</p>
<p>{getStatusDescription(ticket.status)}</p>
<div class="flex flex-wrap gap-2">
{#each getPrazoBadges() as badge (badge.label)}
<span class={badge.classe}>{badge.label}</span>
{/each}
</div>
</div>
</button>
</article>

View File

@@ -0,0 +1,261 @@
<script lang="ts">
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { createEventDispatcher } from 'svelte';
import { Paperclip, X, Send } from 'lucide-svelte';
interface FormValues {
titulo: string;
descricao: string;
tipo: Doc<'tickets'>['tipo'];
prioridade: Doc<'tickets'>['prioridade'];
categoria: string;
canalOrigem?: string;
anexos: File[];
}
interface Props {
loading?: boolean;
}
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
const props = $props<Props>();
const loading = $derived(props.loading ?? false);
let titulo = $state('');
let descricao = $state('');
let tipo = $state<Doc<'tickets'>['tipo']>('chamado');
let prioridade = $state<Doc<'tickets'>['prioridade']>('media');
let categoria = $state('');
let canalOrigem = $state('Portal SGSE');
let anexos = $state<Array<File>>([]);
let errors = $state<Record<string, string>>({});
function validate(): boolean {
const novoErros: Record<string, string> = {};
if (!titulo.trim()) novoErros.titulo = 'Informe um título para o chamado.';
if (!descricao.trim()) novoErros.descricao = 'Descrição é obrigatória.';
if (!categoria.trim()) novoErros.categoria = 'Informe uma categoria.';
errors = novoErros;
return Object.keys(novoErros).length === 0;
}
function handleFiles(event: Event) {
const target = event.target as HTMLInputElement;
const files = Array.from(target.files ?? []);
anexos = files.slice(0, 5); // limitar para 5 anexos
}
function removeFile(index: number) {
anexos = anexos.filter((_, idx) => idx !== index);
}
function resetForm() {
titulo = '';
descricao = '';
categoria = '';
tipo = 'chamado';
prioridade = 'media';
anexos = [];
errors = {};
}
function handleSubmit(event: SubmitEvent) {
event.preventDefault();
if (!validate()) return;
dispatch('submit', {
values: {
titulo: titulo.trim(),
descricao: descricao.trim(),
tipo,
prioridade,
categoria: categoria.trim(),
canalOrigem,
anexos
}
});
}
</script>
<form class="space-y-8" onsubmit={handleSubmit}>
<!-- Título do Chamado -->
<section class="form-control">
<label class="label">
<span class="label-text text-base-content font-semibold">Título do chamado</span>
</label>
<input
type="text"
class="input input-bordered input-primary w-full"
placeholder="Ex: Erro ao acessar o módulo de licitações"
bind:value={titulo}
/>
{#if errors.titulo}
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
{/if}
</section>
<!-- Tipo de Solicitação e Prioridade -->
<section class="grid gap-6 md:grid-cols-2">
<div class="form-control">
<label class="label">
<span class="label-text text-base-content font-semibold">Tipo de solicitação</span>
</label>
<div class="border-base-300 bg-base-200/30 grid grid-cols-2 gap-2 rounded-xl border p-3">
{#each [{ value: 'chamado', label: 'Chamado', icon: '📋' }, { value: 'reclamacao', label: 'Reclamação', icon: '⚠️' }, { value: 'elogio', label: 'Elogio', icon: '⭐' }, { value: 'sugestao', label: 'Sugestão', icon: '💡' }] as opcao}
<label
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
tipo === opcao.value
? 'border-primary bg-primary/10 shadow-md'
: 'border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50'
}`}
>
<input
type="radio"
name="tipo"
class="radio radio-primary radio-sm shrink-0"
value={opcao.value}
checked={tipo === opcao.value}
onclick={() => (tipo = opcao.value as typeof tipo)}
/>
<span class="shrink-0 text-base">{opcao.icon}</span>
<span class="flex-1 text-center text-sm font-medium">{opcao.label}</span>
</label>
{/each}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text text-base-content font-semibold">Prioridade</span>
</label>
<div class="border-base-300 bg-base-200/30 grid grid-cols-2 gap-2 rounded-xl border p-3">
{#each [{ value: 'baixa', label: 'Baixa', color: 'badge-success' }, { value: 'media', label: 'Média', color: 'badge-info' }, { value: 'alta', label: 'Alta', color: 'badge-warning' }, { value: 'critica', label: 'Crítica', color: 'badge-error' }] as opcao}
<label
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
prioridade === opcao.value
? 'border-primary bg-primary/10 shadow-md'
: 'border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50'
}`}
>
<input
type="radio"
name="prioridade"
class={`radio radio-sm shrink-0 ${
opcao.value === 'baixa'
? 'radio-success'
: opcao.value === 'media'
? 'radio-info'
: opcao.value === 'alta'
? 'radio-warning'
: 'radio-error'
}`}
value={opcao.value}
checked={prioridade === opcao.value}
onclick={() => (prioridade = opcao.value as typeof prioridade)}
/>
<span class={`badge badge-sm ${opcao.color} flex-1 justify-center`}>{opcao.label}</span>
</label>
{/each}
</div>
</div>
</section>
<!-- Categoria -->
<section class="form-control">
<label class="label">
<span class="label-text text-base-content font-semibold">Categoria</span>
</label>
<input
type="text"
class="input input-bordered w-full"
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
bind:value={categoria}
/>
{#if errors.categoria}
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
{/if}
</section>
<!-- Descrição Detalhada -->
<section class="form-control">
<label class="label">
<span class="label-text text-base-content font-semibold">Descrição detalhada</span>
<span class="label-text-alt text-base-content/50">Obrigatório</span>
</label>
<textarea
class="textarea textarea-bordered textarea-lg min-h-[180px]"
placeholder="Descreva o problema, erro ou sugestão com o máximo de detalhes possível..."
bind:value={descricao}
></textarea>
{#if errors.descricao}
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
{/if}
</section>
<!-- Anexos -->
<section class="border-base-300 bg-base-200/30 space-y-4 rounded-xl border p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-base-content font-semibold">Anexos (opcional)</p>
<p class="text-base-content/60 text-sm">Suporte a PDF e imagens (máx. 10MB por arquivo)</p>
</div>
<label class="btn btn-outline btn-sm">
<Paperclip class="h-4 w-4" strokeWidth={2} />
Selecionar arquivos
<input
type="file"
class="hidden"
multiple
accept=".pdf,.png,.jpg,.jpeg"
onchange={handleFiles}
/>
</label>
</div>
{#if anexos.length > 0}
<div class="border-base-200 bg-base-100/70 space-y-2 rounded-2xl border p-4">
{#each anexos as file, index (file.name + index)}
<div
class="border-base-200 bg-base-100 flex items-center justify-between gap-3 rounded-xl border px-3 py-2"
>
<div>
<p class="text-sm font-medium">{file.name}</p>
<p class="text-base-content/60 text-xs">
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
</p>
</div>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
onclick={() => removeFile(index)}
>
Remover
</button>
</div>
{/each}
</div>
{:else}
<div
class="border-base-300 bg-base-100/50 text-base-content/60 rounded-2xl border border-dashed p-6 text-center text-sm"
>
Nenhum arquivo selecionado.
</div>
{/if}
</section>
<!-- Ações do Formulário -->
<section class="border-base-300 flex flex-wrap gap-3 border-t pt-6">
<button type="submit" class="btn btn-primary min-w-[200px] flex-1 shadow-lg" disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
<Send class="h-5 w-5" strokeWidth={2} />
Registrar chamado
{/if}
</button>
<button type="button" class="btn btn-ghost" onclick={resetForm} disabled={loading}>
<X class="h-5 w-5" strokeWidth={2} />
Limpar
</button>
</section>
</form>

View File

@@ -0,0 +1,85 @@
<script lang="ts">
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import {
formatarData,
formatarTimelineEtapa,
prazoRestante,
timelineStatus
} from '$lib/utils/chamados';
type Ticket = Doc<'tickets'>;
type TimelineEntry = NonNullable<Ticket['timeline']>[number];
interface Props {
timeline?: Array<TimelineEntry>;
}
const props: Props = $props();
let timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
const badgeClasses: Record<string, string> = {
success: 'bg-success/20 text-success border-success/40',
warning: 'bg-warning/20 text-warning border-warning/40',
error: 'bg-error/20 text-error border-error/40',
info: 'bg-info/20 text-info border-info/40'
};
function getBadgeClass(entry: TimelineEntry) {
const status = timelineStatus(entry);
return badgeClasses[status] ?? badgeClasses.info;
}
function getStatusLabel(entry: TimelineEntry) {
if (entry.status === 'concluido') return 'Concluído';
if (entry.status === 'em_andamento') return 'Em andamento';
if (entry.status === 'vencido') return 'Vencido';
return 'Pendente';
}
function getPrazoDescricao(entry: TimelineEntry) {
if (entry.status === 'concluido' && entry.concluidoEm) {
return `Concluído em ${formatarData(entry.concluidoEm)}`;
}
if (!entry.prazo) return 'Sem prazo definido';
return `${formatarData(entry.prazo)}${prazoRestante(entry.prazo) ?? ''}`;
}
</script>
<div class="space-y-4">
{#if timeline.length === 0}
<div class="alert alert-info">
<span>Nenhuma etapa registrada ainda.</span>
</div>
{:else}
{#each timeline as entry (entry.etapa + entry.prazo)}
<div class="flex gap-3">
<div class="relative flex flex-col items-center">
<div class={`badge border ${getBadgeClass(entry)}`}>
{formatarTimelineEtapa(entry.etapa)}
</div>
{#if entry !== timeline[timeline.length - 1]}
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
{/if}
</div>
<div class="border-base-200 bg-base-100/80 flex-1 rounded-2xl border p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="text-base-content text-sm font-semibold">
{getStatusLabel(entry)}
</span>
{#if entry.status !== 'concluido' && entry.prazo}
<span class="badge badge-sm badge-outline">
{prazoRestante(entry.prazo)}
</span>
{/if}
</div>
{#if entry.observacao}
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
{/if}
<p class="text-base-content/50 mt-3 text-xs tracking-wide uppercase">
{getPrazoDescricao(entry)}
</p>
</div>
</div>
{/each}
{/if}
</div>

View File

@@ -1,514 +1,455 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte"; import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from "@sgse-app/backend/convex/_generated/api"; import { api } from '@sgse-app/backend/convex/_generated/api';
import { abrirConversa } from "$lib/stores/chatStore"; import { abrirConversa } from '$lib/stores/chatStore';
import { formatDistanceToNow } from "date-fns"; import { formatDistanceToNow } from 'date-fns';
import { ptBR } from "date-fns/locale"; import { ptBR } from 'date-fns/locale';
import UserStatusBadge from "./UserStatusBadge.svelte"; import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from "./UserAvatar.svelte"; import UserAvatar from './UserAvatar.svelte';
import NewConversationModal from "./NewConversationModal.svelte"; import NewConversationModal from './NewConversationModal.svelte';
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { obterCoresDoTema } from '$lib/utils/temas';
const client = useConvexClient(); const client = useConvexClient();
// Buscar todos os usuários para o chat // Buscar todos os usuários para o chat
const usuarios = useQuery(api.usuarios.listarParaChat, {}); const usuarios = useQuery(api.usuarios.listarParaChat, {});
// Buscar o perfil do usuário logado // Buscar o perfil do usuário logado
const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
// Buscar conversas (grupos e salas de reunião) // Buscar conversas (grupos e salas de reunião)
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
let searchQuery = $state(""); let searchQuery = $state('');
let activeTab = $state<"usuarios" | "conversas">("usuarios"); let activeTab = $state<'usuarios' | 'conversas'>('usuarios');
// Debug: monitorar carregamento de dados // Obter cores do tema atual (reativo)
$effect(() => { let coresTema = $state(obterCoresDoTema());
console.log(
"📊 [ChatList] Usuários carregados:",
usuarios?.data?.length || 0,
);
console.log(
"👤 [ChatList] Meu perfil:",
meuPerfil?.data?.nome || "Carregando...",
);
console.log(
"🆔 [ChatList] Meu ID:",
meuPerfil?.data?._id || "Não encontrado",
);
if (usuarios?.data) {
const meuId = meuPerfil?.data?._id;
const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId);
if (meusDadosNaLista) {
console.warn(
"⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!",
meusDadosNaLista.nome,
);
}
}
});
const usuariosFiltrados = $derived.by(() => { // Atualizar cores quando o tema mudar
if (!usuarios?.data || !Array.isArray(usuarios.data)) return []; $effect(() => {
if (typeof window === 'undefined') return;
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos const atualizarCores = () => {
if (!meuPerfil?.data) { coresTema = obterCoresDoTema();
console.log("⏳ [ChatList] Aguardando perfil do usuário..."); };
return [];
}
const meuId = meuPerfil.data._id; atualizarCores();
// Filtrar o próprio usuário da lista (filtro de segurança no frontend) window.addEventListener('themechange', atualizarCores);
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
// Log se ainda estiver na lista após filtro (não deveria acontecer) const observer = new MutationObserver(atualizarCores);
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId); const htmlElement = document.documentElement;
if (aindaNaLista) { observer.observe(htmlElement, {
console.error( attributes: true,
"❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!", attributeFilter: ['data-theme']
); });
}
// Aplicar busca por nome/email/matrícula return () => {
if (searchQuery.trim()) { window.removeEventListener('themechange', atualizarCores);
const query = searchQuery.toLowerCase(); observer.disconnect();
listaFiltrada = listaFiltrada.filter( };
(u: any) => });
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query),
);
}
// Ordenar: Online primeiro, depois por nome // Função para obter rgba da cor primária
return listaFiltrada.sort((a: any, b: any) => { function obterPrimariaRgba(alpha: number = 1) {
const statusOrder = { const primary = coresTema.primary;
online: 0, if (primary.startsWith('rgba')) {
ausente: 1, const match = primary.match(/rgba?\(([^)]+)\)/);
externo: 2, if (match) {
em_reuniao: 3, const values = match[1].split(',');
offline: 4, return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}; }
const statusA = }
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4; if (primary.startsWith('#')) {
const statusB = const hex = primary.replace('#', '');
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4; const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (primary.startsWith('hsl')) {
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
if (statusA !== statusB) return statusA - statusB; // Debug: monitorar carregamento de dados
return a.nome.localeCompare(b.nome); $effect(() => {
}); console.log('📊 [ChatList] Usuários carregados:', usuarios?.data?.length || 0);
}); console.log('👤 [ChatList] Meu perfil:', meuPerfil?.data?.nome || 'Carregando...');
console.log('🆔 [ChatList] Meu ID:', meuPerfil?.data?._id || 'Não encontrado');
if (usuarios?.data) {
const meuId = meuPerfil?.data?._id;
const meusDadosNaLista = usuarios.data.find((u) => u._id === meuId);
if (meusDadosNaLista) {
console.warn(
'⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!',
meusDadosNaLista.nome
);
}
}
});
function formatarTempo(timestamp: number | undefined): string { let usuariosFiltrados = $derived.by(() => {
if (!timestamp) return ""; if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
try {
return formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ptBR,
});
} catch {
return "";
}
}
let processando = $state(false); // Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
let showNewConversationModal = $state(false); if (!meuPerfil?.data) {
console.log('⏳ [ChatList] Aguardando perfil do usuário...');
return [];
}
async function handleClickUsuario(usuario: any) { const meuId = meuPerfil.data._id;
if (processando) {
console.log("⏳ Já está processando uma ação, aguarde...");
return;
}
try { // Filtrar o próprio usuário da lista (filtro de segurança no frontend)
processando = true; let listaFiltrada = usuarios.data.filter((u) => u._id !== meuId);
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
// Criar ou buscar conversa individual com este usuário // Log se ainda estiver na lista após filtro (não deveria acontecer)
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual..."); const aindaNaLista = listaFiltrada.find((u) => u._id === meuId);
const conversaId = await client.mutation( if (aindaNaLista) {
api.chat.criarOuBuscarConversaIndividual, console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!');
{ }
outroUsuarioId: usuario._id,
},
);
console.log("✅ Conversa criada/encontrada. ID:", conversaId); // Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
listaFiltrada = listaFiltrada.filter(
(u) =>
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
);
}
// Abrir a conversa // Ordenar: Online primeiro, depois por nome
console.log("📂 Abrindo conversa..."); return listaFiltrada.sort((a, b) => {
abrirConversa(conversaId as any); const statusOrder = {
online: 0,
ausente: 1,
externo: 2,
em_reuniao: 3,
offline: 4
};
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
console.log("✅ Conversa aberta com sucesso!"); if (statusA !== statusB) return statusA - statusB;
} catch (error) { return a.nome.localeCompare(b.nome);
console.error("❌ Erro ao abrir conversa:", error); });
console.error("Detalhes do erro:", { });
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
usuario: usuario,
});
alert(
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
processando = false;
}
}
function getStatusLabel(status: string | undefined): string { function formatarTempo(timestamp: number | undefined): string {
const labels: Record<string, string> = { if (!timestamp) return '';
online: "Online", try {
offline: "Offline", return formatDistanceToNow(new Date(timestamp), {
ausente: "Ausente", addSuffix: true,
externo: "Externo", locale: ptBR
em_reuniao: "Em Reunião", });
}; } catch {
return labels[status || "offline"] || "Offline"; return '';
} }
}
// Filtrar conversas por tipo e busca let processando = $state(false);
const conversasFiltradas = $derived(() => { let showNewConversationModal = $state(false);
if (!conversas?.data) return [];
let lista = conversas.data.filter( async function handleClickUsuario(usuario: any) {
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao", if (processando) {
); console.log('⏳ Já está processando uma ação, aguarde...');
return;
}
// Aplicar busca try {
if (searchQuery.trim()) { processando = true;
const query = searchQuery.toLowerCase(); console.log('🔄 Clicou no usuário:', usuario.nome, 'ID:', usuario._id);
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
}
return lista; // Criar ou buscar conversa individual com este usuário
}); console.log('📞 Chamando mutation criarOuBuscarConversaIndividual...');
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
outroUsuarioId: usuario._id
});
function handleClickConversa(conversa: any) { console.log('✅ Conversa criada/encontrada. ID:', conversaId);
if (processando) return;
try { // Abrir a conversa
processando = true; console.log('📂 Abrindo conversa...');
abrirConversa(conversa._id); abrirConversa(conversaId as Id<'conversas'>);
} catch (error) {
console.error("Erro ao abrir conversa:", error); console.log('✅ Conversa aberta com sucesso!');
alert( } catch (error) {
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`, console.error('❌ Erro ao abrir conversa:', error);
); console.error('Detalhes do erro:', {
} finally { message: error instanceof Error ? error.message : String(error),
processando = false; stack: error instanceof Error ? error.stack : undefined,
} usuario: usuario
} });
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
} finally {
processando = false;
}
}
function getStatusLabel(status: string | undefined): string {
const labels: Record<string, string> = {
online: 'Online',
offline: 'Offline',
ausente: 'Ausente',
externo: 'Externo',
em_reuniao: 'Em Reunião'
};
return labels[status || 'offline'] || 'Offline';
}
// Filtrar conversas por tipo e busca
let conversasFiltradas = $derived.by(() => {
if (!conversas?.data) return [];
let lista = conversas.data.filter(
(c: Doc<'conversas'>) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao'
);
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter((c: Doc<'conversas'>) => c.nome?.toLowerCase().includes(query));
}
return lista;
});
interface Conversa {
_id: Id<'conversas'>;
[key: string]: unknown;
}
function handleClickConversa(conversa: Conversa) {
if (processando) return;
try {
processando = true;
abrirConversa(conversa._id);
} catch (error) {
console.error('Erro ao abrir conversa:', error);
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
} finally {
processando = false;
}
}
</script> </script>
<div class="flex flex-col h-full"> <div class="flex h-full flex-col">
<!-- Search bar --> <!-- Search bar -->
<div class="p-4 border-b border-base-300"> <div class="border-base-300 border-b p-4">
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
placeholder="Buscar usuários (nome, email, matrícula)..." placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10" class="input input-bordered w-full pl-10"
bind:value={searchQuery} bind:value={searchQuery}
/> aria-label="Buscar usuários ou conversas"
<svg aria-describedby="search-help"
xmlns="http://www.w3.org/2000/svg" />
fill="none" <span id="search-help" class="sr-only"
viewBox="0 0 24 24" >Digite para buscar usuários por nome, email ou matrícula</span
stroke-width="1.5" >
stroke="currentColor" <Search
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50" class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
> strokeWidth={1.5}
<path />
stroke-linecap="round" </div>
stroke-linejoin="round" </div>
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
</div>
</div>
<!-- Tabs e Título --> <!-- Tabs e Título -->
<div class="border-b border-base-300 bg-base-200"> <div class="border-base-300 bg-base-200 border-b">
<!-- Tabs --> <!-- Tabs -->
<div class="tabs tabs-boxed p-2"> <div class="tabs tabs-boxed p-2">
<button <button
type="button" type="button"
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`} class={`tab flex-1 ${activeTab === 'usuarios' ? 'tab-active' : ''}`}
onclick={() => (activeTab = "usuarios")} onclick={() => (activeTab = 'usuarios')}
> >
👥 Usuários ({usuariosFiltrados.length}) 👥 Usuários ({usuariosFiltrados.length})
</button> </button>
<button <button
type="button" type="button"
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`} class={`tab flex-1 ${activeTab === 'conversas' ? 'tab-active' : ''}`}
onclick={() => (activeTab = "conversas")} onclick={() => (activeTab = 'conversas')}
> >
💬 Conversas ({conversasFiltradas().length}) 💬 Conversas ({conversasFiltradas.length})
</button> </button>
</div> </div>
<!-- Botão Nova Conversa --> <!-- Botão Nova Conversa -->
<div class="px-4 pb-2 flex justify-end"> <div class="flex justify-end px-4 pb-2">
<button <button
type="button" type="button"
class="btn btn-primary btn-sm" class="btn btn-primary btn-sm"
onclick={() => (showNewConversationModal = true)} onclick={() => (showNewConversationModal = true)}
title="Nova conversa (grupo ou sala de reunião)" title="Nova conversa (grupo ou sala de reunião)"
aria-label="Nova conversa" aria-label="Nova conversa"
> >
<svg <Plus class="mr-1 h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg" Nova Conversa
fill="none" </button>
viewBox="0 0 24 24" </div>
stroke-width="2" </div>
stroke="currentColor"
class="w-4 h-4 mr-1"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
Nova Conversa
</button>
</div>
</div>
<!-- Lista de conteúdo --> <!-- Lista de conteúdo -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
{#if activeTab === "usuarios"} {#if activeTab === 'usuarios'}
<!-- Lista de usuários --> <!-- Lista de usuários -->
{#if usuarios?.data && usuariosFiltrados.length > 0} {#if usuarios?.data && usuariosFiltrados.length > 0}
{#each usuariosFiltrados as usuario (usuario._id)} {#each usuariosFiltrados as usuario (usuario._id)}
<button <button
type="button" type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
? 'opacity-50 cursor-wait' ? 'cursor-wait opacity-50'
: 'cursor-pointer'}" : 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)} onclick={() => handleClickUsuario(usuario)}
disabled={processando} disabled={processando}
> aria-label="Abrir conversa com {usuario.nome}"
<!-- Ícone de mensagem --> aria-describedby="usuario-status-{usuario._id}"
<div >
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110" <!-- Ícone de mensagem -->
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);" <div
> class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
<svg style="background: linear-gradient(135deg, {obterPrimariaRgba(0.1)} 0%, {obterPrimariaRgba(0.1)} 100%); border: 1px solid {obterPrimariaRgba(0.2)};"
xmlns="http://www.w3.org/2000/svg" >
viewBox="0 0 24 24" <MessageSquare class="text-primary h-5 w-5" strokeWidth={2} />
fill="none" </div>
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-primary"
>
<path
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
/>
<path d="M9 10h.01M15 10h.01" />
</svg>
</div>
<!-- Avatar --> <!-- Avatar -->
<div class="relative shrink-0"> <div class="relative shrink-0">
<UserAvatar <UserAvatar
avatar={usuario.avatar} fotoPerfilUrl={usuario.fotoPerfilUrl}
fotoPerfilUrl={usuario.fotoPerfilUrl} nome={usuario.nome}
nome={usuario.nome} size="md"
size="md" userId={usuario._id}
/> />
<!-- Status badge --> <!-- Status badge -->
<div class="absolute bottom-0 right-0"> <div class="absolute right-0 bottom-0">
<UserStatusBadge <UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
status={usuario.statusPresenca || "offline"} </div>
size="sm" </div>
/>
</div>
</div>
<!-- Conteúdo --> <!-- Conteúdo -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<div class="flex items-center justify-between mb-1"> <div class="mb-1 flex items-center justify-between">
<p class="font-semibold text-base-content truncate"> <p class="text-base-content truncate font-semibold">
{usuario.nome} {usuario.nome}
</p> </p>
<span <span
class="text-xs px-2 py-0.5 rounded-full {usuario.statusPresenca === class="rounded-full px-2 py-0.5 text-xs {usuario.statusPresenca === 'online'
'online' ? 'bg-success/20 text-success'
? 'bg-success/20 text-success' : usuario.statusPresenca === 'ausente'
: usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning'
? 'bg-warning/20 text-warning' : usuario.statusPresenca === 'em_reuniao'
: usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error'
? 'bg-error/20 text-error' : 'bg-base-300 text-base-content/50'}"
: 'bg-base-300 text-base-content/50'}" >
> {getStatusLabel(usuario.statusPresenca)}
{getStatusLabel(usuario.statusPresenca)} </span>
</span> </div>
</div> <div class="flex items-center gap-2">
<div class="flex items-center gap-2"> <p class="text-base-content/70 truncate text-sm">
<p class="text-sm text-base-content/70 truncate"> {usuario.statusMensagem || usuario.email}
{usuario.statusMensagem || usuario.email} </p>
</p> </div>
</div> <span id="usuario-status-{usuario._id}" class="sr-only">
</div> Status: {getStatusLabel(usuario.statusPresenca)}
</button> </span>
{/each} </div>
{:else if !usuarios?.data} </button>
<!-- Loading --> {/each}
<div class="flex items-center justify-center h-full"> {:else if !usuarios?.data}
<span class="loading loading-spinner loading-lg"></span> <!-- Loading -->
</div> <div class="flex h-full items-center justify-center">
{:else} <span class="loading loading-spinner loading-lg"></span>
<!-- Nenhum usuário encontrado --> </div>
<div {:else}
class="flex flex-col items-center justify-center h-full text-center px-4" <!-- Nenhum usuário encontrado -->
> <div class="flex h-full flex-col items-center justify-center px-4 text-center">
<svg <UsersRound class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
xmlns="http://www.w3.org/2000/svg" <p class="text-base-content/70">Nenhum usuário encontrado</p>
fill="none" </div>
viewBox="0 0 24 24" {/if}
stroke-width="1.5" {:else}
stroke="currentColor" <!-- Lista de conversas (grupos e salas) -->
class="w-16 h-16 text-base-content/30 mb-4" {#if conversas?.data && conversasFiltradas.length > 0}
> {#each conversasFiltradas as conversa (conversa._id)}
<path <button
stroke-linecap="round" type="button"
stroke-linejoin="round" class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" ? 'cursor-wait opacity-50'
/> : 'cursor-pointer'}"
</svg> onclick={() => handleClickConversa(conversa)}
<p class="text-base-content/70">Nenhum usuário encontrado</p> disabled={processando}
</div> >
{/if} <!-- Ícone de grupo/sala -->
{:else} <div
<!-- Lista de conversas (grupos e salas) --> class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
{#if conversas?.data && conversasFiltradas().length > 0} 'sala_reuniao'
{#each conversasFiltradas() as conversa (conversa._id)} ? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
<button : 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
type="button" >
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando {#if conversa.tipo === 'sala_reuniao'}
? 'opacity-50 cursor-wait' <UsersRound class="h-5 w-5 text-blue-500" strokeWidth={2} />
: 'cursor-pointer'}" {:else}
onclick={() => handleClickConversa(conversa)} <Users class="text-primary h-5 w-5" strokeWidth={2} />
disabled={processando} {/if}
> </div>
<!-- Ícone de grupo/sala -->
<div
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {conversa.tipo ===
'sala_reuniao'
? 'bg-linear-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
: 'bg-linear-to-br from-primary/20 to-secondary/20 border border-primary/30'}"
>
{#if conversa.tipo === "sala_reuniao"}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5 text-blue-500"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-5 h-5 text-primary"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
/>
</svg>
{/if}
</div>
<!-- Conteúdo --> <!-- Conteúdo -->
<div class="flex-1 min-w-0"> <div class="min-w-0 flex-1">
<div class="flex items-center justify-between mb-1"> <div class="mb-1 flex items-center justify-between">
<p class="font-semibold text-base-content truncate"> <p class="text-base-content truncate font-semibold">
{conversa.nome || {conversa.nome ||
(conversa.tipo === "sala_reuniao" (conversa.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome')}
? "Sala sem nome" </p>
: "Grupo sem nome")} {#if conversa.naoLidas > 0}
</p> <span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
{#if conversa.naoLidas > 0} {/if}
<span class="badge badge-primary badge-sm" </div>
>{conversa.naoLidas}</span <div class="flex items-center gap-2">
> <span
{/if} class="rounded-full px-2 py-0.5 text-xs {conversa.tipo === 'sala_reuniao'
</div> ? 'bg-blue-500/20 text-blue-500'
<div class="flex items-center gap-2"> : 'bg-primary/20 text-primary'}"
<span >
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo === {conversa.tipo === 'sala_reuniao' ? '👑 Sala de Reunião' : '👥 Grupo'}
'sala_reuniao' </span>
? 'bg-blue-500/20 text-blue-500' {#if conversa.participantesInfo}
: 'bg-primary/20 text-primary'}" <span class="text-base-content/50 text-xs">
> {conversa.participantesInfo.length} participante{conversa.participantesInfo
{conversa.tipo === "sala_reuniao" .length !== 1
? "👑 Sala de Reunião" ? 's'
: "👥 Grupo"} : ''}
</span> </span>
{#if conversa.participantesInfo} {/if}
<span class="text-xs text-base-content/50"> </div>
{conversa.participantesInfo.length} participante{conversa </div>
.participantesInfo.length !== 1 </button>
? "s" {/each}
: ""} {:else if !conversas?.data}
</span> <!-- Loading -->
{/if} <div class="flex h-full items-center justify-center">
</div> <span class="loading loading-spinner loading-lg"></span>
</div> </div>
</button> {:else}
{/each} <!-- Nenhuma conversa encontrada -->
{:else if !conversas?.data} <div class="flex h-full flex-col items-center justify-center px-4 text-center">
<!-- Loading --> <MessageSquare class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
<div class="flex items-center justify-center h-full"> <p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
<span class="loading loading-spinner loading-lg"></span> <p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
</div> </div>
{:else} {/if}
<!-- Nenhuma conversa encontrada --> {/if}
<div </div>
class="flex flex-col items-center justify-center h-full text-center px-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-16 h-16 text-base-content/30 mb-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
<p class="text-base-content/70 font-medium mb-2">
Nenhuma conversa encontrada
</p>
<p class="text-sm text-base-content/50">
Crie um grupo ou sala de reunião para começar
</p>
</div>
{/if}
{/if}
</div>
</div> </div>
<!-- Modal de Nova Conversa --> <!-- Modal de Nova Conversa -->
{#if showNewConversationModal} {#if showNewConversationModal}
<NewConversationModal onClose={() => (showNewConversationModal = false)} /> <NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if} {/if}

View File

@@ -1,48 +1,62 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
import { SvelteSet } from 'svelte/reactivity';
import { import {
abrirChat,
abrirConversa,
chatAberto, chatAberto,
chatMinimizado, chatMinimizado,
conversaAtiva, conversaAtiva,
fecharChat, fecharChat,
minimizarChat,
maximizarChat, maximizarChat,
abrirChat, minimizarChat,
abrirConversa notificacaoAtiva
} from '$lib/stores/chatStore'; } from '$lib/stores/chatStore';
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import ChatList from './ChatList.svelte'; import ChatList from './ChatList.svelte';
import ChatWindow from './ChatWindow.svelte'; import ChatWindow from './ChatWindow.svelte';
import { getAvatarUrl } from '$lib/utils/avatarGenerator'; import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
import { SvelteSet } from 'svelte/reactivity'; import { obterCoresDoTema, obterTemaPersistidoNoLocalStorage } from '$lib/utils/temas';
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {}); const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para verificar o ID do usuário logado (usar como referência) // Query otimizada: usar apenas uma query para obter usuário atual
// Priorizar obterPerfil pois retorna mais informações úteis
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {}); const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser, {});
// Derivar ID do usuário de forma otimizada (usar perfil primeiro, fallback para currentUser)
const meuId = $derived(() => {
if (meuPerfilQuery?.data?._id) {
return String(meuPerfilQuery.data._id).trim();
}
if (currentUser?.data?._id) {
return String(currentUser.data._id).trim();
}
return null;
});
let isOpen = $derived(false); let isOpen = $derived(false);
let isMinimized = $derived(false); let isMinimized = $derived(false);
let activeConversation = $state<string | null>(null); let activeConversation = $state<string | null>(null);
// Função para obter a URL do avatar/foto do usuário logado // Função para obter a URL do avatar/foto do usuário logado (otimizada)
const avatarUrlDoUsuario = $derived(() => { let avatarUrlDoUsuario = $derived(() => {
const usuario = currentUser?.data; // Priorizar perfil (tem mais informações)
if (!usuario) return null; const perfil = meuPerfilQuery?.data;
if (perfil?.fotoPerfilUrl) {
return perfil.fotoPerfilUrl;
}
// Prioridade: fotoPerfilUrl > avatar > fallback com nome // Fallback para currentUser
if (usuario.fotoPerfilUrl) { const usuario = currentUser?.data;
if (usuario?.fotoPerfilUrl) {
return usuario.fotoPerfilUrl; return usuario.fotoPerfilUrl;
} }
if (usuario.avatar) { // Fallback: retornar null para usar o ícone User do Lucide
return getAvatarUrl(usuario.avatar); return null;
}
// Fallback: gerar avatar baseado no nome
return getAvatarUrl(usuario.nome);
}); });
// Posição do widget (arrastável) // Posição do widget (arrastável)
@@ -54,6 +68,13 @@
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
let isDoubleClicking = $state(false); // Flag para prevenir clique simples após duplo clique
// Suporte a gestos touch (swipe)
let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
let touchCurrent = $state<{ x: number; y: number } | null>(null);
let isTouching = $state(false);
let swipeVelocity = $state(0); // Velocidade do swipe para animação
// Tamanho da janela (redimensionável) // Tamanho da janela (redimensionável)
const MIN_WIDTH = 300; const MIN_WIDTH = 300;
@@ -282,7 +303,7 @@
} }
// Garantir que X também está dentro dos limites // Garantir que X também está dentro dos limites
let newX = Math.max(minX, Math.min(maxX, position.x)); const newX = Math.max(minX, Math.min(maxX, position.x));
// Aplicar novos valores apenas se necessário // Aplicar novos valores apenas se necessário
if (newX !== position.x || newY !== position.y) { if (newX !== position.x || newY !== position.y) {
@@ -401,47 +422,29 @@
} }
} }
// Throttle para evitar execuções muito frequentes do effect
let ultimaExecucaoNotificacao = $state(0);
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
$effect(() => { $effect(() => {
if (todasConversas?.data && currentUser?.data?._id) { const agora = Date.now();
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
// Throttle: só executar se passou tempo suficiente
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
return;
}
if (todasConversas?.data && meuId()) {
ultimaExecucaoNotificacao = agora;
const conversas = todasConversas.data as ConversaComTimestamp[]; const conversas = todasConversas.data as ConversaComTimestamp[];
const meuIdAtual = meuId();
// Encontrar conversas com novas mensagens if (!meuIdAtual) {
// Obter ID do usuário logado de forma robusta console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
// Prioridade: usar query do Convex (mais confiável) > authStore
const usuarioLogado = currentUser?.data;
const perfilConvex = meuPerfilQuery?.data;
// Usar ID do Convex se disponível, caso contrário usar authStore
let meuId: string | null = null;
if (perfilConvex && perfilConvex._id) {
// Usar ID retornado pela query do Convex (mais confiável)
meuId = String(perfilConvex._id).trim();
} else if (usuarioLogado && usuarioLogado._id) {
// Fallback para authStore
meuId = String(usuarioLogado._id).trim();
}
if (!meuId) {
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:', {
currentUser: !!usuarioLogado,
currentUserId: usuarioLogado?._id,
convexPerfil: !!perfilConvex,
convexId: perfilConvex?._id
});
return; return;
} }
// Log para debug (apenas em desenvolvimento)
if (import.meta.env.DEV) {
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
id: meuId,
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
nome: usuarioLogado?.nome || perfilConvex?.nome,
email: usuarioLogado?.email
});
}
conversas.forEach((conv) => { conversas.forEach((conv) => {
if (!conv.ultimaMensagemTimestamp) return; if (!conv.ultimaMensagemTimestamp) return;
@@ -451,21 +454,8 @@
? String(conv.ultimaMensagemRemetenteId).trim() ? String(conv.ultimaMensagemRemetenteId).trim()
: null; : null;
// Log para debug da comparação (apenas em desenvolvimento)
if (import.meta.env.DEV && remetenteIdStr) {
const ehMinhaMensagem = remetenteIdStr === meuId;
if (ehMinhaMensagem) {
console.log('✅ [ChatWidget] Mensagem identificada como própria (ignorada):', {
conversaId: conv._id,
meuId,
remetenteId: remetenteIdStr,
mensagem: conv.ultimaMensagem?.substring(0, 50)
});
}
}
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente // Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
if (remetenteIdStr && remetenteIdStr === meuId) { if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação // Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
// Marcar como notificada para evitar processamento futuro // Marcar como notificada para evitar processamento futuro
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`; const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
@@ -486,14 +476,29 @@
const conversaIdStr = String(conv._id).trim(); const conversaIdStr = String(conv._id).trim();
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr; const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
// Verificar se outra notificação já está ativa para esta mensagem
const notificacaoAtual = $notificacaoAtiva;
const jaTemNotificacaoAtiva =
notificacaoAtual &&
notificacaoAtual.conversaId === conversaIdStr &&
notificacaoAtual.mensagemId === mensagemId;
// Só mostrar notificação se: // Só mostrar notificação se:
// 1. O chat não está aberto OU // 1. O chat não está aberto OU
// 2. O chat está aberto mas não estamos vendo essa conversa específica // 2. O chat está aberto mas não estamos vendo essa conversa específica
if (!isOpen || !estaConversaEstaAberta) { // 3. E não há outra notificação ativa para esta mensagem
if ((!isOpen || !estaConversaEstaAberta) && !jaTemNotificacaoAtiva) {
// Marcar como notificada ANTES de mostrar notificação (evita duplicação) // Marcar como notificada ANTES de mostrar notificação (evita duplicação)
mensagensNotificadasGlobal.add(mensagemId); mensagensNotificadasGlobal.add(mensagemId);
salvarMensagensNotificadasGlobal(); salvarMensagensNotificadasGlobal();
// Registrar notificação ativa no store global
notificacaoAtiva.set({
conversaId: conversaIdStr,
mensagemId,
componente: 'widget'
});
// Tocar som de notificação (apenas uma vez) // Tocar som de notificação (apenas uma vez)
tocarSomNotificacaoGlobal(); tocarSomNotificacaoGlobal();
@@ -505,13 +510,17 @@
}; };
showGlobalNotificationPopup = true; showGlobalNotificationPopup = true;
// Ocultar popup após 5 segundos // Ocultar popup após 5 segundos - garantir limpeza
if (globalNotificationTimeout) { if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout); clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
} }
globalNotificationTimeout = setTimeout(() => { globalNotificationTimeout = setTimeout(() => {
showGlobalNotificationPopup = false; showGlobalNotificationPopup = false;
globalNotificationMessage = null; globalNotificationMessage = null;
globalNotificationTimeout = null;
// Limpar notificação ativa do store
notificacaoAtiva.set(null);
}, 5000); }, 5000);
} else { } else {
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada // Chat está aberto e estamos vendo essa conversa - marcar como visualizada
@@ -521,6 +530,14 @@
} }
}); });
} }
// Cleanup: limpar timeout quando o effect for desmontado
return () => {
if (globalNotificationTimeout) {
clearTimeout(globalNotificationTimeout);
globalNotificationTimeout = null;
}
};
}); });
function handleToggle() { function handleToggle() {
@@ -579,6 +596,56 @@
maximizarChat(); maximizarChat();
} }
// Handler para duplo clique no botão flutuante - abre e maximiza
function handleDoubleClick() {
// Marcar que estamos processando um duplo clique
isDoubleClicking = true;
// Se o chat estiver fechado ou minimizado, abrir e maximizar
if (!isOpen || isMinimized) {
abrirChat();
// Aguardar um pouco para garantir que o chat foi aberto antes de maximizar
setTimeout(() => {
if (position) {
// Salvar tamanho e posição atuais antes de maximizar
previousSize = { ...windowSize };
previousPosition = { ...position };
// Maximizar completamente
const winWidth =
windowDimensions.width ||
(typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH);
const winHeight =
windowDimensions.height ||
(typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT);
windowSize = {
width: winWidth,
height: winHeight
};
position = {
x: 0,
y: 0
};
isMaximized = true;
saveSize();
ajustarPosicao();
maximizarChat();
}
// Resetar flag após processar
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}, 50);
} else {
// Se já estiver aberto, apenas maximizar
handleMaximize();
setTimeout(() => {
isDoubleClicking = false;
}, 300);
}
}
// Funcionalidade de arrastar // Funcionalidade de arrastar
function handleMouseDown(e: MouseEvent) { function handleMouseDown(e: MouseEvent) {
if (e.button !== 0 || !position) return; // Apenas botão esquerdo if (e.button !== 0 || !position) return; // Apenas botão esquerdo
@@ -615,6 +682,136 @@
// Não prevenir default para permitir clique funcionar se não houver movimento // Não prevenir default para permitir clique funcionar se não houver movimento
} }
// Handlers para gestos touch (swipe)
function handleTouchStart(e: TouchEvent) {
if (!position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchStart = {
x: touch.clientX,
y: touch.clientY,
time: Date.now()
};
touchCurrent = { x: touch.clientX, y: touch.clientY };
isTouching = true;
isDragging = true;
dragStart = {
x: touch.clientX - position.x,
y: touch.clientY - position.y
};
hasMoved = false;
shouldPreventClick = false;
document.body.classList.add('dragging');
}
function handleTouchMove(e: TouchEvent) {
if (!isTouching || !touchStart || !position || e.touches.length !== 1) return;
const touch = e.touches[0];
touchCurrent = { x: touch.clientX, y: touch.clientY };
// Calcular velocidade do swipe
const deltaTime = Date.now() - touchStart.time;
const deltaX = touch.clientX - touchStart.x;
const deltaY = touch.clientY - touchStart.y;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (deltaTime > 0) {
swipeVelocity = distance / deltaTime; // pixels por ms
}
// Calcular nova posição
const newX = touch.clientX - dragStart.x;
const newY = touch.clientY - dragStart.y;
// Verificar se houve movimento significativo
const deltaXAbs = Math.abs(newX - position.x);
const deltaYAbs = Math.abs(newY - position.y);
if (deltaXAbs > dragThreshold || deltaYAbs > dragThreshold) {
hasMoved = true;
shouldPreventClick = true;
}
// Dimensões do widget
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth =
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight =
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
position = {
x: Math.max(minX, Math.min(newX, maxX)),
y: Math.max(minY, Math.min(newY, maxY))
};
}
function handleTouchEnd(e: TouchEvent) {
if (!isTouching || !touchStart || !position) return;
const hadMoved = hasMoved;
// Aplicar momentum se houver velocidade suficiente
if (swipeVelocity > 0.5 && hadMoved) {
const deltaX = touchCurrent ? touchCurrent.x - touchStart.x : 0;
const deltaY = touchCurrent ? touchCurrent.y - touchStart.y : 0;
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
if (distance > 10) {
// Aplicar momentum suave
const momentum = Math.min(swipeVelocity * 50, 200); // Limitar momentum
const angle = Math.atan2(deltaY, deltaX);
let momentumX = position.x + Math.cos(angle) * momentum;
let momentumY = position.y + Math.sin(angle) * momentum;
// Limitar dentro dos bounds
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
const winWidth =
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
const winHeight =
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
const minX = -(widgetWidth - 100);
const maxX = Math.max(0, winWidth - 100);
const minY = -(widgetHeight - 100);
const maxY = Math.max(0, winHeight - 100);
momentumX = Math.max(minX, Math.min(momentumX, maxX));
momentumY = Math.max(minY, Math.min(momentumY, maxY));
position = { x: momentumX, y: momentumY };
isAnimating = true;
setTimeout(() => {
isAnimating = false;
ajustarPosicao();
}, 300);
}
} else {
ajustarPosicao();
}
isDragging = false;
isTouching = false;
touchStart = null;
touchCurrent = null;
swipeVelocity = 0;
document.body.classList.remove('dragging');
setTimeout(() => {
hasMoved = false;
shouldPreventClick = false;
}, 100);
savePosition();
}
function handleMouseMove(e: MouseEvent) { function handleMouseMove(e: MouseEvent) {
if (isResizing) { if (isResizing) {
handleResizeMove(e); handleResizeMove(e);
@@ -749,12 +946,90 @@
window.addEventListener('mousemove', handleMouseMove); window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp); window.addEventListener('mouseup', handleMouseUp);
window.addEventListener('touchmove', handleTouchMove, { passive: false });
window.addEventListener('touchend', handleTouchEnd);
return () => { return () => {
window.removeEventListener('mousemove', handleMouseMove); window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('mouseup', handleMouseUp);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
}; };
}); });
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
// Atualizar cores inicialmente
atualizarCores();
// Escutar mudanças de tema
window.addEventListener('themechange', atualizarCores);
// Observar mudanças no atributo data-theme do HTML
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter gradiente do tema
function obterGradienteTema() {
const primary = coresTema.primary;
// Criar variações da cor primária para o gradiente
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 50%, ${primary}bb 100%)`;
}
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary.trim();
// Se já for rgba, extrair os valores
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',').map(v => v.trim());
if (values.length >= 3) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
}
// Se for hex, converter
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
if (hex.length === 6) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
// Se for hsl, converter para hsla
if (primary.startsWith('hsl')) {
const match = primary.match(/hsl\(([^)]+)\)/);
if (match) {
return `hsla(${match[1]}, ${alpha})`;
}
// Fallback: tentar adicionar alpha
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
// Fallback padrão
return `rgba(102, 126, 234, ${alpha})`;
}
</script> </script>
<!-- Botão flutuante MODERNO E ARRASTÁVEL --> <!-- Botão flutuante MODERNO E ARRASTÁVEL -->
@@ -775,10 +1050,10 @@
bottom: {bottomPos}; bottom: {bottomPos};
right: {rightPos}; right: {rightPos};
position: fixed !important; position: fixed !important;
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); background: {obterGradienteTema()};
box-shadow: box-shadow:
0 20px 60px -10px rgba(102, 126, 234, 0.5), 0 20px 60px -10px {obterPrimariaRgba(0.5)},
0 10px 30px -5px rgba(118, 75, 162, 0.4), 0 10px 30px -5px {obterPrimariaRgba(0.4)},
0 0 0 1px rgba(255, 255, 255, 0.1) inset; 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
border-radius: 50%; border-radius: 50%;
cursor: {isDragging ? 'grabbing' : 'grab'}; cursor: {isDragging ? 'grabbing' : 'grab'};
@@ -791,9 +1066,16 @@
onmouseup={(e) => { onmouseup={(e) => {
handleMouseUp(e); handleMouseUp(e);
}} }}
ontouchstart={handleTouchStart}
onclick={(e) => { onclick={(e) => {
// Prevenir clique simples se estamos processando um duplo clique
if (isDoubleClicking) {
e.preventDefault();
e.stopPropagation();
return;
}
// Só executar toggle se não houve movimento durante o arrastar // Só executar toggle se não houve movimento durante o arrastar
if (!shouldPreventClick && !hasMoved) { if (!shouldPreventClick && !hasMoved && !isTouching) {
handleToggle(); handleToggle();
} else { } else {
// Prevenir clique se houve movimento // Prevenir clique se houve movimento
@@ -802,49 +1084,66 @@
shouldPreventClick = false; // Resetar após prevenir shouldPreventClick = false; // Resetar após prevenir
} }
}} }}
aria-label="Abrir chat" ondblclick={(e) => {
// Prevenir que o clique simples seja executado após o duplo clique
e.preventDefault();
e.stopPropagation();
// Executar maximização apenas se não houve movimento
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
aria-label="Abrir chat (duplo clique para maximizar)"
> >
<!-- Anel de brilho rotativo --> <!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
<div <div
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100" class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100"
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;" style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.4) 25%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0.4) 75%, transparent 100%); animation: rotate 3s linear infinite; transform-origin: center;"
></div> ></div>
<!-- Segunda camada para efeito de profundidade -->
<!-- Ondas de pulso -->
<div <div
class="absolute inset-0 rounded-full" class="absolute inset-0 cursor-pointer rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60"
style="animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;" style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
onclick={(e) => {
// Propagar o clique para o elemento pai
e.stopPropagation();
if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) {
handleToggle();
}
}}
ondblclick={(e) => {
e.stopPropagation();
if (!shouldPreventClick && !hasMoved && !isTouching) {
handleDoubleClick();
}
}}
></div> ></div>
<!-- Efeito de brilho pulsante durante arrasto -->
{#if isDragging || isTouching}
<div
class="absolute inset-0 animate-pulse rounded-full opacity-30"
style="background: radial-gradient(circle at center, rgba(255,255,255,0.4) 0%, transparent 70%); animation: pulse-glow 1.5s ease-in-out infinite;"
></div>
{/if}
<!-- Ícone de chat moderno com efeito 3D --> <!-- Ícone de chat moderno com efeito 3D -->
<svg <MessageSquare
xmlns="http://www.w3.org/2000/svg" class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));" style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
> strokeWidth={2}
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> />
<circle cx="9" cy="10" r="1" fill="currentColor" />
<circle cx="12" cy="10" r="1" fill="currentColor" />
<circle cx="15" cy="10" r="1" fill="currentColor" />
</svg>
<!-- Badge ULTRA PREMIUM com gradiente e brilho --> <!-- Badge ULTRA PREMIUM com gradiente e brilho usando cores do tema -->
{#if count?.data && count.data > 0} {#if count?.data && count.data > 0}
<span <span
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white" class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
style=" style="
background: linear-gradient(135deg, #ff416c, #ff4b2b); background: {coresTema.error ? `linear-gradient(135deg, ${coresTema.error}, ${coresTema.error}dd)` : 'linear-gradient(135deg, #ff416c, #ff4b2b)'};
box-shadow: box-shadow:
0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 8px 24px -4px {coresTema.error ? obterPrimariaRgba(0.6).replace(coresTema.primary, coresTema.error) : 'rgba(255, 65, 108, 0.6)'},
0 4px 12px -2px rgba(255, 75, 43, 0.4), 0 4px 12px -2px {coresTema.error ? obterPrimariaRgba(0.4).replace(coresTema.primary, coresTema.error) : 'rgba(255, 75, 43, 0.4)'},
0 0 0 3px rgba(255, 255, 255, 0.3), 0 0 0 3px rgba(255, 255, 255, 0.3),
0 0 0 5px rgba(255, 65, 108, 0.2); 0 0 0 5px {coresTema.error ? obterPrimariaRgba(0.2).replace(coresTema.primary, coresTema.error) : 'rgba(255, 65, 108, 0.2)'};
animation: badge-bounce 2s ease-in-out infinite; animation: badge-bounce 2s ease-in-out infinite;
" "
> >
@@ -897,8 +1196,8 @@
<div <div
class="relative flex items-center justify-between overflow-hidden px-6 py-5 text-white" class="relative flex items-center justify-between overflow-hidden px-6 py-5 text-white"
style=" style="
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); background: {obterGradienteTema()};
box-shadow: 0 8px 32px -4px rgba(102, 126, 234, 0.3); box-shadow: 0 8px 32px -4px {obterPrimariaRgba(0.3)};
cursor: {isDragging ? 'grabbing' : 'grab'}; cursor: {isDragging ? 'grabbing' : 'grab'};
" "
onmousedown={handleMouseDown} onmousedown={handleMouseDown}
@@ -925,26 +1224,16 @@
{#if avatarUrlDoUsuario()} {#if avatarUrlDoUsuario()}
<img <img
src={avatarUrlDoUsuario()} src={avatarUrlDoUsuario()}
alt={currentUser?.data?.nome || 'Usuário'} alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else} {:else}
<!-- Fallback: ícone de chat genérico --> <!-- Fallback: ícone de chat genérico -->
<svg <MessageSquare
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-5 w-5" class="h-5 w-5"
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));" style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
> strokeWidth={2}
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" /> />
<line x1="9" y1="10" x2="15" y2="10" />
<line x1="9" y1="14" x2="13" y2="14" />
</svg>
{/if} {/if}
</div> </div>
<span <span
@@ -966,19 +1255,11 @@
<div <div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20" class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
></div> ></div>
<svg <Minus
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110" class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));" style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
> strokeWidth={2.5}
<line x1="5" y1="12" x2="19" y2="12" /> />
</svg>
</button> </button>
<!-- Botão maximizar MODERNO --> <!-- Botão maximizar MODERNO -->
@@ -992,21 +1273,11 @@
<div <div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20" class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
></div> ></div>
<svg <Maximize2
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110" class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));" style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
> strokeWidth={2.5}
<path />
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"
/>
</svg>
</button> </button>
<!-- Botão fechar MODERNO --> <!-- Botão fechar MODERNO -->
@@ -1020,20 +1291,11 @@
<div <div
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30" class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30"
></div> ></div>
<svg <X
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="relative z-10 h-5 w-5 transition-all duration-300 group-hover:scale-110 group-hover:rotate-90" class="relative z-10 h-5 w-5 transition-all duration-300 group-hover:scale-110 group-hover:rotate-90"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));" style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
> strokeWidth={2.5}
<line x1="18" y1="6" x2="6" y2="18" /> />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -1052,82 +1314,84 @@
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pela borda superior" aria-label="Redimensionar janela pela borda superior"
class="hover:bg-primary/20 absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors" class="absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 24px 0 0;"
onmousedown={(e) => handleResizeStart(e, 'n')} onmousedown={(e) => handleResizeStart(e, 'n')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')}
style="border-radius: 24px 24px 0 0;"
></div> ></div>
<!-- Bottom --> <!-- Bottom -->
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pela borda inferior" aria-label="Redimensionar janela pela borda inferior"
class="hover:bg-primary/20 absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors" class="absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 24px 24px;"
onmousedown={(e) => handleResizeStart(e, 's')} onmousedown={(e) => handleResizeStart(e, 's')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')}
style="border-radius: 0 0 24px 24px;"
></div> ></div>
<!-- Left --> <!-- Left -->
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pela borda esquerda" aria-label="Redimensionar janela pela borda esquerda"
class="hover:bg-primary/20 absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors" class="absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 0 0 24px;"
onmousedown={(e) => handleResizeStart(e, 'w')} onmousedown={(e) => handleResizeStart(e, 'w')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')}
style="border-radius: 24px 0 0 24px;"
></div> ></div>
<!-- Right --> <!-- Right -->
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pela borda direita" aria-label="Redimensionar janela pela borda direita"
class="hover:bg-primary/20 absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors" class="absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 24px 24px 0;"
onmousedown={(e) => handleResizeStart(e, 'e')} onmousedown={(e) => handleResizeStart(e, 'e')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')}
style="border-radius: 0 24px 24px 0;"
></div> ></div>
<!-- Corners --> <!-- Corners -->
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pelo canto superior esquerdo" aria-label="Redimensionar janela pelo canto superior esquerdo"
class="hover:bg-primary/20 absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors" class="absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 0 0 0;"
onmousedown={(e) => handleResizeStart(e, 'nw')} onmousedown={(e) => handleResizeStart(e, 'nw')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')}
style="border-radius: 24px 0 0 0;"
></div> ></div>
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pelo canto superior direito" aria-label="Redimensionar janela pelo canto superior direito"
class="hover:bg-primary/20 absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors" class="absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 24px 0 0;"
onmousedown={(e) => handleResizeStart(e, 'ne')} onmousedown={(e) => handleResizeStart(e, 'ne')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')}
style="border-radius: 0 24px 0 0;"
></div> ></div>
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pelo canto inferior esquerdo" aria-label="Redimensionar janela pelo canto inferior esquerdo"
class="hover:bg-primary/20 absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors" class="absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 0 24px;"
onmousedown={(e) => handleResizeStart(e, 'sw')} onmousedown={(e) => handleResizeStart(e, 'sw')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')}
style="border-radius: 0 0 0 24px;"
></div> ></div>
<div <div
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Redimensionar janela pelo canto inferior direito" aria-label="Redimensionar janela pelo canto inferior direito"
class="hover:bg-primary/20 absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors" class="absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 24px 0;"
onmousedown={(e) => handleResizeStart(e, 'se')} onmousedown={(e) => handleResizeStart(e, 'se')}
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')} onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')}
style="border-radius: 0 0 24px 0;"
></div> ></div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Indicador de Conexão -->
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) --> <!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
{#if showGlobalNotificationPopup && globalNotificationMessage} {#if showGlobalNotificationPopup && globalNotificationMessage}
{@const notificationMsg = globalNotificationMessage} {@const notificationMsg = globalNotificationMessage}
@@ -1135,8 +1399,8 @@
role="button" role="button"
tabindex="0" tabindex="0"
aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}" aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}"
class="bg-base-100 border-primary/20 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl" class="bg-base-100 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl"
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;" style="border-color: {obterPrimariaRgba(0.2)}; box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
onclick={() => { onclick={() => {
const conversaIdToOpen = notificationMsg?.conversaId; const conversaIdToOpen = notificationMsg?.conversaId;
showGlobalNotificationPopup = false; showGlobalNotificationPopup = false;
@@ -1167,21 +1431,8 @@
}} }}
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
<div class="bg-primary/20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full"> <div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full" style="background-color: {obterPrimariaRgba(0.2)}">
<svg <Bell class="h-5 w-5" style="color: {coresTema.primary}" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="text-primary h-5 w-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
/>
</svg>
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-base-content mb-1 text-sm font-semibold"> <p class="text-base-content mb-1 text-sm font-semibold">
@@ -1190,7 +1441,7 @@
<p class="text-base-content/70 line-clamp-2 text-xs"> <p class="text-base-content/70 line-clamp-2 text-xs">
{notificationMsg.conteudo} {notificationMsg.conteudo}
</p> </p>
<p class="text-primary mt-1 text-xs">Clique para abrir</p> <p class="mt-1 text-xs" style="color: {coresTema.primary}">Clique para abrir</p>
</div> </div>
<button <button
type="button" type="button"
@@ -1205,16 +1456,7 @@
} }
}} }}
> >
<svg <X class="h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="h-4 w-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
</div> </div>
@@ -1247,20 +1489,25 @@
} }
} }
/* Ondas de pulso para o botão flutuante */ /* Ondas de pulso para o botão flutuante - cores dinâmicas */
@keyframes pulse-ring { @keyframes pulse-ring {
0% { 0% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.5); box-shadow: 0 0 0 0 var(--pulse-color, rgba(102, 126, 234, 0.5));
} }
50% { 50% {
box-shadow: 0 0 0 15px rgba(102, 126, 234, 0); box-shadow: 0 0 0 15px transparent;
} }
100% { 100% {
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0); box-shadow: 0 0 0 0 var(--pulse-color, rgba(102, 126, 234, 0.5));
} }
} }
/* Rotação para anel de brilho */ /* Estilos para handles de redimensionamento com hover dinâmico */
[style*="--hover-bg"]:hover {
background-color: var(--hover-bg) !important;
}
/* Rotação para anel de brilho - suavizada */
@keyframes rotate { @keyframes rotate {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@@ -1270,6 +1517,19 @@
} }
} }
/* Efeito de pulso de brilho durante arrasto */
@keyframes pulse-glow {
0%,
100% {
opacity: 0.2;
transform: scale(1);
}
50% {
opacity: 0.4;
transform: scale(1.05);
}
}
/* Efeito shimmer para o header */ /* Efeito shimmer para o header */
@keyframes shimmer { @keyframes shimmer {
0% { 0% {

View File

@@ -1,7 +1,5 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { voltarParaLista } from '$lib/stores/chatStore';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import MessageList from './MessageList.svelte'; import MessageList from './MessageList.svelte';
import MessageInput from './MessageInput.svelte'; import MessageInput from './MessageInput.svelte';
@@ -9,14 +7,38 @@
import UserAvatar from './UserAvatar.svelte'; import UserAvatar from './UserAvatar.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte'; import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte'; import SalaReuniaoManager from './SalaReuniaoManager.svelte';
import { getAvatarUrl } from '$lib/utils/avatarGenerator'; import CallWindow from '../call/CallWindow.svelte';
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte'; import ErrorModal from '../ErrorModal.svelte';
import E2EManagementModal from './E2EManagementModal.svelte';
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { browser } from '$app/environment';
import { traduzirErro } from '$lib/utils/erroHelpers';
import {
ArrowLeft,
Bell,
Clock,
LogOut,
Users,
Phone,
Video,
Search,
Lock,
MoreVertical,
XCircle,
X
} from 'lucide-svelte';
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
import { voltarParaLista } from '$lib/stores/chatStore';
import { useConvexClient, useQuery } from 'convex-svelte';
import { obterCoresDoTema } from '$lib/utils/temas';
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
interface Props { interface Props {
conversaId: string; conversaId: string;
} }
let { conversaId }: Props = $props(); const { conversaId }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
@@ -26,12 +48,33 @@
let showSalaManager = $state(false); let showSalaManager = $state(false);
let showAdminMenu = $state(false); let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false); let showNotificacaoModal = $state(false);
let showE2EModal = $state(false);
let iniciandoChamada = $state(false);
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
let showSearch = $state(false);
let searchQuery = $state('');
let searchResults = $state<Array<unknown | undefined>>([]);
let searching = $state(false);
let selectedSearchResult = $state<number>(-1);
let showErrorModal = $state(false);
let errorTitle = $state('Erro');
let errorMessage = $state('');
let errorInstructions = $state<string | undefined>(undefined);
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
conversaId: conversaId as Id<'conversas'>
});
let chamadaAtual = $derived(chamadaAtivaQuery?.data);
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<'conversas'> conversaId: conversaId as Id<'conversas'>
}); });
// Verificar se a conversa tem criptografia E2E habilitada
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, {
conversaId: conversaId as Id<'conversas'>
});
const conversa = $derived(() => { const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId); console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data); console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
@@ -59,10 +102,7 @@
const c = conversa(); const c = conversa();
if (!c) return '💬'; if (!c) return '💬';
if (c.tipo === 'grupo') { if (c.tipo === 'grupo') {
return c.avatar || '👥'; return '👥';
}
if (c.outroUsuario?.avatar) {
return c.outroUsuario.avatar;
} }
return '👤'; return '👤';
} }
@@ -115,36 +155,221 @@
alert(errorMessage); alert(errorMessage);
} }
} }
// Funções para chamadas
async function iniciarChamada(
tipo: 'audio' | 'video',
abrirEmNovaJanela: boolean = false
): Promise<void> {
if (chamadaAtual) {
errorTitle = 'Chamada já em andamento';
errorMessage =
'Já existe uma chamada ativa nesta conversa. Você precisa finalizar a chamada atual antes de iniciar uma nova.';
errorInstructions = 'Finalize a chamada atual e tente novamente.';
errorDetails = undefined;
showErrorModal = true;
return;
}
// Verificar se Jitsi está configurado
try {
const configJitsi = await client.query(api.configuracaoJitsi.obterConfigJitsi, {});
if (!configJitsi || !configJitsi.ativo) {
errorTitle = 'Jitsi não configurado';
errorMessage =
'O sistema de videochamadas não está configurado. Entre em contato com o administrador do sistema para configurar o Jitsi.';
errorInstructions =
'Um administrador precisa configurar o servidor Jitsi no painel de administração antes que as chamadas possam ser iniciadas.';
errorDetails = undefined;
showErrorModal = true;
return;
}
} catch (error: unknown) {
console.error('Erro ao verificar configuração Jitsi:', error);
// Continuar mesmo se houver erro na verificação (pode ser problema temporário)
}
try {
iniciandoChamada = true;
const chamadaId = await client.mutation(api.chamadas.criarChamada, {
conversaId: conversaId as Id<'conversas'>,
tipo,
audioHabilitado: true,
videoHabilitado: tipo === 'video'
});
// Se deve abrir em nova janela
if (abrirEmNovaJanela && browser) {
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import(
'$lib/utils/callWindowManager'
);
if (!verificarSuportePopup()) {
errorTitle = 'Popups bloqueados';
errorMessage =
'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.';
errorInstructions = 'Verifique as configurações do seu navegador para permitir popups.';
showErrorModal = true;
return;
}
// Buscar informações da chamada para obter roomName
const chamadaInfo = await client.query(api.chamadas.obterChamada, { chamadaId });
if (!chamadaInfo) {
throw new Error('Chamada não encontrada');
}
const meuPerfil = await client.query(api.auth.getCurrentUser, {});
const ehAnfitriao = chamadaInfo.criadoPor === meuPerfil?._id;
// Abrir em popup
const popup = abrirCallWindowEmPopup({
chamadaId: chamadaId as string,
conversaId: conversaId as string,
tipo,
roomName: chamadaInfo.roomName,
ehAnfitriao
});
if (!popup) {
throw new Error('Não foi possível abrir a janela de chamada');
}
// Não definir chamadaAtiva aqui, pois será gerenciada pela janela popup
return;
}
chamadaAtiva = chamadaId;
} catch (error) {
console.error('Erro ao iniciar chamada:', error);
// Traduzir erro técnico para mensagem amigável
const erroTraduzido = traduzirErro(error);
errorTitle = erroTraduzido.titulo;
errorMessage = erroTraduzido.mensagem;
errorInstructions = erroTraduzido.instrucoes;
// Apenas mostrar detalhes técnicos se solicitado e disponível
errorDetails =
erroTraduzido.mostrarDetalhesTecnicos && erroTraduzido.detalhesTecnicos
? erroTraduzido.detalhesTecnicos
: undefined;
showErrorModal = true;
} finally {
iniciandoChamada = false;
}
}
function fecharErrorModal(): void {
showErrorModal = false;
errorMessage = '';
errorInstructions = undefined;
errorDetails = undefined;
}
function fecharChamada(): void {
chamadaAtiva = null;
}
// Verificar se usuário é anfitrião da chamada atual
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
let souAnfitriao = $derived(
chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false
);
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
atualizarCores();
window.addEventListener('themechange', atualizarCores);
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary.trim();
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',').map(v => v.trim());
if (values.length >= 3) {
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
}
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
if (hex.length === 6) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
}
if (primary.startsWith('hsl')) {
const match = primary.match(/hsl\(([^)]+)\)/);
if (match) {
return `hsla(${match[1]}, ${alpha})`;
}
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
</script> </script>
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}> <div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
<!-- Header --> <!-- Header -->
<div <div
class="border-base-300 bg-base-200 flex items-center gap-3 border-b px-4 py-3" class="border-base-300 flex items-center gap-3 border-b px-4 py-3"
style="background-color: {coresTema.base200}; border-color: {coresTema.base300};"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
<!-- Botão Voltar --> <!-- Botão Voltar -->
<button <button
type="button" type="button"
class="btn btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110" class="btn btn-sm btn-circle transition-all duration-200 hover:scale-110"
style="--hover-bg: {obterPrimariaRgba(0.2)}"
onclick={voltarParaLista} onclick={voltarParaLista}
aria-label="Voltar" aria-label="Voltar"
title="Voltar para lista de conversas" title="Voltar para lista de conversas"
> >
<ArrowLeft class="text-primary h-6 w-6" strokeWidth={2.5} /> <ArrowLeft class="h-6 w-6" style="color: {coresTema.primary}" strokeWidth={2.5} />
</button> </button>
<!-- Avatar e Info --> <!-- Avatar e Info -->
<div class="relative shrink-0"> <div class="relative shrink-0">
{#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario} {#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario}
<UserAvatar <UserAvatar
avatar={conversa()?.outroUsuario?.avatar}
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl} fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || 'Usuário'} nome={conversa()?.outroUsuario?.nome || 'Usuário'}
size="md" size="md"
userId={conversa()?.outroUsuario?._id}
/> />
{:else} {:else}
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl"> <div class="flex h-10 w-10 items-center justify-center rounded-full text-xl" style="background-color: {obterPrimariaRgba(0.2)}">
{getAvatarConversa()} {getAvatarConversa()}
</div> </div>
{/if} {/if}
@@ -156,9 +381,29 @@
</div> </div>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="text-base-content truncate font-semibold"> <!-- Nome da conversa com indicador de criptografia E2E -->
{getNomeConversa()} <div class="flex items-center gap-2">
</p> <p class="text-base-content truncate font-semibold">
{getNomeConversa()}
</p>
{#if temCriptografiaE2E?.data}
<button
type="button"
class="shrink-0"
onclick={(e) => {
e.stopPropagation();
showE2EModal = true;
}}
title="Gerenciar criptografia end-to-end (E2E)"
aria-label="Gerenciar criptografia E2E"
>
<Lock
class="text-success hover:text-success/80 h-4 w-4 transition-colors"
strokeWidth={2}
/>
</button>
{/if}
</div>
{#if getStatusMensagem()} {#if getStatusMensagem()}
<p class="text-base-content/60 truncate text-xs"> <p class="text-base-content/60 truncate text-xs">
{getStatusMensagem()} {getStatusMensagem()}
@@ -181,7 +426,7 @@
{conversa()?.participantesInfo?.length || 0} {conversa()?.participantesInfo?.length || 0}
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'} {conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
</p> </p>
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0} {#if conversa()?.participantesInfo && conversa()?.participantesInfo?.length > 0}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex -space-x-2"> <div class="flex -space-x-2">
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)} {#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
@@ -195,18 +440,12 @@
alt={participante.nome} alt={participante.nome}
class="h-full w-full object-cover" class="h-full w-full object-cover"
/> />
{:else if participante.avatar}
<img
src={getAvatarUrl(participante.avatar)}
alt={participante.nome}
class="h-full w-full object-cover"
/>
{:else} {:else}
<img <div
src={getAvatarUrl(participante.nome)} class="bg-base-200 flex h-full w-full items-center justify-center text-xs font-semibold"
alt={participante.nome} >
class="h-full w-full object-cover" {participante.nome.substring(0, 2).toUpperCase()}
/> </div>
{/if} {/if}
</div> </div>
{/each} {/each}
@@ -221,7 +460,8 @@
</div> </div>
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data} {#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<span <span
class="text-primary ml-1 text-[10px] font-semibold whitespace-nowrap" class="ml-1 text-[10px] font-semibold whitespace-nowrap"
style="color: {coresTema.primary}"
title="Você é administrador desta sala">• Admin</span title="Você é administrador desta sala">• Admin</span
> >
{/if} {/if}
@@ -233,6 +473,128 @@
<!-- Botões de ação --> <!-- Botões de ação -->
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<!-- Botão de Busca -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
onclick={(e) => {
e.stopPropagation();
showSearch = !showSearch;
if (!showSearch) {
searchQuery = '';
searchResults = [];
}
}}
aria-label="Buscar mensagens"
title="Buscar mensagens"
aria-expanded={showSearch}
>
<div
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
></div>
<Search
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
<!-- Botões de Chamada -->
{#if !chamadaAtual && !chamadaAtiva}
<div class="dropdown dropdown-end">
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio', false);
}}
disabled={iniciandoChamada}
aria-label="Ligação de áudio"
title="Iniciar ligação de áudio"
>
<Phone class="h-5 w-5 text-white" strokeWidth={2} />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio', false);
}}
disabled={iniciandoChamada}
>
<Phone class="h-4 w-4" />
Áudio (nesta janela)
</button>
</li>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('audio', true);
}}
disabled={iniciandoChamada}
>
<Phone class="h-4 w-4" />
Áudio (nova janela)
</button>
</li>
</ul>
</div>
<div class="dropdown dropdown-end">
<button
type="button"
class="btn btn-sm btn-circle btn-primary"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video', false);
}}
disabled={iniciandoChamada}
aria-label="Ligação de vídeo"
title="Iniciar ligação de vídeo"
>
<Video class="h-5 w-5 text-white" strokeWidth={2} />
</button>
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video', false);
}}
disabled={iniciandoChamada}
>
<Video class="h-4 w-4" />
Vídeo (nesta janela)
</button>
</li>
<li>
<button
type="button"
onclick={(e) => {
e.stopPropagation();
iniciarChamada('video', true);
}}
disabled={iniciandoChamada}
>
<Video class="h-4 w-4" />
Vídeo (nova janela)
</button>
</li>
</ul>
</div>
{/if}
<!-- Botão Sair (apenas para grupos e salas de reunião) --> <!-- Botão Sair (apenas para grupos e salas de reunião) -->
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')} {#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<button <button
@@ -352,6 +714,27 @@
</div> </div>
{/if} {/if}
<!-- Botão Gerenciar E2E -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
onclick={(e) => {
e.stopPropagation();
showE2EModal = true;
}}
aria-label="Gerenciar criptografia E2E"
title="Gerenciar criptografia end-to-end"
>
<div
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
></div>
<Lock
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
<!-- Botão Agendar MODERNO --> <!-- Botão Agendar MODERNO -->
<button <button
type="button" type="button"
@@ -372,6 +755,112 @@
</div> </div>
</div> </div>
<!-- Barra de Busca (quando ativa) -->
{#if showSearch}
<div
class="border-base-300 bg-base-200 flex items-center gap-2 border-b px-4 py-2"
onclick={(e) => e.stopPropagation()}
>
<Search class="text-base-content/50 h-4 w-4" strokeWidth={2} />
<input
type="text"
placeholder="Buscar mensagens nesta conversa..."
class="input input-sm input-bordered flex-1"
bind:value={searchQuery}
onkeydown={handleSearchKeyDown}
aria-label="Buscar mensagens"
aria-describedby="search-results-info"
/>
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={() => {
showSearch = false;
searchQuery = '';
searchResults = [];
}}
aria-label="Fechar busca"
>
<X class="h-4 w-4" />
</button>
</div>
<!-- Resultados da Busca -->
{#if searchQuery.trim().length >= 2}
<div
class="border-base-300 bg-base-200 max-h-64 overflow-y-auto border-b"
role="listbox"
aria-label="Resultados da busca"
id="search-results"
>
{#if searching}
<div class="flex items-center justify-center p-4">
<span class="loading loading-spinner loading-sm"></span>
<span class="text-base-content/50 ml-2 text-sm">Buscando...</span>
</div>
{:else if searchResults.length > 0}
<p id="search-results-info" class="sr-only">
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !==
1
? 's'
: ''}
</p>
{#each searchResults as resultado, index (resultado._id)}
<button
type="button"
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors"
style={index === selectedSearchResult ? `background-color: ${obterPrimariaRgba(0.1)}` : ''}
onclick={() => {
window.dispatchEvent(
new CustomEvent('scrollToMessage', {
detail: { mensagemId: resultado._id }
})
);
showSearch = false;
searchQuery = '';
}}
role="option"
aria-selected={index === selectedSearchResult}
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
>
<div
class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
style="background-color: {obterPrimariaRgba(0.2)}"
>
{#if resultado.remetente?.fotoPerfilUrl}
<img
src={resultado.remetente.fotoPerfilUrl}
alt={resultado.remetente.nome}
class="h-full w-full object-cover"
/>
{:else}
<span class="text-xs font-semibold">
{resultado.remetente?.nome?.charAt(0).toUpperCase() || 'U'}
</span>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content mb-1 text-xs font-semibold">
{resultado.remetente?.nome || 'Usuário'}
</p>
<p class="text-base-content/70 line-clamp-2 text-xs">
{resultado.conteudo}
</p>
<p class="text-base-content/50 mt-1 text-xs">
{new Date(resultado.enviadaEm).toLocaleString('pt-BR')}
</p>
</div>
</button>
{/each}
{:else if searchQuery.trim().length >= 2}
<div class="p-4 text-center">
<p class="text-base-content/50 text-sm">Nenhuma mensagem encontrada</p>
</div>
{/if}
</div>
{/if}
{/if}
<!-- Mensagens --> <!-- Mensagens -->
<div class="min-h-0 flex-1 overflow-hidden"> <div class="min-h-0 flex-1 overflow-hidden">
<MessageList conversaId={conversaId as Id<'conversas'>} /> <MessageList conversaId={conversaId as Id<'conversas'>} />
@@ -389,6 +878,14 @@
conversaId={conversaId as Id<'conversas'>} conversaId={conversaId as Id<'conversas'>}
onClose={() => (showScheduleModal = false)} onClose={() => (showScheduleModal = false)}
/> />
<!-- Modal de Gerenciamento E2E -->
{#if showE2EModal}
<E2EManagementModal
conversaId={conversaId as Id<'conversas'>}
onClose={() => (showE2EModal = false)}
/>
{/if}
{/if} {/if}
<!-- Modal de Gerenciamento de Sala --> <!-- Modal de Gerenciamento de Sala -->
@@ -400,6 +897,20 @@
/> />
{/if} {/if}
<!-- Janela de Chamada -->
{#if browser && chamadaAtiva && chamadaAtual}
<div class="pointer-events-none fixed inset-0 z-[9999]">
<CallWindow
chamadaId={chamadaAtiva}
conversaId={conversaId as Id<'conversas'>}
tipo={chamadaAtual.tipo}
roomName={chamadaAtual.roomName}
ehAnfitriao={souAnfitriao}
onClose={fecharChamada}
/>
</div>
{/if}
<!-- Modal de Enviar Notificação --> <!-- Modal de Enviar Notificação -->
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data} {#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<dialog <dialog
@@ -409,7 +920,7 @@
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}> <div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4"> <div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 class="flex items-center gap-2 text-xl font-semibold"> <h2 class="flex items-center gap-2 text-xl font-semibold">
<Bell class="text-primary h-5 w-5" /> <Bell class="h-5 w-5" style="color: {coresTema.primary}" />
Enviar Notificação Enviar Notificação
</h2> </h2>
<button <button
@@ -491,3 +1002,19 @@
</form> </form>
</dialog> </dialog>
{/if} {/if}
<!-- Modal de Erro -->
<ErrorModal
open={showErrorModal}
title={errorTitle}
message={errorMessage}
details={errorInstructions || errorDetails}
onClose={fecharErrorModal}
/>
<style>
/* Estilos para hover dinâmico com cores do tema */
[style*="--hover-bg"]:hover {
background-color: var(--hover-bg) !important;
}
</style>

View File

@@ -0,0 +1,90 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { onMount } from 'svelte';
import { Wifi, WifiOff, AlertCircle } from 'lucide-svelte';
const client = useConvexClient();
let isOnline = $state(true);
let convexConnected = $state(true);
let showIndicator = $state(false);
// Detectar status de conexão com internet
function updateOnlineStatus() {
isOnline = navigator.onLine;
showIndicator = !isOnline || !convexConnected;
}
// Detectar status de conexão com Convex
function updateConvexStatus() {
// Verificar se o client está conectado
// O Convex client expõe o status de conexão
const connectionState = (client as any).connectionState?.();
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
onMount(() => {
// Verificar status inicial
updateOnlineStatus();
updateConvexStatus();
// Listeners para mudanças de conexão
window.addEventListener('online', updateOnlineStatus);
window.addEventListener('offline', updateOnlineStatus);
// Verificar status do Convex periodicamente
const interval = setInterval(() => {
updateConvexStatus();
}, 5000);
return () => {
window.removeEventListener('online', updateOnlineStatus);
window.removeEventListener('offline', updateOnlineStatus);
clearInterval(interval);
};
});
// Observar mudanças no client do Convex
$effect(() => {
// Tentar acessar o estado de conexão do Convex
try {
const connectionState = (client as any).connectionState?.();
if (connectionState !== undefined) {
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
showIndicator = !isOnline || !convexConnected;
}
} catch {
// Se não conseguir acessar, assumir conectado
convexConnected = true;
}
});
</script>
{#if showIndicator}
<div
class="fixed bottom-4 left-4 z-[99998] flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg transition-all"
class:bg-error={!isOnline || !convexConnected}
class:bg-warning={isOnline && !convexConnected}
class:text-white={!isOnline || !convexConnected}
role="status"
aria-live="polite"
aria-label={!isOnline
? 'Sem conexão com a internet'
: !convexConnected
? 'Reconectando ao servidor'
: 'Conectado'}
>
{#if !isOnline}
<WifiOff class="h-4 w-4" />
<span class="text-sm font-medium">Sem conexão</span>
{:else if !convexConnected}
<AlertCircle class="h-4 w-4" />
<span class="text-sm font-medium">Reconectando...</span>
{:else}
<Wifi class="h-4 w-4" />
<span class="text-sm font-medium">Conectado</span>
{/if}
</div>
{/if}

View File

@@ -0,0 +1,267 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { Lock, X, RefreshCw, Shield, AlertTriangle, CheckCircle } from 'lucide-svelte';
import {
generateEncryptionKey,
exportKey,
storeEncryptionKey,
hasEncryptionKey,
removeStoredEncryptionKey
} from '$lib/utils/e2eEncryption';
import { armazenarChaveCriptografia, removerChaveCriptografia } from '$lib/stores/chatStore';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface Props {
conversaId: Id<'conversas'>;
onClose: () => void;
}
let { conversaId, onClose }: Props = $props();
const client = useConvexClient();
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
const chaveAtual = useQuery(api.chat.obterChaveCriptografia, { conversaId });
const conversa = useQuery(api.chat.listarConversas, {});
let ativando = $state(false);
let regenerando = $state(false);
let desativando = $state(false);
// Obter informações da conversa
const conversaInfo = $derived(() => {
if (!conversa?.data || !Array.isArray(conversa.data)) return null;
return conversa.data.find((c: { _id: string }) => c._id === conversaId) || null;
});
async function ativarE2E() {
if (!confirm('Deseja ativar criptografia end-to-end para esta conversa?\n\nTodas as mensagens futuras serão criptografadas.')) {
return;
}
try {
ativando = true;
// Gerar nova chave de criptografia
const encryptionKey = await generateEncryptionKey();
const keyData = await exportKey(encryptionKey.key);
// Armazenar localmente
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey.key);
// Compartilhar chave com outros participantes
await client.mutation(api.chat.compartilharChaveCriptografia, {
conversaId,
chaveCompartilhada: keyData, // Em produção, isso deveria ser criptografado com chave pública de cada participante
keyId: encryptionKey.keyId
});
alert('Criptografia E2E ativada com sucesso!');
} catch (error) {
console.error('Erro ao ativar E2E:', error);
alert('Erro ao ativar criptografia E2E');
} finally {
ativando = false;
}
}
async function regenerarChave() {
if (!confirm('Deseja regenerar a chave de criptografia?\n\nAs mensagens antigas continuarão legíveis, mas novas mensagens usarão a nova chave.')) {
return;
}
try {
regenerando = true;
// Gerar nova chave
const encryptionKey = await generateEncryptionKey();
const keyData = await exportKey(encryptionKey.key);
// Atualizar chave localmente
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
armazenarChaveCriptografia(conversaId, encryptionKey.key);
// Compartilhar nova chave (desativa chaves antigas automaticamente)
await client.mutation(api.chat.compartilharChaveCriptografia, {
conversaId,
chaveCompartilhada: keyData,
keyId: encryptionKey.keyId
});
alert('Chave regenerada com sucesso!');
} catch (error) {
console.error('Erro ao regenerar chave:', error);
alert('Erro ao regenerar chave');
} finally {
regenerando = false;
}
}
async function desativarE2E() {
if (!confirm('Deseja desativar criptografia end-to-end para esta conversa?\n\nAs mensagens antigas continuarão criptografadas, mas novas mensagens não serão mais criptografadas.')) {
return;
}
try {
desativando = true;
// Remover chave localmente
removeStoredEncryptionKey(conversaId);
removerChaveCriptografia(conversaId);
// Desativar chave no servidor (marcar como inativa)
// Nota: Não removemos a chave do servidor, apenas a marcamos como inativa
// Isso permite que mensagens antigas ainda possam ser descriptografadas
if (chaveAtual?.data) {
// A mutation compartilharChaveCriptografia já desativa chaves antigas
// Mas precisamos de uma mutation específica para desativar completamente
// Por enquanto, vamos apenas remover localmente
alert('Criptografia E2E desativada localmente. As mensagens antigas ainda podem ser descriptografadas se você tiver a chave.');
}
} catch (error) {
console.error('Erro ao desativar E2E:', error);
alert('Erro ao desativar criptografia E2E');
} finally {
desativando = false;
}
}
function formatarData(timestamp: number): string {
try {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
} catch {
return 'Data inválida';
}
}
</script>
<div
class="modal modal-open"
role="dialog"
aria-labelledby="modal-title"
aria-modal="true"
onclick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
class="modal-box max-w-2xl"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
<Shield class="text-primary h-5 w-5" />
Criptografia End-to-End (E2E)
</h2>
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Status da Criptografia -->
<div class="card bg-base-200">
<div class="card-body">
<div class="flex items-center gap-3">
{#if temCriptografiaE2E?.data}
<CheckCircle class="text-success h-6 w-6 shrink-0" />
<div class="flex-1">
<h3 class="card-title text-lg text-success">Criptografia E2E Ativa</h3>
<p class="text-sm text-base-content/70">
Suas mensagens estão protegidas com criptografia end-to-end
</p>
</div>
{:else}
<AlertTriangle class="text-warning h-6 w-6 shrink-0" />
<div class="flex-1">
<h3 class="card-title text-lg text-warning">Criptografia E2E Desativada</h3>
<p class="text-sm text-base-content/70">
Suas mensagens não estão criptografadas
</p>
</div>
{/if}
</div>
</div>
</div>
<!-- Informações da Chave -->
{#if temCriptografiaE2E?.data && chaveAtual?.data}
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Informações da Chave</h3>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="text-base-content/70">ID da Chave:</span>
<span class="font-mono text-xs">{chaveAtual.data.keyId.substring(0, 16)}...</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">Criada em:</span>
<span>{formatarData(chaveAtual.data.criadoEm)}</span>
</div>
<div class="flex justify-between">
<span class="text-base-content/70">Chave local:</span>
<span class="text-success">
{hasEncryptionKey(conversaId) ? '✓ Armazenada' : '✗ Não encontrada'}
</span>
</div>
</div>
</div>
</div>
{/if}
<!-- Informações sobre E2E -->
<div class="alert alert-info">
<Lock class="h-5 w-5" />
<div class="text-sm">
<p class="font-semibold">Como funciona a criptografia E2E?</p>
<ul class="mt-2 list-inside list-disc space-y-1 text-xs">
<li>Suas mensagens são criptografadas no seu dispositivo antes de serem enviadas</li>
<li>Apenas você e os participantes da conversa podem descriptografar as mensagens</li>
<li>O servidor não consegue ler o conteúdo das mensagens criptografadas</li>
<li>Mensagens antigas continuam legíveis mesmo após regenerar a chave</li>
</ul>
</div>
</div>
<!-- Ações -->
<div class="flex flex-col gap-3">
{#if temCriptografiaE2E?.data}
<button
type="button"
class="btn btn-warning"
onclick={regenerarChave}
disabled={regenerando || ativando || desativando}
>
<RefreshCw class="h-4 w-4 {regenerando ? 'animate-spin' : ''}" />
{regenerando ? 'Regenerando...' : 'Regenerar Chave'}
</button>
<button
type="button"
class="btn btn-error"
onclick={desativarE2E}
disabled={regenerando || ativando || desativando}
>
<X class="h-4 w-4" />
{desativando ? 'Desativando...' : 'Desativar E2E'}
</button>
{:else}
<button
type="button"
class="btn btn-primary"
onclick={ativarE2E}
disabled={regenerando || ativando || desativando}
>
<Lock class="h-4 w-4" />
{ativando ? 'Ativando...' : 'Ativar Criptografia E2E'}
</button>
{/if}
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,26 +1,26 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { abrirConversa } from '$lib/stores/chatStore'; import { useConvexClient, useQuery } from 'convex-svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import { import {
ChevronRight,
MessageSquare, MessageSquare,
Plus,
Search,
User, User,
Users, Users,
UserX,
Video, Video,
X, X
Search,
ChevronRight,
Plus,
UserX
} from 'lucide-svelte'; } from 'lucide-svelte';
import { abrirConversa } from '$lib/stores/chatStore';
import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
interface Props { interface Props {
onClose: () => void; onClose: () => void;
} }
let { onClose }: Props = $props(); const { onClose }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const usuarios = useQuery(api.usuarios.listarParaChat, {}); const usuarios = useQuery(api.usuarios.listarParaChat, {});
@@ -35,7 +35,7 @@
let salaReuniaoName = $state(''); let salaReuniaoName = $state('');
let loading = $state(false); let loading = $state(false);
const usuariosFiltrados = $derived(() => { let usuariosFiltrados = $derived(() => {
if (!usuarios?.data) return []; if (!usuarios?.data) return [];
// Filtrar o próprio usuário // Filtrar o próprio usuário

File diff suppressed because it is too large Load Diff

View File

@@ -1,89 +1,169 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from "convex-svelte"; import { useConvexClient } from 'convex-svelte';
import { api } from "@sgse-app/backend/convex/_generated/api"; import { useQuery } from 'convex-svelte';
import { onMount } from "svelte"; import { api } from '@sgse-app/backend/convex/_generated/api';
import { onMount } from 'svelte';
const client = useConvexClient(); const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte // Verificar se o usuário está autenticado antes de gerenciar presença
const currentUser = useQuery(api.auth.getCurrentUser, {});
let usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined);
let heartbeatInterval: ReturnType<typeof setInterval> | null = null; // Token é passado automaticamente via interceptadores em +layout.svelte
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
let lastActivity = Date.now();
// Detectar atividade do usuário let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
function handleActivity() { let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
lastActivity = Date.now(); let lastActivity = Date.now();
let lastStatusUpdate = 0;
let pendingStatusUpdate: ReturnType<typeof setTimeout> | null = null;
const STATUS_UPDATE_THROTTLE = 5000; // 5 segundos entre atualizações
// Limpar timeout de inatividade anterior // Função auxiliar para atualizar status com throttle e tratamento de erro
if (inactivityTimeout) { async function atualizarStatusPresencaSeguro(
clearTimeout(inactivityTimeout); status: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao'
} ) {
if (!usuarioAutenticado) return;
// Configurar novo timeout (5 minutos) const now = Date.now();
inactivityTimeout = setTimeout(() => { // Throttle: só atualizar se passou tempo suficiente desde a última atualização
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" }); if (now - lastStatusUpdate < STATUS_UPDATE_THROTTLE) {
}, 5 * 60 * 1000); // Cancelar atualização pendente se houver
} if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
}
// Agendar atualização para depois do throttle
pendingStatusUpdate = setTimeout(
() => {
atualizarStatusPresencaSeguro(status);
},
STATUS_UPDATE_THROTTLE - (now - lastStatusUpdate)
);
return;
}
onMount(() => { // Limpar atualização pendente se houver
// Configurar como online ao montar if (pendingStatusUpdate) {
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" }); clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
// Heartbeat a cada 30 segundos lastStatusUpdate = now;
heartbeatInterval = setInterval(() => {
const timeSinceLastActivity = Date.now() - lastActivity;
// Se houve atividade nos últimos 5 minutos, manter online try {
if (timeSinceLastActivity < 5 * 60 * 1000) { await client.mutation(api.chat.atualizarStatusPresenca, { status });
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" }); } catch (error) {
} // Silenciar erros de timeout - não são críticos para a funcionalidade
}, 30 * 1000); const errorMessage = error instanceof Error ? error.message : String(error);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
if (!isTimeout) {
console.error('Erro ao atualizar status de presença:', error);
}
}
}
// Listeners para detectar atividade // Detectar atividade do usuário
const events = ["mousedown", "keydown", "scroll", "touchstart"]; function handleActivity() {
events.forEach((event) => { if (!usuarioAutenticado) return;
window.addEventListener(event, handleActivity);
});
// Configurar timeout inicial de inatividade lastActivity = Date.now();
handleActivity();
// Detectar quando a aba fica inativa/ativa // Limpar timeout de inatividade anterior
function handleVisibilityChange() { if (inactivityTimeout) {
if (document.hidden) { clearTimeout(inactivityTimeout);
// Aba ficou inativa }
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
} else {
// Aba ficou ativa
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
handleActivity();
}
}
document.addEventListener("visibilitychange", handleVisibilityChange); // Configurar novo timeout (5 minutos)
inactivityTimeout = setTimeout(
() => {
if (usuarioAutenticado) {
atualizarStatusPresencaSeguro('ausente');
}
},
5 * 60 * 1000
);
}
// Cleanup onMount(() => {
return () => { // Só configurar presença se usuário estiver autenticado
// Marcar como offline ao desmontar if (!usuarioAutenticado) return;
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
if (heartbeatInterval) { // Configurar como online ao montar (apenas se autenticado)
clearInterval(heartbeatInterval); atualizarStatusPresencaSeguro('online');
}
if (inactivityTimeout) { // Heartbeat a cada 30 segundos (apenas se autenticado)
clearTimeout(inactivityTimeout); heartbeatInterval = setInterval(() => {
} if (!usuarioAutenticado) {
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = null;
}
return;
}
events.forEach((event) => { const timeSinceLastActivity = Date.now() - lastActivity;
window.removeEventListener(event, handleActivity);
});
document.removeEventListener("visibilitychange", handleVisibilityChange); // Se houve atividade nos últimos 5 minutos, manter online
}; if (timeSinceLastActivity < 5 * 60 * 1000) {
}); atualizarStatusPresencaSeguro('online');
}
}, 30 * 1000);
// Listeners para detectar atividade
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
events.forEach((event) => {
window.addEventListener(event, handleActivity);
});
// Configurar timeout inicial de inatividade
if (usuarioAutenticado) {
handleActivity();
}
// Detectar quando a aba fica inativa/ativa
function handleVisibilityChange() {
if (!usuarioAutenticado) return;
if (document.hidden) {
// Aba ficou inativa
atualizarStatusPresencaSeguro('ausente');
} else {
// Aba ficou ativa
atualizarStatusPresencaSeguro('online');
handleActivity();
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
// Cleanup
return () => {
// Limpar atualização pendente
if (pendingStatusUpdate) {
clearTimeout(pendingStatusUpdate);
pendingStatusUpdate = null;
}
// Marcar como offline ao desmontar (apenas se autenticado)
if (usuarioAutenticado) {
atualizarStatusPresencaSeguro('offline');
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
}
if (inactivityTimeout) {
clearTimeout(inactivityTimeout);
}
events.forEach((event) => {
window.removeEventListener(event, handleActivity);
});
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
});
</script> </script>
<!-- Componente invisível - apenas lógica --> <!-- Componente invisível - apenas lógica -->

View File

@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { ArrowDown, ArrowUp, Search, Trash2, UserPlus, Users, X } from 'lucide-svelte';
import UserAvatar from './UserAvatar.svelte'; import UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte'; import UserStatusBadge from './UserStatusBadge.svelte';
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
interface Props { interface Props {
conversaId: Id<'conversas'>; conversaId: Id<'conversas'>;
@@ -12,7 +12,7 @@
onClose: () => void; onClose: () => void;
} }
let { conversaId, isAdmin, onClose }: Props = $props(); const { conversaId, isAdmin, onClose }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {}); const conversas = useQuery(api.chat.listarConversas, {});
@@ -23,16 +23,16 @@
let loading = $state<string | null>(null); let loading = $state<string | null>(null);
let error = $state<string | null>(null); let error = $state<string | null>(null);
const conversa = $derived(() => { let conversa = $derived(() => {
if (!conversas?.data) return null; if (!conversas?.data) return null;
return conversas.data.find((c: any) => c._id === conversaId); return conversas.data.find((c: any) => c._id === conversaId);
}); });
const todosUsuarios = $derived(() => { let todosUsuarios = $derived(() => {
return todosUsuariosQuery?.data || []; return todosUsuariosQuery?.data || [];
}); });
const participantes = $derived(() => { let participantes = $derived(() => {
try { try {
const conv = conversa(); const conv = conversa();
const usuarios = todosUsuarios(); const usuarios = todosUsuarios();
@@ -76,11 +76,11 @@
} }
}); });
const administradoresIds = $derived(() => { let administradoresIds = $derived(() => {
return conversa()?.administradores || []; return conversa()?.administradores || [];
}); });
const usuariosDisponiveis = $derived(() => { let usuariosDisponiveis = $derived(() => {
const usuarios = todosUsuarios(); const usuarios = todosUsuarios();
if (!usuarios || usuarios.length === 0) return []; if (!usuarios || usuarios.length === 0) return [];
const participantesIds = conversa()?.participantes || []; const participantesIds = conversa()?.participantes || [];
@@ -89,7 +89,7 @@
); );
}); });
const usuariosFiltrados = $derived(() => { let usuariosFiltrados = $derived(() => {
const disponiveis = usuariosDisponiveis(); const disponiveis = usuariosDisponiveis();
if (!searchQuery.trim()) return disponiveis; if (!searchQuery.trim()) return disponiveis;
const query = searchQuery.toLowerCase(); const query = searchQuery.toLowerCase();

View File

@@ -1,17 +1,18 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale'; import { ptBR } from 'date-fns/locale';
import { Clock, X, Trash2 } from 'lucide-svelte'; import { Clock, Trash2, X } from 'lucide-svelte';
import { obterCoresDoTema } from '$lib/utils/temas';
interface Props { interface Props {
conversaId: Id<'conversas'>; conversaId: Id<'conversas'>;
onClose: () => void; onClose: () => void;
} }
let { conversaId, onClose }: Props = $props(); const { conversaId, onClose }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
@@ -23,6 +24,63 @@
let hora = $state(''); let hora = $state('');
let loading = $state(false); let loading = $state(false);
// Obter cores do tema atual (reativo)
let coresTema = $state(obterCoresDoTema());
// Atualizar cores quando o tema mudar
$effect(() => {
if (typeof window === 'undefined') return;
const atualizarCores = () => {
coresTema = obterCoresDoTema();
};
atualizarCores();
window.addEventListener('themechange', atualizarCores);
const observer = new MutationObserver(atualizarCores);
const htmlElement = document.documentElement;
observer.observe(htmlElement, {
attributes: true,
attributeFilter: ['data-theme']
});
return () => {
window.removeEventListener('themechange', atualizarCores);
observer.disconnect();
};
});
// Função para obter rgba da cor primária
function obterPrimariaRgba(alpha: number = 1) {
const primary = coresTema.primary;
if (primary.startsWith('rgba')) {
const match = primary.match(/rgba?\(([^)]+)\)/);
if (match) {
const values = match[1].split(',');
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
}
}
if (primary.startsWith('#')) {
const hex = primary.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
if (primary.startsWith('hsl')) {
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
}
return `rgba(102, 126, 234, ${alpha})`;
}
// Função para obter gradiente do tema
function obterGradienteTema() {
const primary = coresTema.primary;
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 100%)`;
}
// Rastrear mudanças nas mensagens agendadas // Rastrear mudanças nas mensagens agendadas
$effect(() => { $effect(() => {
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data); console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
@@ -186,7 +244,7 @@
<button <button
type="button" type="button"
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50" class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);" style="background: {obterGradienteTema()}; box-shadow: 0 8px 24px -4px {obterPrimariaRgba(0.4)};"
onclick={handleAgendar} onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora} disabled={loading || !mensagem.trim() || !data || !hora}
> >

View File

@@ -1,41 +1,99 @@
<script lang="ts"> <script lang="ts">
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator"; import { User } from 'lucide-svelte';
import { getCachedAvatar } from '$lib/utils/avatarCache';
import { onMount } from 'svelte';
interface Props { interface Props {
avatar?: string; fotoPerfilUrl?: string | null;
fotoPerfilUrl?: string | null; nome: string;
nome: string; size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
size?: "xs" | "sm" | "md" | "lg"; userId?: string; // ID do usuário para cache
} }
let { avatar, fotoPerfilUrl, nome, size = "md" }: Props = $props(); let { fotoPerfilUrl, nome, size = 'md', userId }: Props = $props();
const sizeClasses = { let cachedAvatarUrl = $state<string | null>(null);
xs: "w-8 h-8", let loading = $state(true);
sm: "w-10 h-10",
md: "w-12 h-12",
lg: "w-16 h-16",
};
function getAvatarUrl(avatarId: string): string { onMount(async () => {
// Usar gerador local ao invés da API externa if (fotoPerfilUrl) {
return generateAvatarUrl(avatarId); loading = true;
} try {
cachedAvatarUrl = await getCachedAvatar(fotoPerfilUrl, userId);
} catch (error) {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
} finally {
loading = false;
}
} else {
loading = false;
}
});
const avatarUrlToShow = $derived(() => { // Atualizar quando fotoPerfilUrl mudar
if (fotoPerfilUrl) return fotoPerfilUrl; $effect(() => {
if (avatar) return getAvatarUrl(avatar); if (fotoPerfilUrl) {
return getAvatarUrl(nome); // Fallback usando o nome loading = true;
}); getCachedAvatar(fotoPerfilUrl, userId)
.then((url) => {
cachedAvatarUrl = url;
loading = false;
})
.catch((error) => {
console.warn('Erro ao carregar avatar:', error);
cachedAvatarUrl = null;
loading = false;
});
} else {
cachedAvatarUrl = null;
loading = false;
}
});
const sizeClasses = {
xs: 'w-8 h-8',
sm: 'w-10 h-10',
md: 'w-12 h-12',
lg: 'w-16 h-16',
xl: 'w-32 h-32'
};
const iconSizes = {
xs: 16,
sm: 20,
md: 24,
lg: 32,
xl: 64
};
</script> </script>
<div class="avatar"> <div class="avatar placeholder">
<div class={`${sizeClasses[size]} rounded-full bg-base-200 overflow-hidden`}> <div
<img class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
src={avatarUrlToShow()} >
alt={`Avatar de ${nome}`} {#if loading}
class="w-full h-full object-cover" <span class="loading loading-spinner loading-xs"></span>
/> {:else if cachedAvatarUrl}
</div> <img
src={cachedAvatarUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
onerror={() => {
cachedAvatarUrl = null;
}}
/>
{:else if fotoPerfilUrl}
<!-- Fallback: usar URL original se cache falhar -->
<img
src={fotoPerfilUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
loading="lazy"
/>
{:else}
<User size={iconSizes[size]} />
{/if}
</div>
</div> </div>

View File

@@ -1,75 +1,68 @@
<script lang="ts"> <script lang="ts">
interface Props { import { CheckCircle2, XCircle, AlertCircle, Plus, Video } from 'lucide-svelte';
status?: "online" | "offline" | "ausente" | "externo" | "em_reuniao";
size?: "sm" | "md" | "lg";
}
let { status = "offline", size = "md" }: Props = $props(); interface Props {
status?: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
size?: 'sm' | 'md' | 'lg';
}
const sizeClasses = { let { status = 'offline', size = 'md' }: Props = $props();
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
const statusConfig = { const sizeClasses = {
online: { sm: 'w-3 h-3',
color: "bg-success", md: 'w-4 h-4',
borderColor: "border-success", lg: 'w-5 h-5'
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full"> };
<circle cx="12" cy="12" r="10" fill="#10b981"/>
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>`,
label: "🟢 Online",
},
offline: {
color: "bg-base-300",
borderColor: "border-base-300",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#9ca3af"/>
<path d="M8 8l8 8M16 8l-8 8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "⚫ Offline",
},
ausente: {
color: "bg-warning",
borderColor: "border-warning",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#f59e0b"/>
<circle cx="12" cy="6" r="1.5" fill="white"/>
<path d="M12 10v4" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "🟡 Ausente",
},
externo: {
color: "bg-info",
borderColor: "border-info",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#3b82f6"/>
<path d="M8 12h8M12 8v8" stroke="white" stroke-width="2" stroke-linecap="round"/>
</svg>`,
label: "🔵 Externo",
},
em_reuniao: {
color: "bg-error",
borderColor: "border-error",
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
<rect x="8" y="8" width="8" height="8" fill="white" rx="1"/>
</svg>`,
label: "🔴 Em Reunião",
},
};
const config = $derived(statusConfig[status]); const iconSizes = {
sm: 8,
md: 12,
lg: 16
};
const statusConfig = {
online: {
color: 'bg-success',
borderColor: 'border-success',
icon: CheckCircle2,
label: '🟢 Online'
},
offline: {
color: 'bg-base-300',
borderColor: 'border-base-300',
icon: XCircle,
label: '⚫ Offline'
},
ausente: {
color: 'bg-warning',
borderColor: 'border-warning',
icon: AlertCircle,
label: '🟡 Ausente'
},
externo: {
color: 'bg-info',
borderColor: 'border-info',
icon: Plus,
label: '🔵 Externo'
},
em_reuniao: {
color: 'bg-error',
borderColor: 'border-error',
icon: Video,
label: '🔴 Em Reunião'
}
};
const config = $derived(statusConfig[status]);
const IconComponent = $derived(config.icon);
const iconSize = $derived(iconSizes[size]);
</script> </script>
<div <div
class={`${sizeClasses[size]} rounded-full relative flex items-center justify-center`} class={`${sizeClasses[size]} ${config.color} ${config.borderColor} relative flex items-center justify-center rounded-full border-2`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;" style="box-shadow: 0 2px 8px rgba(0,0,0,0.15);"
title={config.label} title={config.label}
aria-label={config.label} aria-label={config.label}
> >
{@html config.icon} <IconComponent class="text-white" size={iconSize} strokeWidth={2.5} fill="currentColor" />
</div> </div>

View File

@@ -0,0 +1,121 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte';
import type { FunctionReference } from 'convex/server';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { LogIn, Settings, User, UserCog } from 'lucide-svelte';
import { authClient } from '$lib/auth';
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
let currentPath = $derived(page.url.pathname);
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
// Função para obter a URL do avatar/foto do usuário
let avatarUrlDoUsuario = $derived.by(() => {
if (!currentUser.data) return null;
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
if (currentUser.data.fotoPerfilUrl) {
return currentUser.data.fotoPerfilUrl;
}
if (currentUser.data.avatar) {
return currentUser.data.avatar;
}
// Fallback: retornar null para usar o ícone User do Lucide
return null;
});
function goToLogin(redirectTo?: string) {
const target = redirectTo || currentPath || '/';
goto(`${resolve('/login')}?redirect=${encodeURIComponent(target)}`);
}
async function handleLogout() {
const result = await authClient.signOut();
if (result.error) {
console.error('Sign out error:', result.error);
}
goto(resolve('/home'));
}
</script>
<div class="flex items-center gap-3">
{#if currentUser.data}
<!-- Nome e Perfil -->
<div class="hidden flex-col items-end lg:flex">
<span class="text-base-content text-sm leading-tight font-semibold"
>{currentUser.data.nome}</span
>
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span>
</div>
<div class="dropdown dropdown-end">
<!-- Botão de Perfil com Avatar -->
<button
type="button"
tabindex="0"
class="btn avatar ring-base-200 hover:ring-primary/50 h-10 w-10 p-0 ring-2 ring-offset-2 transition-all"
aria-label="Menu do usuário"
>
<div class="h-full w-full overflow-hidden rounded-full">
{#if avatarUrlDoUsuario}
<img
src={avatarUrlDoUsuario}
alt={currentUser.data?.nome || 'Usuário'}
class="h-full w-full object-cover"
/>
{:else}
<div class="bg-primary/10 text-primary flex h-full w-full items-center justify-center">
<User class="h-5 w-5" />
</div>
{/if}
</div>
</button>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul
tabindex="0"
class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
>
<li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
<span class="text-base-content font-bold">{currentUser.data?.nome}</span>
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
</li>
<li>
<a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
>
</li>
<li>
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
>
</li>
<div class="divider my-1"></div>
<li>
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
>
</li>
</ul>
</div>
<!-- Sino de notificações -->
<div class="relative">
<NotificationBell />
</div>
{:else}
<button
type="button"
class="btn btn-primary btn-sm rounded-full px-6"
onclick={() => goToLogin()}
>
Entrar
</button>
{/if}
</div>

View File

@@ -1,14 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { Calendar } from '@fullcalendar/core'; import { Calendar } from '@fullcalendar/core';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import multiMonthPlugin from '@fullcalendar/multimonth'; import multiMonthPlugin from '@fullcalendar/multimonth';
import ptBrLocale from '@fullcalendar/core/locales/pt-br'; import { onMount } from 'svelte';
import { SvelteDate } from 'svelte/reactivity'; import { SvelteDate } from 'svelte/reactivity';
interface Props { interface Props {
periodosExistentes?: Array<{ dataInicio: string; dataFim: string; dias: number }>; periodosExistentes?: Array<{
dataInicio: string;
dataFim: string;
dias: number;
}>;
onPeriodoAdicionado?: (periodo: { dataInicio: string; dataFim: string; dias: number }) => void; onPeriodoAdicionado?: (periodo: { dataInicio: string; dataFim: string; dias: number }) => void;
onPeriodoRemovido?: (index: number) => void; onPeriodoRemovido?: (index: number) => void;
maxPeriodos?: number; maxPeriodos?: number;
@@ -17,7 +21,7 @@
readonly?: boolean; readonly?: boolean;
} }
let { const {
periodosExistentes = [], periodosExistentes = [],
onPeriodoAdicionado, onPeriodoAdicionado,
onPeriodoRemovido, onPeriodoRemovido,
@@ -37,7 +41,7 @@
{ bg: '#4facfe', border: '#00c6ff', text: '#ffffff' } // Azul { bg: '#4facfe', border: '#00c6ff', text: '#ffffff' } // Azul
]; ];
const eventos = $derived.by(() => let eventos = $derived.by(() =>
periodosExistentes.map((periodo, index) => ({ periodosExistentes.map((periodo, index) => ({
id: `periodo-${index}`, id: `periodo-${index}`,
title: `Período ${index + 1} (${periodo.dias} dias)`, title: `Período ${index + 1} (${periodo.dias} dias)`,
@@ -99,7 +103,10 @@
selectable: !readonly, selectable: !readonly,
selectMirror: true, selectMirror: true,
unselectAuto: false, unselectAuto: false,
events: eventos.map((evento) => ({ ...evento, extendedProps: { ...evento.extendedProps } })), events: eventos.map((evento) => ({
...evento,
extendedProps: { ...evento.extendedProps }
})),
// Estilo customizado // Estilo customizado
buttonText: { buttonText: {

View File

@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
interface Props { interface Props {
funcionarioId: Id<'funcionarios'>; funcionarioId: Id<'funcionarios'>;
} }
let { funcionarioId }: Props = $props(); const { funcionarioId }: Props = $props();
// Queries // Queries
const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId }); const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId });
@@ -15,20 +15,18 @@
funcionarioId funcionarioId
}); });
const saldos = $derived(saldosQuery.data || []); let saldos = $derived(saldosQuery.data || []);
const solicitacoes = $derived(solicitacoesQuery.data || []); let solicitacoes = $derived(solicitacoesQuery.data || []);
// Estatísticas derivadas // Estatísticas derivadas
const saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear())); let saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
const totalSolicitacoes = $derived(solicitacoes.length); let totalSolicitacoes = $derived(solicitacoes.length);
const aprovadas = $derived( let aprovadas = $derived(
solicitacoes.filter((s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada') solicitacoes.filter((s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada')
.length .length
); );
const pendentes = $derived( let pendentes = $derived(solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length);
solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length let reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
);
const reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
// Canvas para gráfico de pizza // Canvas para gráfico de pizza
let canvasSaldo = $state<HTMLCanvasElement>(); let canvasSaldo = $state<HTMLCanvasElement>();

View File

@@ -1,8 +1,9 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { toast } from 'svelte-sonner';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { toast } from 'svelte-sonner';
import { Check, Zap, Clock, Info, AlertTriangle, Calendar, X, Plus, ChevronLeft, ChevronRight, Trash2, CheckCircle } from 'lucide-svelte';
interface Props { interface Props {
funcionarioId: Id<'funcionarios'>; funcionarioId: Id<'funcionarios'>;
@@ -10,7 +11,7 @@
onCancelar?: () => void; onCancelar?: () => void;
} }
let { funcionarioId, onSucesso, onCancelar }: Props = $props(); const { funcionarioId, onSucesso, onCancelar }: Props = $props();
// Cliente Convex // Cliente Convex
const client = useConvexClient(); const client = useConvexClient();
@@ -34,18 +35,20 @@
let dataFimPeriodo = $state(''); let dataFimPeriodo = $state('');
// Queries // Queries
const funcionarioQuery = useQuery(api.funcionarios.getById, { id: funcionarioId }); const funcionarioQuery = useQuery(api.funcionarios.getById, {
const funcionario = $derived(funcionarioQuery?.data); id: funcionarioId
const regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt'); });
let funcionario = $derived(funcionarioQuery?.data);
let regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
const saldoQuery = $derived( let saldoQuery = $derived(
useQuery(api.saldoFerias.obterSaldo, { useQuery(api.saldoFerias.obterSaldo, {
funcionarioId, funcionarioId,
anoReferencia: anoSelecionado anoReferencia: anoSelecionado
}) })
); );
const validacaoQuery = $derived( let validacaoQuery = $derived(
periodosFerias.length > 0 periodosFerias.length > 0
? useQuery(api.saldoFerias.validarSolicitacao, { ? useQuery(api.saldoFerias.validarSolicitacao, {
funcionarioId, funcionarioId,
@@ -59,18 +62,18 @@
); );
// Derivados // Derivados
const saldo = $derived(saldoQuery.data); let saldo = $derived(saldoQuery.data);
const validacao = $derived(validacaoQuery.data); let validacao = $derived(validacaoQuery.data);
const totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0)); let totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0));
// Anos disponíveis (últimos 3 anos + próximo ano) // Anos disponíveis (últimos 3 anos + próximo ano)
const anosDisponiveis = $derived.by(() => { let anosDisponiveis = $derived.by(() => {
const anoAtual = new Date().getFullYear(); const anoAtual = new Date().getFullYear();
return [anoAtual - 1, anoAtual, anoAtual + 1]; return [anoAtual - 1, anoAtual, anoAtual + 1];
}); });
// Verificar se é regime estatutário PE ou Municipal // Verificar se é regime estatutário PE ou Municipal
const ehEstatutarioPEOuMunicipal = $derived( let ehEstatutarioPEOuMunicipal = $derived(
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal' regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
); );
@@ -127,7 +130,9 @@
// Verificar se o total não excede 30 dias // Verificar se o total não excede 30 dias
const novoTotal = totalDiasSelecionados + dias; const novoTotal = totalDiasSelecionados + dias;
if (novoTotal > 30) { if (novoTotal > 30) {
toast.error(`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`); toast.error(
`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`
);
return; return;
} }
} }
@@ -135,7 +140,9 @@
// Verificar se o total não excede o saldo disponível // Verificar se o total não excede o saldo disponível
const novoTotal = totalDiasSelecionados + dias; const novoTotal = totalDiasSelecionados + dias;
if (saldo && novoTotal > saldo.diasDisponiveis) { if (saldo && novoTotal > saldo.diasDisponiveis) {
toast.error(`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`); toast.error(
`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`
);
return; return;
} }
@@ -221,18 +228,19 @@
} }
// Calcular dias do período atual // Calcular dias do período atual
const diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo)); let diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
</script> </script>
<div class="wizard-ferias-container"> <div class="wizard-ferias-container">
<!-- Progress Bar --> <!-- Progress Bar -->
<div class="mb-8"> <div class="mb-8">
<div class="flex items-center justify-between"> <div class="relative flex items-start">
{#each Array(totalPassos) as _, i (i)} {#each Array(totalPassos) as _, i (i)}
<div class="flex flex-1 items-center"> {@const labels = ['Ano & Saldo', 'Períodos', 'Confirmação']}
<div class="relative z-10 flex flex-1 flex-col items-center">
<!-- Círculo do passo --> <!-- Círculo do passo -->
<div <div
class="relative flex h-12 w-12 items-center justify-center rounded-full font-bold transition-all duration-300" class="relative z-20 flex h-12 w-12 items-center justify-center rounded-full font-bold transition-all duration-300"
class:bg-primary={passoAtual > i + 1} class:bg-primary={passoAtual > i + 1}
class:text-white={passoAtual > i + 1} class:text-white={passoAtual > i + 1}
class:border-4={passoAtual === i + 1} class:border-4={passoAtual === i + 1}
@@ -242,29 +250,25 @@
style:box-shadow={passoAtual === i + 1 ? '0 0 20px rgba(102, 126, 234, 0.5)' : 'none'} style:box-shadow={passoAtual === i + 1 ? '0 0 20px rgba(102, 126, 234, 0.5)' : 'none'}
> >
{#if passoAtual > i + 1} {#if passoAtual > i + 1}
<svg <Check class="h-6 w-6" strokeWidth={3} />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="3"
d="M5 13l4 4L19 7"
/>
</svg>
{:else} {:else}
{i + 1} {i + 1}
{/if} {/if}
</div> </div>
<!-- Label do passo -->
<p
class="mt-3 text-center text-sm font-semibold"
class:text-primary={passoAtual === i + 1}
>
{labels[i]}
</p>
<!-- Linha conectora --> <!-- Linha conectora -->
{#if i < totalPassos - 1} {#if i < totalPassos - 1}
<div <div
class="mx-2 h-1 flex-1 transition-all duration-300" class="absolute top-6 left-1/2 z-10 h-1 transition-all duration-300"
style="width: calc(100% - 1.5rem); margin-left: calc(50% + 0.75rem);"
class:bg-primary={passoAtual > i + 1} class:bg-primary={passoAtual > i + 1}
class:bg-base-300={passoAtual <= i + 1} class:bg-base-300={passoAtual <= i + 1}
></div> ></div>
@@ -272,19 +276,6 @@
</div> </div>
{/each} {/each}
</div> </div>
<!-- Labels dos passos -->
<div class="mt-4 flex justify-between px-1">
<div class="flex-1 text-center">
<p class="text-sm font-semibold" class:text-primary={passoAtual === 1}>Ano & Saldo</p>
</div>
<div class="flex-1 text-center">
<p class="text-sm font-semibold" class:text-primary={passoAtual === 2}>Períodos</p>
</div>
<div class="flex-1 text-center">
<p class="text-sm font-semibold" class:text-primary={passoAtual === 3}>Confirmação</p>
</div>
</div>
</div> </div>
<!-- Conteúdo dos Passos --> <!-- Conteúdo dos Passos -->
@@ -309,7 +300,9 @@
style:border-width={anoSelecionado === ano ? '2px' : undefined} style:border-width={anoSelecionado === ano ? '2px' : undefined}
style:color={anoSelecionado === ano ? '#000000' : undefined} style:color={anoSelecionado === ano ? '#000000' : undefined}
style:background-color={anoSelecionado === ano ? 'transparent' : undefined} style:background-color={anoSelecionado === ano ? 'transparent' : undefined}
style:box-shadow={anoSelecionado === ano ? '0 0 10px rgba(249, 115, 22, 0.3)' : undefined} style:box-shadow={anoSelecionado === ano
? '0 0 10px rgba(249, 115, 22, 0.3)'
: undefined}
onclick={() => (anoSelecionado = ano)} onclick={() => (anoSelecionado = ano)}
> >
{ano} {ano}
@@ -332,19 +325,7 @@
<div class="stats stats-vertical lg:stats-horizontal w-full shadow-lg"> <div class="stats stats-vertical lg:stats-horizontal w-full shadow-lg">
<div class="stat"> <div class="stat">
<div class="stat-figure text-primary"> <div class="stat-figure text-primary">
<svg <Zap class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-8 w-8 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
</div> </div>
<div class="stat-title">Total Direito</div> <div class="stat-title">Total Direito</div>
<div class="stat-value text-primary">{saldo.diasDireito}</div> <div class="stat-value text-primary">{saldo.diasDireito}</div>
@@ -353,19 +334,7 @@
<div class="stat"> <div class="stat">
<div class="stat-figure text-success"> <div class="stat-figure text-success">
<svg <Check class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-8 w-8 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
></path>
</svg>
</div> </div>
<div class="stat-title">Disponível</div> <div class="stat-title">Disponível</div>
<div class="stat-value text-success"> <div class="stat-value text-success">
@@ -376,19 +345,7 @@
<div class="stat"> <div class="stat">
<div class="stat-figure text-warning"> <div class="stat-figure text-warning">
<svg <Clock class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-8 w-8 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
</div> </div>
<div class="stat-title">Usado</div> <div class="stat-title">Usado</div>
<div class="stat-value text-warning">{saldo.diasUsados}</div> <div class="stat-value text-warning">{saldo.diasUsados}</div>
@@ -398,19 +355,7 @@
<!-- Informações do Regime --> <!-- Informações do Regime -->
<div class="alert alert-info mt-4"> <div class="alert alert-info mt-4">
<svg <Info class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div> <div>
<h4 class="font-bold">{saldo.regimeTrabalho}</h4> <h4 class="font-bold">{saldo.regimeTrabalho}</h4>
<p class="text-sm"> <p class="text-sm">
@@ -419,7 +364,8 @@
</p> </p>
{#if ehEstatutarioPEOuMunicipal} {#if ehEstatutarioPEOuMunicipal}
<p class="mt-2 text-sm font-semibold"> <p class="mt-2 text-sm font-semibold">
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias. ⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode
exceder 30 dias.
</p> </p>
{/if} {/if}
</div> </div>
@@ -427,19 +373,7 @@
{#if saldo.diasDisponiveis === 0} {#if saldo.diasDisponiveis === 0}
<div class="alert alert-warning mt-4"> <div class="alert alert-warning mt-4">
<svg <AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Você não tem saldo disponível para este ano.</span> <span>Você não tem saldo disponível para este ano.</span>
</div> </div>
{/if} {/if}
@@ -447,19 +381,7 @@
</div> </div>
{:else} {:else}
<div class="alert alert-warning"> <div class="alert alert-warning">
<svg <AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<span>Nenhum saldo encontrado para este ano.</span> <span>Nenhum saldo encontrado para este ano.</span>
</div> </div>
{/if} {/if}
@@ -477,19 +399,7 @@
<!-- Resumo rápido --> <!-- Resumo rápido -->
<div class="alert bg-base-200 mb-6"> <div class="alert bg-base-200 mb-6">
<svg <Info class="stroke-info h-6 w-6 shrink-0" />
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-info h-6 w-6 shrink-0"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
<div> <div>
<p> <p>
<strong>Saldo disponível:</strong> <strong>Saldo disponível:</strong>
@@ -500,14 +410,15 @@
</p> </p>
{#if ehEstatutarioPEOuMunicipal} {#if ehEstatutarioPEOuMunicipal}
<p class="mt-2 text-sm font-semibold"> <p class="mt-2 text-sm font-semibold">
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias. ⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30
dias.
</p> </p>
{/if} {/if}
</div> </div>
</div> </div>
<!-- Formulário para adicionar período --> <!-- Formulário para adicionar período -->
<div class="card bg-base-100 shadow-lg mb-6"> <div class="card bg-base-100 mb-6 shadow-lg">
<div class="card-body"> <div class="card-body">
<h3 class="card-title mb-4">Adicionar Período</h3> <h3 class="card-title mb-4">Adicionar Período</h3>
@@ -516,11 +427,7 @@
<label class="label"> <label class="label">
<span class="label-text font-semibold">Data Início</span> <span class="label-text font-semibold">Data Início</span>
</label> </label>
<input <input type="date" class="input input-bordered" bind:value={dataInicioPeriodo} />
type="date"
class="input input-bordered"
bind:value={dataInicioPeriodo}
/>
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -540,7 +447,7 @@
<span class="label-text font-semibold">Dias</span> <span class="label-text font-semibold">Dias</span>
</label> </label>
<div class="input input-bordered flex items-center"> <div class="input input-bordered flex items-center">
<span class="font-bold text-primary">{diasPeriodoAtual}</span> <span class="text-primary font-bold">{diasPeriodoAtual}</span>
<span class="ml-2 text-sm opacity-70">dias</span> <span class="ml-2 text-sm opacity-70">dias</span>
</div> </div>
</div> </div>
@@ -553,20 +460,7 @@
onclick={adicionarPeriodo} onclick={adicionarPeriodo}
disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0} disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0}
> >
<svg <Plus class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Adicionar Período Adicionar Período
</button> </button>
</div> </div>
@@ -575,7 +469,7 @@
<!-- Lista de períodos adicionados --> <!-- Lista de períodos adicionados -->
{#if periodosFerias.length > 0} {#if periodosFerias.length > 0}
<div class="card bg-base-100 shadow-lg mb-6"> <div class="card bg-base-100 mb-6 shadow-lg">
<div class="card-body"> <div class="card-body">
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3> <h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
<div class="space-y-3"> <div class="space-y-3">
@@ -601,20 +495,7 @@
class="btn btn-error btn-sm gap-2" class="btn btn-error btn-sm gap-2"
onclick={() => removerPeriodo(index)} onclick={() => removerPeriodo(index)}
> >
<svg <Trash2 class="h-4 w-4" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
Remover Remover
</button> </button>
</div> </div>
@@ -629,36 +510,12 @@
<div class="mt-6"> <div class="mt-6">
{#if validacao.valido} {#if validacao.valido}
<div class="alert alert-success"> <div class="alert alert-success">
<svg <CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>✅ Períodos válidos! Total: {validacao.totalDias} dias</span> <span>✅ Períodos válidos! Total: {validacao.totalDias} dias</span>
</div> </div>
{:else} {:else}
<div class="alert alert-error"> <div class="alert alert-error">
<svg <X class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div> <div>
<p class="font-bold">Erros encontrados:</p> <p class="font-bold">Erros encontrados:</p>
<ul class="list-inside list-disc"> <ul class="list-inside list-disc">
@@ -672,19 +529,7 @@
{#if validacao.avisos.length > 0} {#if validacao.avisos.length > 0}
<div class="alert alert-warning mt-4"> <div class="alert alert-warning mt-4">
<svg <AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<div> <div>
<p class="font-bold">Avisos:</p> <p class="font-bold">Avisos:</p>
<ul class="list-inside list-disc"> <ul class="list-inside list-disc">
@@ -774,20 +619,7 @@
<div> <div>
{#if passoAtual > 1} {#if passoAtual > 1}
<button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}> <button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}>
<svg <ChevronLeft class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 19l-7-7 7-7"
/>
</svg>
Voltar Voltar
</button> </button>
{:else if onCancelar} {:else if onCancelar}
@@ -804,20 +636,7 @@
disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)} disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)}
> >
Próximo Próximo
<svg <ChevronRight class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</button> </button>
{:else} {:else}
<button <button
@@ -830,20 +649,7 @@
<span class="loading loading-spinner"></span> <span class="loading loading-spinner"></span>
Enviando... Enviando...
{:else} {:else}
<svg <Check class="h-5 w-5" strokeWidth={2} />
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 13l4 4L19 7"
/>
</svg>
Enviar Solicitação Enviar Solicitação
{/if} {/if}
</button> </button>

View File

@@ -0,0 +1,29 @@
<script lang="ts">
export type BreadcrumbItem = {
label: string;
href?: string;
};
interface Props {
items: BreadcrumbItem[];
class?: string;
}
let { items, class: className = '' }: Props = $props();
</script>
<div class={['breadcrumbs mb-4 text-sm', className].filter(Boolean)}>
<ul>
{#each items as item (item.label)}
<li>
{#if item.href}
<a href={item.href} class="text-primary hover:underline">{item.label}</a>
{:else}
{item.label}
{/if}
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
icon?: Snippet;
actions?: Snippet;
class?: string;
}
let {
title,
subtitle,
icon,
actions,
class: className = ''
}: Props = $props();
</script>
<div class={['mb-6', className].filter(Boolean)}>
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-4">
{#if icon}
<div class="bg-primary/10 rounded-xl p-3">
<div class="text-primary [&_svg]:h-8 [&_svg]:w-8">
{@render icon()}
</div>
</div>
{/if}
<div>
<h1 class="text-primary text-3xl font-bold">{title}</h1>
{#if subtitle}
<p class="text-base-content/70">{subtitle}</p>
{/if}
</div>
</div>
{#if actions}
<div class="flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<main class={['container mx-auto flex max-w-7xl flex-col px-4 py-4', className].filter(Boolean)}>
{@render children?.()}
</main>

View File

@@ -0,0 +1,62 @@
<script lang="ts">
import { Field } from '@ark-ui/svelte/field';
import type { Snippet } from 'svelte';
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props {
id: string;
label: string;
type?: string;
placeholder?: string;
autocomplete?: HTMLInputAttributes['autocomplete'];
disabled?: boolean;
required?: boolean;
error?: string | null;
right?: Snippet;
value?: string;
}
let {
id,
label,
type = 'text',
placeholder = '',
autocomplete,
disabled = false,
required = false,
error = null,
right,
value = $bindable('')
}: Props = $props();
const invalid = $derived(!!error);
</script>
<Field.Root {invalid} {required} class="space-y-2">
<div class="flex items-center justify-between gap-3">
<Field.Label
for={id}
class="text-base-content/60 text-xs font-semibold tracking-wider uppercase"
>
{label}
</Field.Label>
{@render right?.()}
</div>
<div class="group relative">
<Field.Input
{id}
{type}
{placeholder}
{disabled}
{autocomplete}
{required}
bind:value
class="border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border px-4 py-3 transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
{#if error}
<Field.ErrorText class="text-error text-sm font-medium">{error}</Field.ErrorText>
{/if}
</Field.Root>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,684 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { Calendar, CheckCircle2, Clock, MapPin, Printer, User, X, XCircle } from 'lucide-svelte';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
interface Props {
registroId: Id<'registrosPonto'>;
onClose: () => void;
}
const { registroId, onClose }: Props = $props();
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
let gerando = $state(false);
let modalPosition = $state<{ top: number; left: number } | null>(null);
// Função para calcular a posição baseada no card de registro de ponto
function calcularPosicaoModal() {
// Procurar pelo elemento do card de registro de ponto
const cardRef = document.getElementById('card-registro-ponto-ref');
if (cardRef) {
const rect = cardRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
const top = rect.top;
// Garantir que o modal não saia da viewport
// Considerar uma altura mínima do modal (aproximadamente 300px)
const minTop = 20;
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
const finalTop = Math.max(minTop, Math.min(top, maxTop));
// Centralizar horizontalmente
return {
top: finalTop,
left: window.innerWidth / 2
};
}
// Se não encontrar, usar posição padrão (centro da tela)
return null;
}
// Atualizar posição quando o modal for aberto (quando registroQuery tiver dados)
$effect(() => {
if (registroQuery?.data) {
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
const updatePosition = () => {
requestAnimationFrame(() => {
const pos = calcularPosicaoModal();
if (pos) {
modalPosition = pos;
} else {
// Fallback para centralização
modalPosition = {
top: window.innerHeight / 2,
left: window.innerWidth / 2
};
}
});
};
// Aguardar um pouco para garantir que o DOM está atualizado
setTimeout(updatePosition, 50);
// Adicionar listener de scroll para atualizar posição
const handleScroll = () => {
updatePosition();
};
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleScroll);
};
} else {
// Limpar posição quando o modal for fechado
modalPosition = null;
}
});
// Função para obter estilo do modal baseado na posição calculada
function getModalStyle() {
if (modalPosition) {
// Posicionar na altura do card, centralizado horizontalmente
// position: fixed já é relativo à viewport, então podemos usar diretamente
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
}
// Se não houver posição calculada, centralizar na tela
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
}
async function gerarPDF() {
if (!registroQuery?.data) return;
gerando = true;
try {
const registro = registroQuery.data;
const doc = new jsPDF();
// Adicionar logo no canto superior esquerdo
let yPosition = 20;
try {
const logoImg = await new Promise<HTMLImageElement>((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = (err) => reject(err);
setTimeout(() => reject(new Error('Timeout loading logo')), 3000);
img.src = logoGovPE;
});
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = 10 + logoHeight + 10;
} catch (err) {
console.warn('Erro ao carregar logo:', err);
yPosition = 20;
}
// Cabeçalho padrão do sistema (centralizado)
doc.setFontSize(14);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), {
align: 'center'
});
doc.setFontSize(12);
doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), {
align: 'center'
});
yPosition = Math.max(yPosition, 40);
yPosition += 10;
// Título do comprovante
doc.setFontSize(16);
doc.setTextColor(102, 126, 234); // Cor primária padrão do sistema
doc.setFont('helvetica', 'bold');
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, {
align: 'center'
});
yPosition += 15;
// Informações do Funcionário em tabela
const funcionarioData: string[][] = [];
if (registro.funcionario) {
if (registro.funcionario.matricula) {
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
}
funcionarioData.push(['Nome', registro.funcionario.nome || '-']);
if (registro.funcionario.descricaoCargo) {
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
}
if (registro.funcionario.simbolo) {
const simboloTipo =
registro.funcionario.simbolo.tipo === 'cargo_comissionado'
? 'Cargo Comissionado'
: 'Função Gratificada';
funcionarioData.push([
'Símbolo',
`${registro.funcionario.simbolo.nome} (${simboloTipo})`
]);
}
}
if (funcionarioData.length > 0) {
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Informação']],
body: funcionarioData,
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 10 },
margin: { left: 15, right: 15 }
});
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalY + 10;
}
// Informações do Registro em tabela
const config = configQuery?.data;
const tipoLabel = config
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: config.nomeEntrada,
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
nomeSaida: config.nomeSaida
})
: getTipoRegistroLabel(registro.tipo);
const dataHora = formatarDataHoraCompleta(
registro.data,
registro.hora,
registro.minuto,
registro.segundo
);
const registroData: string[][] = [
['Tipo', tipoLabel],
['Data e Hora', dataHora],
['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'],
['Tolerância', `${registro.toleranciaMinutos} minutos`],
['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (Servidor interno)']
];
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('DADOS DO REGISTRO', 15, yPosition);
yPosition += 8;
autoTable(doc, {
startY: yPosition,
head: [['Campo', 'Informação']],
body: registroData,
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 10 },
margin: { left: 15, right: 15 }
});
type JsPDFWithAutoTable2 = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY2 = (doc as JsPDFWithAutoTable2).lastAutoTable?.finalY ?? yPosition + 10;
yPosition = finalY2 + 10;
// Imagem capturada (se disponível)
if (registro.imagemUrl) {
yPosition += 10;
// Verificar se precisa de nova página
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
doc.setFontSize(12);
doc.setFont('helvetica', 'bold');
doc.setTextColor(0, 0, 0);
doc.text('FOTO CAPTURADA', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 10;
try {
// Carregar imagem usando fetch para evitar problemas de CORS
const response = await fetch(registro.imagemUrl);
if (!response.ok) {
throw new Error('Erro ao carregar imagem');
}
const blob = await response.blob();
const reader = new FileReader();
// Converter blob para base64
const base64 = await new Promise<string>((resolve, reject) => {
reader.onloadend = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Erro ao converter imagem'));
}
};
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
reader.readAsDataURL(blob);
});
// Criar elemento de imagem para obter dimensões
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error('Erro ao processar imagem'));
img.src = base64;
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
});
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
const maxWidth = 80;
const maxHeight = 60;
let imgWidth = img.width;
let imgHeight = img.height;
const aspectRatio = imgWidth / imgHeight;
if (imgWidth > maxWidth || imgHeight > maxHeight) {
if (aspectRatio > 1) {
// Imagem horizontal
imgWidth = maxWidth;
imgHeight = maxWidth / aspectRatio;
} else {
// Imagem vertical
imgHeight = maxHeight;
imgWidth = maxHeight * aspectRatio;
}
}
// Centralizar imagem
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
// Verificar se cabe na página atual
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
doc.addPage();
yPosition = 20;
}
// Adicionar imagem ao PDF usando base64
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
yPosition += imgHeight + 10;
} catch (error) {
console.warn('Erro ao adicionar imagem ao PDF:', error);
doc.setFontSize(10);
doc.text('Foto não disponível para impressão', 105, yPosition, {
align: 'center'
});
yPosition += 6;
}
}
// Rodapé
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 10,
{ align: 'center' }
);
}
// Salvar
const nomeArquivo = `comprovante-ponto-${registro.data}-${registro.hora}${registro.minuto.toString().padStart(2, '0')}.pdf`;
doc.save(nomeArquivo);
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar comprovante PDF. Tente novamente.');
} finally {
gerando = false;
}
}
</script>
<div
class="pointer-events-none fixed inset-0 z-50"
style="animation: fadeIn 0.2s ease-out;"
onkeydown={(e) => e.key === 'Escape' && onClose()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-comprovante-title"
>
<!-- Backdrop leve -->
<div
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
onclick={onClose}
></div>
<!-- Modal Box -->
<div
class="from-base-100 via-base-100 to-primary/5 border-primary/20 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl border-2 bg-gradient-to-br shadow-2xl transition-all duration-300"
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
onclick={(e) => e.stopPropagation()}
>
<!-- Header Premium com gradiente -->
<div
class="from-primary/10 via-primary/5 border-primary/20 flex flex-shrink-0 items-center justify-between border-b-2 bg-gradient-to-r to-transparent px-6 py-5"
>
<div class="flex items-center gap-3">
<div class="bg-primary/20 rounded-xl p-2.5 shadow-lg">
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
</div>
<div>
<h3 id="modal-comprovante-title" class="text-base-content text-xl font-bold">
Comprovante de Registro de Ponto
</h3>
<p class="text-base-content/70 mt-0.5 text-sm">Detalhes do registro realizado</p>
</div>
</div>
<button
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all"
onclick={onClose}
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
{#if registroQuery === undefined}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if !registroQuery?.data}
<div class="alert alert-error shadow-lg">
<XCircle class="h-5 w-5" />
<span class="font-semibold">Erro ao carregar registro</span>
</div>
{:else}
{@const registro = registroQuery.data}
<div class="space-y-6">
<!-- Informações do Funcionário -->
<div
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<User class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="text-base-content text-lg font-bold">Dados do Funcionário</h4>
</div>
{#if registro.funcionario}
<div class="space-y-3">
{#if registro.funcionario.matricula}
<div
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
>
<div class="flex-1">
<span
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Matrícula</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{registro.funcionario.matricula}
</p>
</div>
</div>
{/if}
<div
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
>
<div class="flex-1">
<span
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Nome</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{registro.funcionario.nome}
</p>
</div>
</div>
{#if registro.funcionario.descricaoCargo}
<div
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
>
<div class="flex-1">
<span
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Cargo/Função</span
>
<p class="text-base-content mt-1 text-base font-semibold">
{registro.funcionario.descricaoCargo}
</p>
</div>
</div>
{/if}
</div>
{/if}
</div>
</div>
<!-- Informações do Registro -->
<div
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-3">
<div class="bg-primary/20 rounded-lg p-2">
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
</div>
<h4 class="text-base-content text-lg font-bold">Dados do Registro</h4>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Tipo -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Tipo</span
>
<p class="text-primary mt-1 text-lg font-bold">
{configQuery?.data
? getTipoRegistroLabel(registro.tipo, {
nomeEntrada: configQuery.data.nomeEntrada,
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
nomeSaida: configQuery.data.nomeSaida
})
: getTipoRegistroLabel(registro.tipo)}
</p>
</div>
<!-- Data e Hora -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Data e Hora</span
>
<p class="text-base-content mt-1 text-lg font-bold">
{formatarDataHoraCompleta(
registro.data,
registro.hora,
registro.minuto,
registro.segundo
)}
</p>
</div>
<!-- Status -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Status</span
>
<div class="mt-2">
<span
class="badge badge-lg gap-2 {registro.dentroDoPrazo
? 'badge-success'
: 'badge-error'}"
>
{#if registro.dentroDoPrazo}
<CheckCircle2 class="h-4 w-4" />
{:else}
<XCircle class="h-4 w-4" />
{/if}
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
</span>
</div>
</div>
<!-- Tolerância -->
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
>Tolerância</span
>
<p class="text-base-content mt-1 text-lg font-bold">
{registro.toleranciaMinutos} minutos
</p>
</div>
</div>
</div>
</div>
<!-- Imagem Capturada -->
{#if registro.imagemUrl}
<div
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
>
<div class="card-body p-6">
<div class="mb-4 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
</div>
<h4 class="text-base-content text-lg font-bold">Foto Capturada</h4>
</div>
<div
class="bg-base-100 border-primary/20 flex justify-center rounded-xl border-2 p-4"
>
<img
src={registro.imagemUrl}
alt="Foto do registro de ponto"
class="max-h-[300px] max-w-full rounded-lg object-contain shadow-md"
onerror={(e) => {
console.error('Erro ao carregar imagem:', e);
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer fixo com botões -->
<div
class="border-primary/20 bg-base-100/50 flex flex-shrink-0 justify-end gap-3 border-t-2 px-6 py-4 backdrop-blur-sm"
>
<button class="btn btn-outline gap-2" onclick={onClose}>
<X class="h-4 w-4" />
Fechar
</button>
<button
class="btn btn-primary gap-2 shadow-lg transition-all hover:shadow-xl"
onclick={gerarPDF}
disabled={gerando}
>
{#if gerando}
<span class="loading loading-spinner loading-sm"></span>
Gerando...
{:else}
<Printer class="h-5 w-5" />
Imprimir Comprovante
{/if}
</button>
</div>
</div>
</div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* Scrollbar customizada para os modais */
:global(.modal-scroll) {
scrollbar-width: thin;
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
}
:global(.modal-scroll::-webkit-scrollbar) {
width: 8px;
}
:global(.modal-scroll::-webkit-scrollbar-track) {
background: transparent;
border-radius: 4px;
}
:global(.modal-scroll::-webkit-scrollbar-thumb) {
background-color: hsl(var(--bc) / 0.3);
border-radius: 4px;
transition: background-color 0.2s ease;
}
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
background-color: hsl(var(--bc) / 0.5);
}
</style>

View File

@@ -0,0 +1,24 @@
<script lang="ts">
import { AlertCircle, HelpCircle, MapPin } from 'lucide-svelte';
interface Props {
dentroRaioPermitido: boolean | null | undefined;
showTooltip?: boolean;
}
const { dentroRaioPermitido, showTooltip = true }: Props = $props();
</script>
{#if dentroRaioPermitido === true}
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
<MapPin class="text-success h-5 w-5" strokeWidth={2.5} />
</div>
{:else if dentroRaioPermitido === false}
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
<AlertCircle class="text-error h-5 w-5" strokeWidth={2.5} />
</div>
{:else}
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
<HelpCircle class="text-base-content/40 h-5 w-5" strokeWidth={2.5} />
</div>
{/if}

View File

@@ -0,0 +1,193 @@
<script lang="ts">
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { CheckCircle2, Printer, X } from 'lucide-svelte';
interface Props {
funcionarioId: Id<'funcionarios'>;
onClose: () => void;
onGenerate: (sections: {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}) => void;
}
const { funcionarioId, onClose, onGenerate }: Props = $props();
let modalRef: HTMLDialogElement;
// Seções selecionáveis
let sections = $state({
dadosFuncionario: true,
registrosPonto: true,
saldoDiario: true,
bancoHoras: true,
alteracoesGestor: true,
dispensasRegistro: true
});
function selectAll() {
Object.keys(sections).forEach((key) => {
sections[key as keyof typeof sections] = true;
});
}
function deselectAll() {
Object.keys(sections).forEach((key) => {
sections[key as keyof typeof sections] = false;
});
}
function handleGenerate() {
onGenerate(sections);
// Não chamar onClose() aqui - o modal será fechado pelo callback onSuccess
// após a geração do PDF ser concluída com sucesso
}
function handleClose() {
if (modalRef) {
modalRef.close();
}
onClose();
}
$effect(() => {
if (modalRef) {
modalRef.showModal();
}
});
</script>
<dialog bind:this={modalRef} class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="mb-6 flex items-center justify-between">
<h3 class="text-2xl font-bold">Selecionar Campos para Impressão</h3>
<button class="btn btn-sm btn-circle btn-ghost" onclick={handleClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Seção 1: Dados do Funcionário -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Dados do Funcionário</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.dadosFuncionario}
/>
</label>
<p class="text-base-content/70 mt-2 text-sm">
Nome, matrícula, cargo e informações básicas
</p>
</div>
</div>
<!-- Seção 2: Registros de Ponto -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Registros de Ponto</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.registrosPonto}
/>
</label>
<p class="text-base-content/70 mt-2 text-sm">
Data, tipo, horário e status de cada registro
</p>
</div>
</div>
<!-- Seção 3: Saldo Diário -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Saldo Diário</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.saldoDiario}
/>
</label>
<p class="text-base-content/70 mt-2 text-sm">
Saldo em horas e minutos de cada dia (positivo/negativo)
</p>
</div>
</div>
<!-- Seção 4: Banco de Horas -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Banco de Horas</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.bancoHoras}
/>
</label>
<p class="text-base-content/70 mt-2 text-sm">Saldo acumulado do banco de horas</p>
</div>
</div>
<!-- Seção 5: Alterações pelo Gestor -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Alterações pelo Gestor</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.alteracoesGestor}
/>
</label>
<p class="text-base-content/70 mt-2 text-sm">
Edições e ajustes realizados pelo gestor (se houver)
</p>
</div>
</div>
<!-- Seção 6: Dispensas de Registro -->
<div class="card bg-base-200">
<div class="card-body p-4">
<label class="label cursor-pointer">
<span class="label-text font-semibold">Dispensas de Registro</span>
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.dispensasRegistro}
/>
</label>
<p class="text-base-content/70 mt-2 text-sm">
Períodos onde o funcionário esteve dispensado de registrar ponto
</p>
</div>
</div>
</div>
<div class="flex items-center justify-between">
<div class="flex gap-2">
<button class="btn btn-sm btn-outline" onclick={selectAll}> Selecionar Todos </button>
<button class="btn btn-sm btn-outline" onclick={deselectAll}> Desmarcar Todos </button>
</div>
<div class="flex gap-2">
<button class="btn btn-ghost" onclick={handleClose}> Cancelar </button>
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
<Printer class="h-4 w-4" />
Gerar PDF
</button>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop" onsubmit={handleClose}>
<button type="submit">fechar</button>
</form>
</dialog>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,210 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient } from 'convex-svelte';
import { AlertCircle, CheckCircle2, Clock } from 'lucide-svelte';
import { onDestroy, onMount } from 'svelte';
import { obterTempoPC, obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
const client = useConvexClient();
// Expor estados para o componente pai usando $props() do Svelte 5
let { sincronizacaoConcluida = $bindable(false) }: { sincronizacaoConcluida: boolean } = $props();
let tempoAtual = $state<Date>(new Date());
let sincronizado = $state(false);
let sincronizando = $state(false);
let usandoServidorExterno = $state(false);
let offsetSegundos = $state(0);
let erro = $state<string | null>(null);
let intervalId: ReturnType<typeof setInterval> | null = null;
let intervaloSincronizacao: ReturnType<typeof setInterval> | null = null;
let sincronizacaoEmAndamento = $state(false); // Flag para evitar múltiplas sincronizações simultâneas
let sincronizacaoInicialConcluida = $state(false); // Flag para indicar que a primeira sincronização foi concluída
async function atualizarTempo() {
// Evitar múltiplas sincronizações simultâneas
if (sincronizacaoEmAndamento) {
return;
}
sincronizacaoEmAndamento = true;
sincronizando = true;
try {
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
// Se não estiver configurado, usar null e tratar como 0
const gmtOffset = config.gmtOffset ?? 0;
let timestampBase: number;
if (config.usarServidorExterno) {
try {
// Adicionar timeout de 10 segundos para sincronização
const sincronizacaoPromise = client.action(api.configuracaoRelogio.sincronizarTempo, {});
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('Timeout na sincronização (10s)')), 10000)
);
const resultado = await Promise.race([sincronizacaoPromise, timeoutPromise]);
if (resultado.sucesso && resultado.timestamp) {
timestampBase = resultado.timestamp;
sincronizado = true;
usandoServidorExterno = resultado.usandoServidorExterno || false;
offsetSegundos = resultado.offsetSegundos || 0;
erro = null;
} else {
throw new Error('Falha ao sincronizar');
}
} catch (error) {
console.warn('Erro ao sincronizar:', error);
if (config.fallbackParaPC) {
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando servidor interno (falha na sincronização)';
} else {
// Mesmo sem fallback configurado, usar PC como última opção
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando servidor interno (servidor indisponível)';
}
}
} else {
// Usar servidor interno (sem sincronização com servidor)
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando servidor interno';
}
// Aplicar GMT offset ao timestamp UTC
// O offset é aplicado manualmente, então usamos UTC como base para evitar conversão dupla
let timestampAjustado: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado ao timestamp UTC
timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000;
} else {
// Quando GMT = 0, manter timestamp UTC puro
timestampAjustado = timestampBase;
}
// Armazenar o timestamp ajustado (não o Date, para evitar problemas de timezone)
tempoAtual = new Date(timestampAjustado);
} catch (error) {
console.error('Erro ao obter tempo:', error);
tempoAtual = new Date(obterTempoPC());
sincronizado = false;
erro = 'Erro ao obter tempo do servidor';
} finally {
sincronizando = false;
sincronizacaoEmAndamento = false;
// Marcar sincronização inicial como concluída após a primeira tentativa
if (!sincronizacaoInicialConcluida) {
sincronizacaoInicialConcluida = true;
sincronizacaoConcluida = true;
}
}
}
function atualizarRelogio() {
// Atualizar segundo a segundo
const agora = new Date(tempoAtual.getTime() + 1000);
tempoAtual = agora;
}
onMount(async () => {
// Inicializar com servidor interno imediatamente para não bloquear a interface
tempoAtual = new Date(obterTempoPC());
sincronizado = false;
erro = 'Usando servidor interno';
sincronizacaoConcluida = false; // Garantir que começa como false
// Atualizar display a cada segundo
intervalId = setInterval(atualizarRelogio, 1000);
// Sincronizar em background (não bloquear) após um pequeno delay para garantir que a UI está renderizada
setTimeout(() => {
atualizarTempo().catch((error) => {
console.error('Erro ao sincronizar tempo em background:', error);
});
}, 100);
// Sincronizar a cada 30 segundos
intervaloSincronizacao = setInterval(() => {
atualizarTempo().catch((error) => {
console.error('Erro ao sincronizar tempo periódico:', error);
});
}, 30000);
});
onDestroy(() => {
if (intervalId) {
clearInterval(intervalId);
}
if (intervaloSincronizacao) {
clearInterval(intervaloSincronizacao);
}
sincronizacaoEmAndamento = false;
});
const horaFormatada = $derived.by(() => {
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
// Isso evita conversão dupla pelo navegador
return tempoAtual.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
});
});
const dataFormatada = $derived.by(() => {
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
// Isso evita conversão dupla pelo navegador
return tempoAtual.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
});
});
</script>
<div class="flex w-full flex-col items-center gap-4">
<!-- Hora -->
<div class="text-primary font-mono text-5xl font-black tracking-tight drop-shadow-sm">
{horaFormatada}
</div>
<!-- Data -->
<div class="text-base-content/80 text-base font-semibold capitalize">
{dataFormatada}
</div>
<!-- Status de Sincronização -->
<div
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizando
? 'bg-info/20 text-info border-info/30 border animate-pulse'
: sincronizado
? 'bg-success/20 text-success border-success/30 border'
: erro
? 'bg-warning/20 text-warning border-warning/30 border'
: 'bg-base-300/50 text-base-content/60 border-base-300 border'}"
>
{#if sincronizando}
<span class="loading loading-spinner loading-sm text-info"></span>
<span class="text-sm font-semibold">Sincronizando com servidor...</span>
{:else if sincronizado}
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">
{#if usandoServidorExterno}
Sincronizado com servidor NTP
{:else}
Sincronizado com servidor
{/if}
</span>
{:else if erro}
<AlertCircle class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">{erro}</span>
{:else}
<Clock class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">Usando servidor interno</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
interface Props {
saldo?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
} | null;
size?: 'sm' | 'md' | 'lg';
}
const { saldo, size = 'md' }: Props = $props();
function formatarSaldo(saldo: NonNullable<Props['saldo']>): string {
const sinal = saldo.positivo ? '+' : '-';
return `${sinal}${saldo.horas}h ${saldo.minutos}min`;
}
const sizeClasses = {
sm: 'badge-sm',
md: 'badge-lg',
lg: 'badge-xl'
};
</script>
{#if saldo}
<span
class="badge font-semibold shadow-sm {sizeClasses[size]} {saldo.positivo
? 'badge-success'
: 'badge-error'}"
>
{formatarSaldo(saldo)}
</span>
{:else}
<span class="badge badge-ghost {sizeClasses[size]}">-</span>
{/if}

View File

@@ -0,0 +1,51 @@
<script lang="ts">
interface Props {
saldo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
} | null;
size?: 'sm' | 'md' | 'lg';
}
const { saldo, size = 'md' }: Props = $props();
function formatarMinutos(minutos: number): { horas: number; minutos: number } {
const horas = Math.floor(Math.abs(minutos) / 60);
const mins = Math.abs(minutos) % 60;
return { horas, minutos: mins };
}
const sizeClasses = {
sm: 'text-xs px-2 py-1',
md: 'text-sm px-3 py-1.5',
lg: 'text-base px-4 py-2'
};
</script>
{#if saldo}
{@const trabalhado = formatarMinutos(saldo.trabalhadoMinutos)}
{@const diferenca = formatarMinutos(saldo.diferencaMinutos)}
{@const sinalDiferenca = saldo.diferencaMinutos >= 0 ? '+' : '-'}
{@const isNegativo = saldo.diferencaMinutos < 0}
<div
class="inline-flex items-center gap-1.5 {sizeClasses[
size
]} rounded-lg border font-semibold shadow-sm {isNegativo
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400'
: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'}"
>
<span class="font-bold text-green-600 dark:text-green-400"
>+{trabalhado.horas}h {trabalhado.minutos}min</span
>
<span class="text-base-content/50">/</span>
<span
class={isNegativo ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}
>
{sinalDiferenca}{diferenca.horas}h {diferenca.minutos}min
</span>
</div>
{:else}
<span class="badge badge-ghost {sizeClasses[size]}">-</span>
{/if}

Some files were not shown because too many files have changed in this diff Show More