Compare commits

...

193 Commits

Author SHA1 Message Date
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
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
917a1d03ca fix: resolve issues with file upload component and backend integration
- Addressed bugs in the FileUpload component related to file handling and state management.
- Improved backend integration to ensure consistent data flow and error handling during file uploads.
- Enhanced user feedback mechanisms to provide clearer notifications during upload processes.
2025-11-17 11:42:53 -03:00
3d9e8ae1f8 Merge remote-tracking branch 'origin/master' into bug-perfil 2025-11-17 11:41:41 -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
9b3b095c01 feat: enhance file upload component and backend schema
- Updated the FileUpload component to improve file handling and state management, including better cancellation logic and input handling.
- Added a new alias in the Svelte configuration for easier backend access.
- Enhanced the backend schema to include a gestorSuperiorId for better team management and query capabilities.
- Refactored queries and mutations in the backend to support the new structure and improve data retrieval for subordinate teams.
2025-11-14 22:11:06 -03:00
Kilder Costa
2420aafdba Merge pull request #25 from killer-cf/emp-perfis
Emp perfis
2025-11-14 21:58:00 -03:00
d8da7e2a05 refactor: enhance FileUpload component with improved error handling and UI updates
- Refactored the FileUpload component to improve code readability and maintainability.
- Enhanced error handling during file upload and removal processes, providing clearer feedback to users.
- Updated UI elements for better alignment and consistency, including file type previews and action buttons.
- Integrated the resolve function for help URLs to ensure proper linking.
- Streamlined file validation logic for size and type checks, improving user experience.
2025-11-14 21:55:28 -03:00
b503045b41 refactor: integrate ProtectedRoute component across dashboard pages
- Added the ProtectedRoute component to various dashboard pages to enforce authentication and role-based access control.
- Updated allowedRoles and maxLevel parameters for specific routes to align with the new permission management structure.
- Enhanced user experience by ensuring consistent access checks across the application.
2025-11-14 16:15:21 -03:00
3c371bc35c refactor: update permission management and role structure
- Enhanced the permission management system by introducing a new base permissions structure, allowing for better organization and clarity.
- Updated the role creation process to normalize role levels, limiting them to two levels: 0 (maximum access) and 1 (administrative access).
- Improved the UI for permission displays, ensuring users are informed when no permissions are available.
- Added alerts and messages to guide users in creating permissions when none exist.
- Streamlined backend queries for permissions and roles to improve performance and maintainability.
2025-11-14 15:12:54 -03:00
731f95d0b5 refactor: update vacation scheduling button logic and UI
- Modified the vacation scheduling button to enable/disable based on employee ID availability.
- Improved user feedback with dynamic button titles reflecting the validation status of employee data.
- Cleaned up the button's SVG structure for better readability and maintainability.
2025-11-14 11:42:51 -03:00
5b2c682870 lixo 2025-11-14 10:47:41 -03:00
Kilder Costa
33a9c9e81d Merge pull request #23 from killer-cf/correcao-ferias
Correcao ferias
2025-11-14 09:24:50 -03:00
aa0b03ed3f feat: implement immediate email processing for unscheduled emails
- Added logic to process emails immediately if no scheduling is provided or if the scheduled time has passed.
- Introduced error handling for immediate email scheduling to ensure the mutation does not fail if scheduling encounters an error.
- Updated comments for clarity on email processing behavior.
2025-11-13 16:46:08 -03:00
73d550aa96 refactor: optimize vacation data aggregation and logging
- Updated the logic for aggregating approved vacation requests to streamline data handling and improve performance.
- Enhanced logging to provide detailed insights into vacation periods and requests, aiding in debugging and monitoring.
- Cleaned up code formatting for better readability and maintainability, ensuring a consistent coding style throughout the component.
2025-11-13 16:39:56 -03:00
c058865817 refactor: update vacation management structure and enhance status handling
- Renamed and refactored vacation-related types and components for clarity, transitioning from 'SolicitacaoFerias' to 'PeriodoFerias'.
- Improved the handling of vacation statuses, including the addition of 'EmFérias' to the status options.
- Streamlined the vacation request and approval components to better reflect individual vacation periods.
- Enhanced data handling in backend queries and schema to support the new structure and ensure accurate status updates.
- Improved user experience by refining UI elements related to vacation periods and their statuses.
2025-11-13 15:54:59 -03:00
Kilder Costa
fe68dd9d11 Merge pull request #18 from killer-cf/fix-page-with-lint-errors
Fix page with lint errors
2025-11-13 08:55:09 -03:00
1fea5f1f26 Merge remote-tracking branch 'origin' into fix-page-with-lint-errors 2025-11-13 08:52:38 -03:00
Kilder Costa
b65cf5b4a6 Merge pull request #17 from killer-cf/correcao-ferias
Correcao ferias
2025-11-13 08:51:34 -03:00
4ae5baffcc refactor: enhance gestor status calculation and stability
- Introduced a more robust calculation for determining if a user is a gestor, considering team membership, roles, and subordinate requests.
- Implemented stable state management to prevent UI flickering during updates.
- Added logging for debugging purposes to track changes in gestor status.
- Improved overall user experience by ensuring consistent visibility of tabs based on gestor status.
2025-11-13 06:51:53 -03:00
fd7d3729c1 feat: add validation for vacation request approval and adjustments
- Implemented validation checks to ensure employee and vacation period data are valid before approval.
- Enhanced error handling to provide specific feedback for invalid requests.
- Added checks for adjusted vacation periods to ensure all required dates are present.
- Improved overall user experience by ensuring only valid requests can be processed.
2025-11-13 06:25:22 -03:00
ebde59c6d2 refactor: enhance vacation management components and add status update functionality
- Improved the vacation request component with better loading states and error handling.
- Added a new mutation to update the status of vacation requests, allowing transitions between different states.
- Enhanced the calendar display for vacation periods and integrated a 3D bar chart for visualizing vacation data.
- Refactored the code for better readability and maintainability, ensuring a smoother user experience.
2025-11-13 05:51:55 -03:00
0b7f1ad621 chore: update dependencies and enhance vacation request component
- Updated convex-svelte to version 0.0.12 and convex to version 1.28.2 for improved functionality.
- Refactored the vacation request component to enhance loading states and error handling.
- Implemented derived states for better management of loading and error states.
- Improved calendar initialization logic and cleanup processes for better performance and reliability.
2025-11-13 03:28:39 -03:00
4ffa403c46 Refactor FileUpload component and improve type safety
- Rename imported File icon to FileIcon to avoid naming conflicts
- Update onUpload type to use globalThis.File
- Reformat loadExistingFile and related code for better readability
- Add stricter typing for funcionarioId and related data in documentos
  and editar pages
- Improve error handling and response validation in file upload logic
- Add keyed each blocks for better Svelte list rendering stability
- Fix minor formatting issues in breadcrumb links
2025-11-13 00:12:16 -03:00
bd574aedc0 Use resolve() for all internal hrefs and goto paths to ensure correct
routing
2025-11-12 23:18:41 -03:00
a2451baafc Use $app/paths resolve for internal URLs and clean code``` 2025-11-12 20:00:01 -03:00
34e4835c81 chore: update convex and convex-svelte dependencies
- Bumped convex version from 1.28.0 to 1.28.2 for improved functionality.
- Updated convex-svelte version from 0.0.11 to 0.0.12 to incorporate the latest features and fixes.
2025-11-12 19:03:36 -03:00
Kilder Costa
9822343561 Merge pull request #16 from killer-cf/fix-page-with-lint-errors
Fix page with lint errors
2025-11-12 16:37:31 -03:00
11eef4aa2a refactor: improve Svelte components and enhance user experience
- Updated various Svelte components to improve code readability and maintainability.
- Standardized button classes across components for a consistent user interface.
- Enhanced error handling and user feedback in modals and forms.
- Cleaned up unnecessary imports and optimized component structure for better performance.
2025-11-12 16:36:29 -03:00
94f4b23a39 refactor: enhance employee registration form and backend validation
- Updated the employee registration form in Svelte to include additional fields for personal and banking information.
- Improved validation logic for required fields and document uploads, ensuring better user feedback.
- Refactored the backend mutation to streamline argument handling and added new fields for document storage.
- Enhanced the overall structure and readability of the code, maintaining existing functionality while improving maintainability.
2025-11-12 16:29:18 -03:00
3a783727dc refactor: enhance vacation request component and improve error handling
- Updated the SolicitarFerias component to improve the user interface and experience.
- Added validation for overlapping vacation periods and ensured proper error messages are displayed.
- Refactored the logic for adding and removing vacation periods, enhancing code readability and maintainability.
- Improved the handling of form submission and error states for better user feedback.
2025-11-12 14:38:08 -03:00
553fc578a6 fix: foto perfil url 2025-11-12 14:26:51 -03:00
87b59af8da Merge remote-tracking branch 'origin' into fix-page-with-lint-errors 2025-11-12 12:16:43 -03:00
6087990eaf refactor: improve user retrieval and formatting in auth component
- Enhanced the getCurrentUser function to include the user's profile picture URL.
- Cleaned up formatting for better readability and consistency across the auth.ts file.
- Maintained existing functionality while improving code organization.
2025-11-12 12:15:43 -03:00
Kilder Costa
67ea8bd695 Merge pull request #15 from killer-cf/fix-page-with-lint-errors
Fix page with lint errors
2025-11-12 12:00:40 -03:00
da26a21f7e refactor: update permissions and clean up Svelte component
- Removed outdated information about the permissions system from the Svelte component.
- Added new actions for resource management in the backend, including 'aprovar_ausencias' and 'aprovar_ferias'.
- Cleaned up console logs in the user retrieval function for better performance and security.
2025-11-12 11:59:53 -03:00
90bc5771ae refactor: enhance role management and permissions handling
- Introduced a new mutation for creating roles with validation and slugification of names.
- Updated existing queries to improve role retrieval and error handling.
- Enhanced permission copying functionality when creating new roles.
- Improved code organization and readability by restructuring functions and adding type annotations.
2025-11-12 10:24:56 -03:00
1c56d71d43 refactor: update Svelte and TypeScript rules for improved application behavior
- Set Svelte rules to always apply for consistent usage.
- Adjust TypeScript rules to exclude .tsx files and ensure clarity in type safety guidelines.
- Cleaned up formatting and examples for better readability and understanding.
2025-11-12 10:24:45 -03:00
9bb13b486e add sevelt rules 2025-11-12 09:11:48 -03:00
349a7bb1e4 add project mcps 2025-11-12 09:06:43 -03:00
Kilder Costa
c6e6ec4823 Merge pull request #14 from killer-cf/feat-many-fixes
refactor: update ESLint configuration and improve Svelte component st…
2025-11-12 08:52:31 -03:00
Kilder Costa
11543db953 Merge branch 'master' into feat-many-fixes 2025-11-12 08:51:59 -03:00
2fb934ba18 refactor: update ESLint configuration and improve Svelte component structure
- Added .eslintcache to .gitignore for better cache management.
- Enhanced ESLint configuration to ignore specific directories and files for cleaner linting.
- Updated Svelte components to use unique keys in loops for improved performance and maintainability.
- Cleaned up imports and standardized formatting across various components for better code clarity.
- Resolved merge conflicts in multiple Svelte files to ensure consistent functionality.
2025-11-12 08:45:40 -03:00
Kilder Costa
81d96b8d88 Merge pull request #13 from killer-cf/revert-12-feat-many-fixes
Revert "Feat many fixes"
2025-11-11 16:41:58 -03:00
Kilder Costa
caff7035f7 Revert "Feat many fixes" 2025-11-11 16:41:40 -03:00
Kilder Costa
1c197a7534 Merge pull request #12 from killer-cf/feat-many-fixes
Feat many fixes
2025-11-11 16:26:47 -03:00
dab4754e47 Merge remote-tracking branch 'origin' into feat-many-fixes 2025-11-11 16:25:29 -03:00
Kilder Costa
3886dbd3ba Merge pull request #11 from killer-cf/ferias-ausencia
refactor: enhance employee management with regime de trabalho integra…
2025-11-11 16:11:39 -03:00
8a0a4552f7 refactor: enhance employee management with regime de trabalho integration
- Added regime de trabalho selection to employee forms for better categorization.
- Updated backend validation to include regime de trabalho options for employees.
- Enhanced employee data handling by integrating regime de trabalho into various components.
- Removed the print modal for financial data to streamline the employee profile interface.
- Improved overall code clarity and maintainability across multiple files.
2025-11-11 16:10:08 -03:00
d3d7744402 Refactor backend code style and improve user profile handling
- Standardize import formatting and indentation in auth and funcionarios
  modules
- Enhance getCurrentUser query to include photo URL retrieval from
  storage
- Add getCurrent funcionario query based on authenticated user
- Update controller logic to avoid redundant local state for profile
  photos
- Upgrade dependencies: convex 1.28.2, svelte 5.43.6, vite 7.2.2, rollup
  4.53.2, tailwindcss 4.1.17, and others
2025-11-11 16:01:18 -03:00
e09d03ceb8 refactor: enhance error handling and improve component structure in cadastro page
- Updated error handling to provide clearer messages by casting errors to the Error type.
- Cleaned up imports and improved breadcrumb navigation using the resolve function for better path management.
- Streamlined the rendering of options in select elements by adding unique keys for better performance.
- Enhanced the mapping of document categories and symbols for improved readability and maintainability.
2025-11-11 10:24:01 -03:00
2772aa3112 refactor: simplify button styles across Svelte components
- Removed unnecessary 'btn-square' class from buttons in multiple components to streamline styling.
- Updated button elements in SolicitarFerias, editar, cadastro, and times pages for consistency and improved readability.
- Replaced SVG icons with Lucide icons for better visual consistency in the cadastro page.
- Cleaned up imports in the cadastro page to enhance code clarity.
2025-11-10 17:01:38 -03:00
c7479222da refactor: streamline Svelte components and enhance user feedback
- Refactored multiple Svelte components, including AprovarAusencias, AprovarFerias, and ErrorModal, to improve code clarity and maintainability.
- Updated modal interactions and error handling messages for better user feedback.
- Cleaned up component structures and standardized formatting for consistency across the codebase.
- Enhanced styling and layout adjustments to align with the new design system.
2025-11-10 16:34:15 -03:00
ed00739b30 refactor: enhance chat components and improve user interaction
- Refactored Sidebar, ChatList, ChatWindow, and NewConversationModal components for better readability and maintainability.
- Updated user data handling to utilize the latest API responses, ensuring accurate display of user statuses and notifications.
- Improved modal interactions and user feedback mechanisms across chat components.
- Cleaned up unused code and optimized state management for a smoother user experience.
2025-11-10 15:03:16 -03:00
Kilder Costa
3cc774d7df Merge pull request #10 from killer-cf/feat-better-auth
refactor: improve layout and backend monitoring functionality

- Streamlined the layout component in Svelte for better readability and consistency.
- Enhanced the backend monitoring functions by updating argument structures and improving code clarity.
- Added new query functions for system status and database activity, providing better insights into system performance.
- Cleaned up existing code to ensure maintainability and improved error handling across various functions.
2025-11-08 19:27:29 -03:00
4ed90d380d refactor: improve layout and backend monitoring functionality
- Streamlined the layout component in Svelte for better readability and consistency.
- Enhanced the backend monitoring functions by updating argument structures and improving code clarity.
- Added new query functions for system status and database activity, providing better insights into system performance.
- Cleaned up existing code to ensure maintainability and improved error handling across various functions.
2025-11-08 18:30:27 -03:00
5d76c375c2 refactor: streamline chat widget and backend user management
- Removed the .editorconfig file to simplify project configuration.
- Refactored the ChatWidget component to enhance readability and maintainability, including the integration of current user data and improved notification handling.
- Updated backend functions to utilize the new getCurrentUserFunction for user authentication, ensuring consistent user data retrieval across various modules.
- Cleaned up code in multiple backend files, enhancing clarity and performance while maintaining functionality.
- Improved error handling and user feedback mechanisms in user-related operations.
2025-11-08 17:46:10 -03:00
57b5f6821b refactor: remove biome configuration and update dependencies
- Deleted the obsolete biome.json file to streamline project configuration.
- Updated package.json and bun.lock to include new ESLint and Prettier dependencies for improved code quality and consistency.
- Added ESLint configuration package for better linting support across the project.
- Cleaned up various package dependencies to ensure a more maintainable and efficient codebase.
2025-11-08 17:16:43 -03:00
4e30d6a2ba refactor: remove obsolete authentication documentation and files
- Deleted multiple markdown files related to authentication analysis, push notifications configuration, and environment variable setup to streamline the documentation.
- Removed outdated scripts and guides that are no longer relevant due to the migration to Better Auth.
- Cleaned up the repository by eliminating unnecessary files, ensuring a more focused and maintainable codebase.
2025-11-08 17:16:29 -03:00
9a5f2b294d refactor: integrate current user data across components
- Replaced instances of `authStore` with `currentUser` to streamline user authentication handling.
- Updated permission checks and user-related data retrieval to utilize the new `useQuery` for better performance and clarity.
- Cleaned up component structures and improved formatting for consistency and readability.
- Enhanced error handling and user feedback mechanisms in various components to improve user experience.
2025-11-08 10:52:33 -03:00
01138b3e1c refactor: clean up Svelte components and improve code readability
- Refactored multiple Svelte components to enhance code clarity and maintainability.
- Standardized formatting and indentation across various files for consistency.
- Improved error handling messages in the AprovarAusencias component for better user feedback.
- Updated class names in the UI components to align with the new design system.
- Removed unnecessary whitespace and comments to streamline the codebase.
2025-11-08 10:11:40 -03:00
28107b4050 refactor: enhance Sidebar component with Better Auth integration
- Replaced the use of `useConvexClient` with `useQuery` for fetching the current user.
- Updated avatar URL retrieval to utilize the current user data from Better Auth.
- Refactored login and logout functions to use the new `authClient` methods for improved authentication flow.
- Cleaned up the component structure and styling for better readability and maintainability.
- Adjusted sidebar and footer styles for consistency with the new design system.
2025-11-08 09:48:12 -03:00
3a32f5e4eb refactor: remove authentication module and integrate Better Auth
- Deleted the `autenticacao.ts` file to streamline the authentication process.
- Updated the `auth.ts` file to include new functions for user management and password updates.
- Modified the schema to enforce the presence of `authId` for users, ensuring integration with Better Auth.
- Refactored the seed process to create users with Better Auth integration, enhancing user management capabilities.
- Cleaned up the `usuarios.ts` file to utilize the new authentication functions and improve code clarity.
2025-11-07 23:33:09 -03:00
427c78ec37 refactor: update better-auth configuration and clean up code
- Changed the version of "better-auth" to use a catalog reference in package.json and bun.lock for better dependency management.
- Removed unnecessary comments and cleaned up the code in various files to enhance readability and maintainability.
- Updated the authentication handling in hooks.server.ts to ensure proper token retrieval.
- Simplified the layout and structure of Svelte components for improved clarity and performance.
2025-11-07 11:18:34 -03:00
57dd9492ef refactor base auth 2025-11-07 10:33:48 -03:00
6f4df44a00 refactor: simplify auth component configuration
- Removed the local schema configuration from the auth component, streamlining the integration with Better Auth.
- Updated the client creation to focus solely on the DataModel, enhancing clarity and maintainability.
2025-11-07 09:18:41 -03:00
ca51839082 initial better auth config 2025-11-06 11:42:48 -03:00
ffeab9cace Merge remote-tracking branch 'origin/correcao-smtp' into feat-better-auth 2025-11-06 09:41:02 -03:00
06f03b53e5 feat: integrate Better Auth and enhance authentication flow
- Added Better Auth integration to the web application, allowing for dual login support with both custom and Better Auth systems.
- Updated authentication client configuration to dynamically set the base URL based on the environment.
- Enhanced chat components to utilize user authentication status, improving user experience and security.
- Refactored various components to support Better Auth, including error handling and user identity management.
- Improved notification handling and user feedback mechanisms during authentication processes.
2025-11-06 09:35:36 -03:00
33f305220b feat: improve email status querying and URL handling
- Updated email status query to execute only when there are email IDs, enhancing performance.
- Ensured URL handling in email sending functions always includes a protocol, improving reliability.
- Added new queries for fetching emails by IDs and listing scheduled emails, enriching email management capabilities.
2025-11-05 16:23:47 -03:00
Kilder Costa
db9a93a58b Merge pull request #9 from killer-cf/ajuste_chat
Ajuste chat
2025-11-05 15:44:43 -03:00
36933b53cb Merge remote-tracking branch 'origin/master' into ajuste_chat 2025-11-05 15:17:26 -03:00
05244b9207 feat: enhance ChatWidget with improved drag-and-resize functionality
- Introduced drag threshold and movement detection to prevent unintended clicks during dragging.
- Added reactive window dimensions to ensure proper positioning and resizing of the chat widget.
- Refactored mouse event handlers for better separation of concerns and improved user experience.
- Enhanced position adjustment logic to maintain visibility within the viewport during dragging and resizing.
2025-11-05 15:16:20 -03:00
Kilder Costa
dc7447cfbc Merge pull request #8 from killer-cf/fix-email
feat: update email handling and user management logic
2025-11-05 15:11:42 -03:00
1b02ea7c22 feat: enhance notification management with new clearing functionalities
- Added functionality to clear all notifications and clear unread notifications for improved user control.
- Updated NotificationBell component to support modal display for notifications, enhancing user experience.
- Refactored notification handling to separate read and unread notifications, providing clearer organization.
- Introduced new UI elements for managing notifications, including buttons for clearing notifications directly from the modal.
- Improved backend mutations to handle notification deletion securely, ensuring users can only delete their own notifications.
2025-11-05 15:06:41 -03:00
fe83a3d371 feat: update email handling and user management logic
- Added logic to update user email if it differs from the existing record when updating a funcionario.
- Refactored email sending action to improve code readability and maintainability, including consistent formatting and error handling.
- Enhanced logging for email configuration and sending processes to provide clearer feedback during operations.
2025-11-05 15:05:54 -03:00
6166043735 feat: enhance ErrorModal and chat components with new features and improvements
- Refactored ErrorModal to utilize a dialog element for better accessibility and user experience, including a close button with an icon.
- Updated chat components to improve participant display and message read status, enhancing user engagement and clarity.
- Introduced loading indicators for user and conversation data in SalaReuniaoManager to improve responsiveness during data fetching.
- Enhanced message handling in MessageList to indicate whether messages have been read, providing users with better feedback on message status.
- Improved overall structure and styling across various components for consistency and maintainability.
2025-11-05 14:05:52 -03:00
c459297968 refactor: update components to use lucide icons and improve structure
- Replaced SVG icons with lucide-svelte components across various files for consistency and improved performance.
- Refactored ActionGuard, ErrorModal, FileUpload, and other components to enhance readability and maintainability.
- Updated the dashboard pages to include new icons and improved layout for better user experience.
- Enhanced StatsCard component to support dynamic icon rendering, allowing for more flexible usage.
- Improved overall styling and structure in multiple components to align with design standards.
2025-11-05 12:09:41 -03:00
6cb7414dcc fix: update dependencies and improve chat component structure
- Updated `lucide-svelte` dependency to version 0.552.0 across multiple files for consistency.
- Refactored chat components to enhance structure and readability, including adjustments to the Sidebar, ChatList, and MessageInput components.
- Improved notification handling in chat components to ensure better user experience and responsiveness.
- Added type safety enhancements in various components to ensure better integration with backend data models.
2025-11-05 11:52:01 -03:00
Kilder Costa
d0bcef4d40 Merge pull request #7 from killer-cf/feat-ausencia
Feat ausencia
2025-11-05 10:49:12 -03:00
1774b135b3 feat: enhance chat functionality with global notifications and user management
- Implemented global notifications for new messages, allowing users to receive alerts even when the chat is minimized or closed.
- Added functionality for users to leave group conversations and meeting rooms, with appropriate notifications sent to remaining participants.
- Introduced a modal for sending notifications within meeting rooms, enabling admins to communicate important messages to all participants.
- Enhanced the chat components to support mention functionality, allowing users to tag participants in messages for better engagement.
- Updated backend mutations to handle user exit from conversations and sending notifications, ensuring robust data handling and user experience.
2025-11-05 10:40:30 -03:00
8ca737c62f feat: enhance chat functionality with new conversation and meeting room features
- Added support for creating and managing group conversations and meeting rooms, allowing users to initiate discussions with multiple participants.
- Implemented a modal for creating new conversations, including options for individual, group, and meeting room types.
- Enhanced the chat list component to filter and display conversations based on type, improving user navigation.
- Introduced admin functionalities for meeting rooms, enabling user management and role assignments within the chat interface.
- Updated backend schema and API to accommodate new conversation types and related operations, ensuring robust data handling.
2025-11-05 07:20:37 -03:00
aa3e3470cd feat: enhance push notification management and error handling
- Implemented error handling for unhandled promise rejections related to message channels, improving stability during push notification operations.
- Updated the PushNotificationManager component to manage push subscription registration with timeouts, preventing application hangs.
- Enhanced the sidebar and chat components to display user avatars, improving user experience and visual consistency.
- Refactored email processing logic to support scheduled email sending, integrating new backend functionalities for better email management.
- Improved overall error handling and logging across components to reduce console spam and enhance debugging capabilities.
2025-11-05 06:14:52 -03:00
f6671e0f16 feat: enhance email monitoring and management features
- Added a new section for monitoring email status, allowing users to track the email queue and identify sending issues.
- Updated the backend to support new internal queries for listing pending emails and retrieving email configurations.
- Refactored email-related mutations to improve error handling and streamline the email sending process.
- Enhanced the overall email management experience by providing clearer feedback and monitoring capabilities.
2025-11-04 21:27:48 -03:00
12db52a8a7 refactor: enhance chat components with type safety and response functionality
- Updated type definitions in ChatWindow and MessageList components for better type safety.
- Improved MessageInput to handle message responses, including a preview feature for replying to messages.
- Enhanced the chat message handling logic to support message references and improve user interaction.
- Refactored notification utility functions to support push notifications and rate limiting for email sending.
- Updated backend schema to accommodate new features related to message responses and notifications.
2025-11-04 20:36:01 -03:00
15374276d5 refactor: streamline absence management interface and enhance user experience
- Reorganized the absence management page to improve navigation and accessibility.
- Introduced a menu structure for better categorization of absence-related options.
- Updated the layout and styling for a more modern and user-friendly interface.
- Enhanced the overall user experience by providing clearer descriptions and visual cues for actions related to absence management.
2025-11-04 19:19:51 -03:00
c86397f150 Merge remote-tracking branch 'origin/master' into feat-ausencia 2025-11-04 15:10:35 -03:00
c1e0998a5f feat: enhance absence management with calendar integration and error handling
- Added functionality to check for date overlaps with existing absence requests in the absence calendar.
- Implemented a modal to display error messages when users attempt to create overlapping absence requests.
- Updated the calendar component to visually indicate blocked days due to existing approved or pending absence requests.
- Improved user feedback by providing alerts for unavailable periods and enhancing the overall user experience in absence management.
2025-11-04 15:09:15 -03:00
Kilder Costa
9ff61b325f Merge pull request #6 from killer-cf/fix-usuarios-page
Fix usuarios page
2025-11-04 14:42:21 -03:00
fbec5c46c2 feat: enhance user management with matricula retrieval and validation
- Updated user-related queries and mutations to retrieve the matricula from associated funcionario records, improving data accuracy.
- Refactored user creation and listing functionalities to ensure matricula is correctly handled and displayed.
- Enhanced error handling and validation for user operations, ensuring a more robust user management experience.
- Improved the overall structure of user-related code for better maintainability and clarity.
2025-11-04 14:37:28 -03:00
a93d55f02b feat: implement absence management features in the dashboard
- Added functionality for managing absence requests, including listing, approving, and rejecting requests.
- Enhanced the user interface to display statistics and pending requests for better oversight.
- Updated backend schema to support absence requests and notifications, ensuring data integrity and efficient handling.
- Integrated new components for absence request forms and approval workflows, improving user experience and administrative efficiency.
2025-11-04 14:23:46 -03:00
d0692c3608 chore: update editorconfig and tool versions
- Changed the indent size in .editorconfig from 4 to 2 spaces for consistency.
- Updated Node.js version in .tool-versions from 25.0.0 to 22.21.1 to align with project requirements.
2025-11-04 13:41:12 -03:00
f02eb473ca feat: add salary family and income tax options for dependents in employee registration
- Enhanced the employee registration form by adding checkboxes for "Salário Família" and "Imposto de Renda" for each dependent.
- Updated the backend schema and mutations to include optional fields for salary family and income tax benefits.
- Improved the handling of dependent data to accommodate the new fields, enhancing the overall functionality of the dependents management section.
2025-11-04 10:54:39 -03:00
f7cc758d33 refactor: improve UI and functionality in employee registration and audit pages
- Enhanced the employee registration form by adding a dependents management section, allowing users to input details such as relationship, name, CPF, and birth date.
- Updated the layout and styling of the audit page, including improved statistics display and user feedback elements.
- Refined the handling of user actions in the audit logs, providing clearer labels and better organization of information.
- Improved the overall user experience by ensuring consistent design patterns and responsive elements across the registration and audit interfaces.
2025-11-04 06:31:28 -03:00
d5c01aabab feat: add dependents management to employee registration
- Introduced functionality to manage dependents during employee registration, allowing users to add details such as relationship, name, CPF, and birth date.
- Updated the backend schema and mutation to support optional dependents, enhancing the employee data model.
- Enhanced the frontend form to include fields for dependents, improving the user experience in the employee registration process.
2025-11-04 05:05:13 -03:00
90eee27ba7 feat: enhance alert configuration modal with improved user interface and functionality
- Updated the AlertConfigModal component to provide a more interactive and user-friendly experience for configuring alerts.
- Added options for selecting metrics and conditions, along with a preview of the alert configuration.
- Improved form handling for creating and editing alerts, including better state management and error handling.
- Enhanced the display of configured alerts with clear status indicators and action buttons for editing and deleting alerts.
2025-11-04 04:35:03 -03:00
0fee0cfd35 feat: integrate interactive absence calendar in atestados-licencas page
- Added the CalendarioAfastamentos component to display an interactive calendar for managing absences.
- Removed the previous static calendar placeholder and replaced it with a dynamic query to fetch absence events.
- Enhanced user experience by allowing users to visualize absence events directly in the calendar format.
2025-11-04 04:02:07 -03:00
bc3c7df00f feat: refactor document URL retrieval for atestados and licencas
- Updated the document URL fetching logic in the +page.svelte file to use a new query method, enhancing the retrieval process.
- Added a new query in atestadosLicencas.ts to obtain stored document URLs, improving authentication checks and error handling.
- Streamlined the user experience by ensuring URLs are fetched correctly and opened in a new tab when available.
2025-11-04 03:46:50 -03:00
ccc8c5d5f4 fix: update licencaOriginalId handling and improve error feedback in document retrieval
- Changed the initialization of licencaOriginalId to undefined for better clarity in state management.
- Added logic to ensure licencaOriginalId is cleared when not a prorrogação, enhancing data integrity.
- Improved error handling for document retrieval, providing clearer user feedback when URLs cannot be obtained.
- Refactored document URL fetching to streamline the process and enhance user experience.
2025-11-04 03:45:09 -03:00
6d613fe618 feat: add required field validation and error handling in file upload component
- Introduced a `required` prop in the `FileUpload` component to enforce mandatory file uploads.
- Enhanced error handling by implementing a modal for displaying error messages related to missing required fields and upload issues.
- Updated the `+page.svelte` file to integrate the new error modal and improve user feedback during form submissions.
- Ensured that all relevant file upload sections now validate the presence of documents before allowing form submission.
2025-11-04 03:37:22 -03:00
c6c88f85a7 feat: enhance login process with IP capture and improved error handling
- Implemented an internal mutation for login that captures the user's IP address and user agent for better security and tracking.
- Enhanced the HTTP login endpoint to extract and log client IP, improving the overall authentication process.
- Added validation for IP addresses to ensure only valid formats are recorded, enhancing data integrity.
- Updated the login mutation to handle rate limiting and user status checks more effectively, providing clearer feedback on login attempts.
2025-11-04 03:26:34 -03:00
f278ad4d17 feat: capture and log browser information during user login
- Integrated browser information capture in the login process, including user agent and IP address.
- Enhanced device and browser detection logic to provide more detailed insights into user environments.
- Improved system detection for various operating systems and devices, ensuring accurate reporting during authentication.
2025-11-04 02:27:56 -03:00
372b2b5bf9 feat: add statistics and filtering options for user roles in dashboard
- Introduced a new `StatsCard` component to display statistics related to user roles, including total profiles and access levels.
- Implemented filtering options for user roles based on access level, enhancing the user experience in the dashboard.
- Improved the layout and styling of the dashboard, including adjustments to the filters and role display cards for better usability.
- Added derived state for active filters and statistics, ensuring real-time updates in the UI.
2025-11-04 02:19:09 -03:00
5d2df8077b feat: enhance email handling with improved error reporting and statistics
- Updated the `reenviarEmail` mutation to return detailed error messages for better user feedback.
- Added a new query to obtain email queue statistics, providing insights into email statuses.
- Enhanced the `processarFilaEmails` mutation to track processing failures and successes more effectively.
- Implemented a manual email processing mutation for immediate testing and control over email sending.
- Improved email validation and error handling in the email sending action, ensuring robust delivery processes.
2025-11-04 02:14:07 -03:00
e6105ae8ea fix: update email configuration handling and improve type safety
- Changed the mutation for testing SMTP connection to use an action for better handling.
- Introduced an internal mutation to update the test timestamp for email configurations.
- Enhanced type safety by specifying document types for user and session queries.
- Improved error handling in the SMTP connection test to provide clearer feedback on failures.
2025-11-04 01:59:08 -03:00
3b89c496c6 feat: enhance scheduling and management of email notifications
- Added functionality to cancel scheduled email notifications, improving user control over their email management.
- Implemented a query to list all scheduled emails for the current user, providing better visibility into upcoming notifications.
- Enhanced the email schema to support scheduling features, including a timestamp for scheduled delivery.
- Improved error handling and user feedback for email scheduling actions, ensuring a smoother user experience.
2025-11-04 00:43:13 -03:00
7fb1693717 feat: enhance email notification system with tracking and feedback
- Introduced a new feature to track email statuses by implementing a mapping of email IDs.
- Added a query to fetch email statuses based on tracked IDs, improving the monitoring of email delivery.
- Enhanced the logging system for email and chat notifications, providing detailed feedback on the sending process.
- Implemented user feedback messages for various actions, improving the overall user experience.
- Refactored the notification sending logic to support better error handling and status updates.
2025-11-04 00:19:35 -03:00
ce24190b1a feat: enhance email configuration and validation features
- Implemented mutual exclusivity for SSL and TLS options in the email configuration.
- Added comprehensive validation for required fields, port range, email format, and password requirements.
- Updated the backend to support reversible encryption for SMTP passwords, ensuring secure handling of sensitive data.
- Introduced loading states and improved user feedback in the email configuration UI for better user experience.
2025-11-03 23:51:57 -03:00
3d8f907fa5 feat: implement scheduling for notifications with enhanced validation
- Added functionality to schedule notifications for future delivery, including date and time selection in the UI.
- Implemented validation to ensure scheduled times are in the future and correctly formatted.
- Updated backend email handling to support scheduled sending, with appropriate checks for agendamentos.
- Enhanced user feedback for both immediate and scheduled notifications, improving overall user experience.
2025-11-03 23:11:27 -03:00
e59d96735a refactor: enhance notification management UI and improve data handling
- Refactored the notification management component to improve data extraction from queries, ensuring robust handling of loading states and errors.
- Introduced a modal for creating new templates, including validation and user authentication checks.
- Enhanced the notification sending logic to support bulk sending and provide detailed feedback on the sending process.
- Improved UI elements for better user experience, including loading indicators and dynamic user selection options.
2025-11-03 23:04:31 -03:00
35ff55822d login broken usuario 2025-11-03 17:01:19 -03:00
0d011b8f42 refactor: enhance role management UI and integrate profile management features
- Introduced a modal for managing user profiles, allowing for the creation and editing of profiles with improved state management.
- Updated the role filtering logic to enhance type safety and readability.
- Refactored UI components for better user experience, including improved button states and loading indicators.
- Removed outdated code related to permissions and streamlined the overall structure for maintainability.
2025-11-03 15:14:33 -03:00
c1d9958c9f refactor: update user role management and enhance UI components
- Updated the user role management logic to improve type safety and error handling, including better handling of role permissions and user associations.
- Refactored the UI components for user management, enhancing the layout and styling for better user experience.
- Removed outdated code related to menu permissions and streamlined the database schema for roles and profiles.
- Improved the overall structure and readability of the codebase, ensuring consistency across components.
2025-11-03 15:12:10 -03:00
5cb63f9437 refactor: improve type safety and error handling in vacation management components
- Updated the `AprovarFerias.svelte` component to use specific types for `solicitacao` and `gestorId`, enhancing type safety.
- Improved error handling by refining catch blocks to handle errors more accurately.
- Made minor adjustments to ensure consistent code formatting and readability across the component.
2025-10-31 13:39:41 -03:00
5dec7d7da7 refactor: optimize user seeding and database cleanup processes
- Streamlined user seeding logic by implementing a variable for the TI Master user ID and updating the admin user matricula.
- Enhanced the database cleanup function with improved logging and a more organized deletion process for various entities.
2025-10-31 11:08:58 -03:00
875b2ef201 refactor: update user seeding logic and enhance database cleanup process
- Modified user seeding logic to use a variable for the TI Master user ID and updated the admin user matricula.
- Improved the database cleanup function by adding detailed logging and restructuring the deletion process for various entities, ensuring a more organized and comprehensive cleanup.
2025-10-31 10:14:09 -03:00
f1b9860310 change dependencies 2025-10-31 08:44:11 -03:00
5469c50d90 feat: add svelte-sonner dependency and enhance NotificationBell component
- Added `svelte-sonner` to dependencies for improved notification handling.
- Refactored the `NotificationBell.svelte` component for better readability and maintainability, including code formatting and structure improvements.
- Updated `package.json` and `bun.lock` to reflect the new dependency.
2025-10-30 14:55:51 -03:00
Kilder Costa
bf67faa470 Merge pull request #5 from killer-cf/feat-ajuste-acesso
Feat ajuste acesso
2025-10-30 14:02:19 -03:00
1b751efc5e Remove outdated documentation files related to employee association, email notifications, vacation management, and monitoring system. This cleanup enhances project maintainability and reduces clutter in the repository. 2025-10-30 14:01:16 -03:00
ff9ca523cd Merge remote-tracking branch 'origin/master' into feat-ajuste-acesso 2025-10-30 14:01:08 -03:00
Kilder Costa
726004dd73 Merge pull request #4 from killer-cf/ajustes-cad_func
Ajustes cad func
2025-10-30 13:43:03 -03:00
23bdaa184a Add monitoring features and alert configurations
- Introduced new system metrics tracking with the ability to save and retrieve metrics such as CPU usage, memory usage, and network latency.
- Added alert configuration functionality, allowing users to set thresholds for metrics and receive notifications via email or chat.
- Updated the sidebar component to include a new "Monitorar SGSE" card for real-time system monitoring.
- Enhanced the package dependencies with `papaparse` and `svelte-chartjs` for improved data handling and charting capabilities.
- Updated the schema to support new tables for system metrics and alert configurations.
2025-10-30 13:36:29 -03:00
2841a2349d refactor: remove unused authentication module and related dependencies; update package.json and bun.lock for improved dependency management; enhance access control UI with expanded resource management features 2025-10-30 12:34:14 -03:00
fd445e8246 feat: enhance vacation management system with new employee association functionality, improved email notification handling, and comprehensive documentation; update dependencies and UI components for better user experience 2025-10-30 09:27:10 -03:00
21b41121db refactor: remove outdated avatar and chat update documentation files; streamline project structure for improved maintainability 2025-10-30 09:25:53 -03:00
ef20d599eb feat: implement professional avatar system with 30 3D realistic avatars inspired by cinema; enhance upload functionality and user experience with instant updates and improved UI components 2025-10-30 02:16:50 -03:00
16bcd2ac25 feat: implement vacation management system with request approval, notification handling, and employee training tracking; enhance UI components for improved user experience 2025-10-29 22:05:29 -03:00
1058375a90 refactor: remove unused authentication files and dependencies; update package.json to streamline dependencies and improve project structure 2025-10-29 18:57:05 -03:00
251 changed files with 83335 additions and 20916 deletions

View File

@@ -0,0 +1,189 @@
You are a Svelte expert tasked to build components and utilities for Svelte developers. If you need documentation for anything related to Svelte you can invoke the tool `get_documentation` with one of the following paths:
<available-docs>
- title: Overview, use_cases: project setup, creating new svelte apps, scaffolding, cli tools, initializing projects, path: cli/overview
- title: Frequently asked questions, use_cases: project setup, initializing new svelte projects, troubleshooting cli installation, package manager configuration, path: cli/faq
- title: sv create, use_cases: project setup, starting new sveltekit app, initializing project, creating from playground, choosing project template, path: cli/sv-create
- title: sv add, use_cases: project setup, adding features to existing projects, integrating tools, testing setup, styling setup, authentication, database setup, deployment adapters, path: cli/sv-add
- title: sv check, use_cases: code quality, ci/cd pipelines, error checking, typescript projects, pre-commit hooks, finding unused css, accessibility auditing, production builds, path: cli/sv-check
- title: sv migrate, use_cases: migration, upgrading svelte versions, upgrading sveltekit versions, modernizing codebase, svelte 3 to 4, svelte 4 to 5, sveltekit 1 to 2, adopting runes, refactoring deprecated apis, path: cli/sv-migrate
- title: devtools-json, use_cases: development setup, chrome devtools integration, browser-based editing, local development workflow, debugging setup, path: cli/devtools-json
- title: drizzle, use_cases: database setup, sql queries, orm integration, data modeling, postgresql, mysql, sqlite, server-side data access, database migrations, type-safe queries, path: cli/drizzle
- title: eslint, use_cases: code quality, linting, error detection, project setup, code standards, team collaboration, typescript projects, path: cli/eslint
- title: lucia, use_cases: authentication, login systems, user management, registration pages, session handling, auth setup, path: cli/lucia
- title: mcp, use_cases: use title and path to estimate use case, path: cli/mcp
- title: mdsvex, use_cases: blog, content sites, markdown rendering, documentation sites, technical writing, cms integration, article pages, path: cli/mdsvex
- title: paraglide, use_cases: internationalization, multi-language sites, i18n, translation, localization, language switching, global apps, multilingual content, path: cli/paraglide
- title: playwright, use_cases: browser testing, e2e testing, integration testing, test automation, quality assurance, ci/cd pipelines, testing user flows, path: cli/playwright
- title: prettier, use_cases: code formatting, project setup, code style consistency, team collaboration, linting configuration, path: cli/prettier
- title: storybook, use_cases: component development, design systems, ui library, isolated component testing, documentation, visual testing, component showcase, path: cli/storybook
- title: sveltekit-adapter, use_cases: deployment, production builds, hosting setup, choosing deployment platform, configuring adapters, static site generation, node server, vercel, cloudflare, netlify, path: cli/sveltekit-adapter
- title: tailwindcss, use_cases: project setup, styling, css framework, rapid prototyping, utility-first css, design systems, responsive design, adding tailwind to svelte, path: cli/tailwind
- title: vitest, use_cases: testing, unit tests, component testing, test setup, quality assurance, ci/cd pipelines, test-driven development, path: cli/vitest
- title: Introduction, use_cases: learning sveltekit, project setup, understanding framework basics, choosing between svelte and sveltekit, getting started with full-stack apps, path: kit/introduction
- title: Creating a project, use_cases: project setup, starting new sveltekit app, initial development environment, first-time sveltekit users, scaffolding projects, path: kit/creating-a-project
- title: Project types, use_cases: deployment, project setup, choosing adapters, ssg, spa, ssr, serverless, mobile apps, desktop apps, pwa, offline apps, browser extensions, separate backend, docker containers, path: kit/project-types
- title: Project structure, use_cases: project setup, understanding file structure, organizing code, starting new project, learning sveltekit basics, path: kit/project-structure
- title: Web standards, use_cases: always, any sveltekit project, data fetching, forms, api routes, server-side rendering, deployment to various platforms, path: kit/web-standards
- title: Routing, use_cases: routing, navigation, multi-page apps, project setup, file structure, api endpoints, data loading, layouts, error pages, always, path: kit/routing
- title: Loading data, use_cases: data fetching, api calls, database queries, dynamic routes, page initialization, loading states, authentication checks, ssr data, form data, content rendering, path: kit/load
- title: Form actions, use_cases: forms, user input, data submission, authentication, login systems, user registration, progressive enhancement, validation errors, path: kit/form-actions
- title: Page options, use_cases: prerendering static sites, ssr configuration, spa setup, client-side rendering control, url trailing slash handling, adapter deployment config, build optimization, path: kit/page-options
- title: State management, use_cases: sveltekit, server-side rendering, ssr, state management, authentication, data persistence, load functions, context api, navigation, component lifecycle, path: kit/state-management
- title: Remote functions, use_cases: data fetching, server-side logic, database queries, type-safe client-server communication, forms, user input, mutations, authentication, crud operations, optimistic updates, path: kit/remote-functions
- title: Building your app, use_cases: production builds, deployment preparation, build process optimization, adapter configuration, preview before deployment, path: kit/building-your-app
- title: Adapters, use_cases: deployment, production builds, hosting setup, choosing deployment platform, configuring adapters, path: kit/adapters
- title: Zero-config deployments, use_cases: deployment, production builds, hosting setup, choosing deployment platform, ci/cd configuration, path: kit/adapter-auto
- title: Node servers, use_cases: deployment, production builds, node.js hosting, custom server setup, environment configuration, reverse proxy setup, docker deployment, systemd services, path: kit/adapter-node
- title: Static site generation, use_cases: static site generation, ssg, prerendering, deployment, github pages, spa mode, blogs, documentation sites, marketing sites, path: kit/adapter-static
- title: Single-page apps, use_cases: spa mode, single-page apps, client-only rendering, static hosting, mobile app wrappers, no server-side logic, adapter-static setup, fallback pages, path: kit/single-page-apps
- title: Cloudflare, use_cases: deployment, cloudflare workers, cloudflare pages, hosting setup, production builds, serverless deployment, edge computing, path: kit/adapter-cloudflare
- title: Cloudflare Workers, use_cases: deploying to cloudflare workers, cloudflare workers sites deployment, legacy cloudflare adapter, wrangler configuration, cloudflare platform bindings, path: kit/adapter-cloudflare-workers
- title: Netlify, use_cases: deployment, netlify hosting, production builds, serverless functions, edge functions, static site hosting, path: kit/adapter-netlify
- title: Vercel, use_cases: deployment, vercel hosting, production builds, serverless functions, edge functions, isr, image optimization, environment variables, path: kit/adapter-vercel
- title: Writing adapters, use_cases: custom deployment, building adapters, unsupported platforms, adapter development, custom hosting environments, path: kit/writing-adapters
- title: Advanced routing, use_cases: advanced routing, dynamic routes, file viewers, nested paths, custom 404 pages, url validation, route parameters, multi-level navigation, path: kit/advanced-routing
- title: Hooks, use_cases: authentication, logging, error tracking, request interception, api proxying, custom routing, internationalization, database initialization, middleware logic, session management, path: kit/hooks
- title: Errors, use_cases: error handling, custom error pages, 404 pages, api error responses, production error logging, error tracking, type-safe errors, path: kit/errors
- title: Link options, use_cases: routing, navigation, multi-page apps, performance optimization, link preloading, forms with get method, search functionality, focus management, scroll behavior, path: kit/link-options
- title: Service workers, use_cases: offline support, pwa, caching strategies, performance optimization, precaching assets, network resilience, progressive web apps, path: kit/service-workers
- title: Server-only modules, use_cases: api keys, environment variables, sensitive data protection, backend security, preventing data leaks, server-side code isolation, path: kit/server-only-modules
- title: Snapshots, use_cases: forms, user input, preserving form data, multi-step forms, navigation state, preventing data loss, textarea content, input fields, comment systems, surveys, path: kit/snapshots
- title: Shallow routing, use_cases: modals, dialogs, image galleries, overlays, history-driven ui, mobile-friendly navigation, photo viewers, lightboxes, drawer menus, path: kit/shallow-routing
- title: Observability, use_cases: performance monitoring, debugging, observability, tracing requests, production diagnostics, analyzing slow requests, finding bottlenecks, monitoring server-side operations, path: kit/observability
- title: Packaging, use_cases: building component libraries, publishing npm packages, creating reusable svelte components, library development, package distribution, path: kit/packaging
- title: Auth, use_cases: authentication, login systems, user management, session handling, jwt tokens, protected routes, user credentials, authorization checks, path: kit/auth
- title: Performance, use_cases: performance optimization, slow loading pages, production deployment, debugging performance issues, reducing bundle size, improving load times, path: kit/performance
- title: Icons, use_cases: icons, ui components, styling, css frameworks, tailwind, unocss, performance optimization, dependency management, path: kit/icons
- title: Images, use_cases: image optimization, responsive images, performance, hero images, product photos, galleries, cms integration, cdn setup, asset management, path: kit/images
- title: Accessibility, use_cases: always, any sveltekit project, screen reader support, keyboard navigation, multi-page apps, client-side routing, internationalization, multilingual sites, path: kit/accessibility
- title: SEO, use_cases: seo optimization, search engine ranking, content sites, blogs, marketing sites, public-facing apps, sitemaps, amp pages, meta tags, performance optimization, path: kit/seo
- title: Frequently asked questions, use_cases: troubleshooting package imports, library compatibility issues, client-side code execution, external api integration, middleware setup, database configuration, view transitions, yarn configuration, path: kit/faq
- title: Integrations, use_cases: project setup, css preprocessors, postcss, scss, sass, less, stylus, typescript setup, adding integrations, tailwind, testing, auth, linting, formatting, path: kit/integrations
- title: Breakpoint Debugging, use_cases: debugging, breakpoints, development workflow, troubleshooting issues, vscode setup, ide configuration, inspecting code execution, path: kit/debugging
- title: Migrating to SvelteKit v2, use_cases: migration, upgrading from sveltekit 1 to 2, breaking changes, version updates, path: kit/migrating-to-sveltekit-2
- title: Migrating from Sapper, use_cases: migrating from sapper, upgrading legacy projects, sapper to sveltekit conversion, project modernization, path: kit/migrating
- title: Additional resources, use_cases: troubleshooting, getting help, finding examples, learning sveltekit, project templates, common issues, community support, path: kit/additional-resources
- title: Glossary, use_cases: rendering strategies, performance optimization, deployment configuration, seo requirements, static sites, spas, server-side rendering, prerendering, edge deployment, pwa development, path: kit/glossary
- title: @sveltejs/kit, use_cases: forms, form actions, server-side validation, form submission, error handling, redirects, json responses, http errors, server utilities, path: kit/@sveltejs-kit
- title: @sveltejs/kit/hooks, use_cases: middleware, request processing, authentication chains, logging, multiple hooks, request/response transformation, path: kit/@sveltejs-kit-hooks
- title: @sveltejs/kit/node/polyfills, use_cases: node.js environments, custom servers, non-standard runtimes, ssr setup, web api compatibility, polyfill requirements, path: kit/@sveltejs-kit-node-polyfills
- title: @sveltejs/kit/node, use_cases: node.js adapter, custom server setup, http integration, streaming files, node deployment, server-side rendering with node, path: kit/@sveltejs-kit-node
- title: @sveltejs/kit/vite, use_cases: project setup, vite configuration, initial sveltekit setup, build tooling, path: kit/@sveltejs-kit-vite
- title: $app/environment, use_cases: always, conditional logic, client-side code, server-side code, build-time logic, prerendering, development vs production, environment detection, path: kit/$app-environment
- title: $app/forms, use_cases: forms, user input, data submission, progressive enhancement, custom form handling, form validation, path: kit/$app-forms
- title: $app/navigation, use_cases: routing, navigation, multi-page apps, programmatic navigation, data reloading, preloading, shallow routing, navigation lifecycle, scroll handling, view transitions, path: kit/$app-navigation
- title: $app/paths, use_cases: static assets, images, fonts, public files, base path configuration, subdirectory deployment, cdn setup, asset urls, links, navigation, path: kit/$app-paths
- title: $app/server, use_cases: remote functions, server-side logic, data fetching, form handling, api endpoints, client-server communication, prerendering, file reading, batch queries, path: kit/$app-server
- title: $app/state, use_cases: routing, navigation, multi-page apps, loading states, url parameters, form handling, error states, version updates, page metadata, shallow routing, path: kit/$app-state
- title: $app/stores, use_cases: legacy projects, sveltekit pre-2.12, migration from stores to runes, maintaining older codebases, accessing page data, navigation state, app version updates, path: kit/$app-stores
- title: $app/types, use_cases: routing, navigation, type safety, route parameters, dynamic routes, link generation, pathname validation, multi-page apps, path: kit/$app-types
- title: $env/dynamic/private, use_cases: api keys, secrets management, server-side config, environment variables, backend logic, deployment-specific settings, private data handling, path: kit/$env-dynamic-private
- title: $env/dynamic/public, use_cases: environment variables, client-side config, runtime configuration, public api keys, deployment-specific settings, multi-environment apps, path: kit/$env-dynamic-public
- title: $env/static/private, use_cases: server-side api keys, backend secrets, database credentials, private configuration, build-time optimization, server endpoints, authentication tokens, path: kit/$env-static-private
- title: $env/static/public, use_cases: environment variables, public config, client-side data, api endpoints, build-time configuration, public constants, path: kit/$env-static-public
- title: $lib, use_cases: project setup, component organization, importing shared components, reusable ui elements, code structure, path: kit/$lib
- title: $service-worker, use_cases: offline support, pwa, service workers, caching strategies, progressive web apps, offline-first apps, path: kit/$service-worker
- title: Configuration, use_cases: project setup, configuration, adapters, deployment, build settings, environment variables, routing customization, prerendering, csp security, csrf protection, path configuration, typescript setup, path: kit/configuration
- title: Command Line Interface, use_cases: project setup, typescript configuration, generated types, ./$types imports, initial project configuration, path: kit/cli
- title: Types, use_cases: typescript, type safety, route parameters, api endpoints, load functions, form actions, generated types, jsconfig setup, path: kit/types
- title: Overview, use_cases: use title and path to estimate use case, path: mcp/overview
- title: Local setup, use_cases: use title and path to estimate use case, path: mcp/local-setup
- title: Remote setup, use_cases: use title and path to estimate use case, path: mcp/remote-setup
- title: Tools, use_cases: use title and path to estimate use case, path: mcp/tools
- title: Resources, use_cases: use title and path to estimate use case, path: mcp/resources
- title: Prompts, use_cases: use title and path to estimate use case, path: mcp/prompts
- title: Overview, use_cases: always, any svelte project, getting started, learning svelte, introduction, project setup, understanding framework basics, path: svelte/overview
- title: Getting started, use_cases: project setup, starting new svelte project, initial installation, choosing between sveltekit and vite, editor configuration, path: svelte/getting-started
- title: .svelte files, use_cases: always, any svelte project, component creation, project setup, learning svelte basics, path: svelte/svelte-files
- title: .svelte.js and .svelte.ts files, use_cases: shared reactive state, reusable reactive logic, state management across components, global stores, custom reactive utilities, path: svelte/svelte-js-files
- title: What are runes?, use_cases: always, any svelte 5 project, understanding core syntax, learning svelte 5, migration from svelte 4, path: svelte/what-are-runes
- title: $state, use_cases: always, any svelte project, core reactivity, state management, counters, forms, todo apps, interactive ui, data updates, class-based components, path: svelte/$state
- title: $derived, use_cases: always, any svelte project, computed values, reactive calculations, derived data, transforming state, dependent values, path: svelte/$derived
- title: $effect, use_cases: canvas drawing, third-party library integration, dom manipulation, side effects, intervals, timers, network requests, analytics tracking, path: svelte/$effect
- title: $props, use_cases: always, any svelte project, passing data to components, component communication, reusable components, component props, path: svelte/$props
- title: $bindable, use_cases: forms, user input, two-way data binding, custom input components, parent-child communication, reusable form fields, path: svelte/$bindable
- title: $inspect, use_cases: debugging, development, tracking state changes, reactive state monitoring, troubleshooting reactivity issues, path: svelte/$inspect
- title: $host, use_cases: custom elements, web components, dispatching custom events, component library, framework-agnostic components, path: svelte/$host
- title: Basic markup, use_cases: always, any svelte project, basic markup, html templating, component structure, attributes, events, props, text rendering, path: svelte/basic-markup
- title: {#if ...}, use_cases: always, conditional rendering, showing/hiding content, dynamic ui, user permissions, loading states, error handling, form validation, path: svelte/if
- title: {#each ...}, use_cases: always, lists, arrays, iteration, product listings, todos, tables, grids, dynamic content, shopping carts, user lists, comments, feeds, path: svelte/each
- title: {#key ...}, use_cases: animations, transitions, component reinitialization, forcing component remount, value-based ui updates, resetting component state, path: svelte/key
- title: {#await ...}, use_cases: async data fetching, api calls, loading states, promises, error handling, lazy loading components, dynamic imports, path: svelte/await
- title: {#snippet ...}, use_cases: reusable markup, component composition, passing content to components, table rows, list items, conditional rendering, reducing duplication, path: svelte/snippet
- title: {@render ...}, use_cases: reusable ui patterns, component composition, conditional rendering, fallback content, layout components, slot alternatives, template reuse, path: svelte/@render
- title: {@html ...}, use_cases: rendering html strings, cms content, rich text editors, markdown to html, blog posts, wysiwyg output, sanitized html injection, dynamic html content, path: svelte/@html
- title: {@attach ...}, use_cases: tooltips, popovers, dom manipulation, third-party libraries, canvas drawing, element lifecycle, interactive ui, custom directives, wrapper components, path: svelte/@attach
- title: {@const ...}, use_cases: computed values in loops, derived calculations in blocks, local variables in each iterations, complex list rendering, path: svelte/@const
- title: {@debug ...}, use_cases: debugging, development, troubleshooting, tracking state changes, monitoring variables, reactive data inspection, path: svelte/@debug
- title: bind:, use_cases: forms, user input, two-way data binding, interactive ui, media players, file uploads, checkboxes, radio buttons, select dropdowns, contenteditable, dimension tracking, path: svelte/bind
- title: use:, use_cases: custom directives, dom manipulation, third-party library integration, tooltips, click outside, gestures, focus management, element lifecycle hooks, path: svelte/use
- title: transition:, use_cases: animations, interactive ui, modals, dropdowns, notifications, conditional content, show/hide elements, smooth state changes, path: svelte/transition
- title: in: and out:, use_cases: animation, transitions, interactive ui, conditional rendering, independent enter/exit effects, modals, tooltips, notifications, path: svelte/in-and-out
- title: animate:, use_cases: sortable lists, drag and drop, reorderable items, todo lists, kanban boards, playlist editors, priority queues, animated list reordering, path: svelte/animate
- title: style:, use_cases: dynamic styling, conditional styles, theming, dark mode, responsive design, interactive ui, component styling, path: svelte/style
- title: class, use_cases: always, conditional styling, dynamic classes, tailwind css, component styling, reusable components, responsive design, path: svelte/class
- title: await, use_cases: async data fetching, loading states, server-side rendering, awaiting promises in components, async validation, concurrent data loading, path: svelte/await-expressions
- title: Scoped styles, use_cases: always, styling components, scoped css, component-specific styles, preventing style conflicts, animations, keyframes, path: svelte/scoped-styles
- title: Global styles, use_cases: global styles, third-party libraries, css resets, animations, styling body/html, overriding component styles, shared keyframes, base styles, path: svelte/global-styles
- title: Custom properties, use_cases: theming, custom styling, reusable components, design systems, dynamic colors, component libraries, ui customization, path: svelte/custom-properties
- title: Nested <style> elements, use_cases: component styling, scoped styles, dynamic styles, conditional styling, nested style tags, custom styling logic, path: svelte/nested-style-elements
- title: <svelte:boundary>, use_cases: error handling, async data loading, loading states, error recovery, flaky components, error reporting, resilient ui, path: svelte/svelte-boundary
- title: <svelte:window>, use_cases: keyboard shortcuts, scroll tracking, window resize handling, responsive layouts, online/offline detection, viewport dimensions, global event listeners, path: svelte/svelte-window
- title: <svelte:document>, use_cases: document events, visibility tracking, fullscreen detection, pointer lock, focus management, document-level interactions, path: svelte/svelte-document
- title: <svelte:body>, use_cases: mouse tracking, hover effects, cursor interactions, global body events, drag and drop, custom cursors, interactive backgrounds, body-level actions, path: svelte/svelte-body
- title: <svelte:head>, use_cases: seo optimization, page titles, meta tags, social media sharing, dynamic head content, multi-page apps, blog posts, product pages, path: svelte/svelte-head
- title: <svelte:element>, use_cases: dynamic content, cms integration, user-generated content, configurable ui, runtime element selection, flexible components, path: svelte/svelte-element
- title: <svelte:options>, use_cases: migration, custom elements, web components, legacy mode compatibility, runes mode setup, svg components, mathml components, css injection control, path: svelte/svelte-options
- title: Stores, use_cases: shared state, cross-component data, reactive values, async data streams, manual control over updates, rxjs integration, extracting logic, path: svelte/stores
- title: Context, use_cases: shared state, avoiding prop drilling, component communication, theme providers, user context, authentication state, configuration sharing, deeply nested components, path: svelte/context
- title: Lifecycle hooks, use_cases: component initialization, cleanup tasks, timers, subscriptions, dom measurements, chat windows, autoscroll features, migration from svelte 4, path: svelte/lifecycle-hooks
- title: Imperative component API, use_cases: project setup, client-side rendering, server-side rendering, ssr, hydration, testing, programmatic component creation, tooltips, dynamic mounting, path: svelte/imperative-component-api
- title: Testing, use_cases: testing, quality assurance, unit tests, integration tests, component tests, e2e tests, vitest setup, playwright setup, test automation, path: svelte/testing
- title: TypeScript, use_cases: typescript setup, type safety, component props typing, generic components, wrapper components, dom type augmentation, project configuration, path: svelte/typescript
- title: Custom elements, use_cases: web components, custom elements, component library, design system, framework-agnostic components, embedding svelte in non-svelte apps, shadow dom, path: svelte/custom-elements
- title: Svelte 4 migration guide, use_cases: upgrading svelte 3 to 4, version migration, updating dependencies, breaking changes, legacy project maintenance, path: svelte/v4-migration-guide
- title: Svelte 5 migration guide, use_cases: migrating from svelte 4 to 5, upgrading projects, learning svelte 5 syntax changes, runes migration, event handler updates, path: svelte/v5-migration-guide
- title: Frequently asked questions, use_cases: getting started, learning svelte, beginner setup, project initialization, vs code setup, formatting, testing, routing, mobile apps, troubleshooting, community support, path: svelte/faq
- title: svelte, use_cases: migration from svelte 4 to 5, upgrading legacy code, component lifecycle hooks, context api, mounting components, event dispatchers, typescript component types, path: svelte/svelte
- title: svelte/action, use_cases: typescript types, actions, use directive, dom manipulation, element lifecycle, custom behaviors, third-party library integration, path: svelte/svelte-action
- title: svelte/animate, use_cases: animated lists, sortable items, drag and drop, reordering elements, todo lists, kanban boards, playlist management, smooth position transitions, path: svelte/svelte-animate
- title: svelte/attachments, use_cases: library development, component libraries, programmatic element manipulation, migrating from actions to attachments, spreading props onto elements, path: svelte/svelte-attachments
- title: svelte/compiler, use_cases: build tools, custom compilers, ast manipulation, preprocessors, code transformation, migration scripts, syntax analysis, bundler plugins, dev tools, path: svelte/svelte-compiler
- title: svelte/easing, use_cases: animations, transitions, custom easing, smooth motion, interactive ui, modals, dropdowns, carousels, page transitions, scroll effects, path: svelte/svelte-easing
- title: svelte/events, use_cases: window events, document events, global event listeners, event delegation, programmatic event handling, cleanup functions, media queries, path: svelte/svelte-events
- title: svelte/legacy, use_cases: migration from svelte 4 to svelte 5, upgrading legacy code, event modifiers, class components, imperative component instantiation, path: svelte/svelte-legacy
- title: svelte/motion, use_cases: animation, smooth transitions, interactive ui, sliders, counters, physics-based motion, drag gestures, accessibility, reduced motion, path: svelte/svelte-motion
- title: svelte/reactivity/window, use_cases: responsive design, viewport tracking, scroll effects, window resize handling, online/offline detection, zoom level tracking, path: svelte/svelte-reactivity-window
- title: svelte/reactivity, use_cases: reactive data structures, state management with maps/sets, game boards, selection tracking, url manipulation, query params, real-time clocks, media queries, responsive design, path: svelte/svelte-reactivity
- title: svelte/server, use_cases: server-side rendering, ssr, static site generation, seo optimization, initial page load, pre-rendering, node.js server, custom server setup, path: svelte/svelte-server
- title: svelte/store, use_cases: state management, shared data, reactive stores, cross-component communication, global state, computed values, data synchronization, legacy svelte projects, path: svelte/svelte-store
- title: svelte/transition, use_cases: animations, transitions, interactive ui, modals, dropdowns, tooltips, notifications, svg animations, list animations, page transitions, path: svelte/svelte-transition
- title: Compiler errors, use_cases: animation, transitions, keyed each blocks, list animations, path: svelte/compiler-errors
- title: Compiler warnings, use_cases: accessibility, a11y compliance, wcag standards, screen readers, keyboard navigation, aria attributes, semantic html, interactive elements, path: svelte/compiler-warnings
- title: Runtime errors, use_cases: debugging errors, error handling, troubleshooting runtime issues, migration to svelte 5, component binding, effects and reactivity, path: svelte/runtime-errors
- title: Runtime warnings, use_cases: debugging state proxies, console logging reactive values, inspecting state changes, development troubleshooting, path: svelte/runtime-warnings
- title: Overview, use_cases: migrating from svelte 3/4 to svelte 5, maintaining legacy components, understanding deprecated features, gradual upgrade process, path: svelte/legacy-overview
- title: Reactive let/var declarations, use_cases: migration, legacy svelte projects, upgrading from svelte 4, understanding old reactivity, maintaining existing code, learning runes differences, path: svelte/legacy-let
- title: Reactive $: statements, use_cases: legacy mode, migration from svelte 4, reactive statements, computed values, derived state, side effects, path: svelte/legacy-reactive-assignments
- title: export let, use_cases: legacy mode, migration from svelte 4, maintaining older projects, component props without runes, exporting component methods, renaming reserved word props, path: svelte/legacy-export-let
- title: $$props and $$restProps, use_cases: legacy mode migration, component wrappers, prop forwarding, button components, reusable ui components, spreading props to child elements, path: svelte/legacy-$$props-and-$$restProps
- title: on:, use_cases: legacy mode, event handling, button clicks, forms, user interactions, component communication, event forwarding, event modifiers, path: svelte/legacy-on
- title: <slot>, use_cases: legacy mode, migrating from svelte 4, component composition, reusable components, passing content to components, modals, layouts, wrappers, path: svelte/legacy-slots
- title: $$slots, use_cases: legacy mode, conditional slot rendering, optional content sections, checking if slots provided, migrating from legacy to runes, path: svelte/legacy-$$slots
- title: <svelte:fragment>, use_cases: named slots, component composition, layout systems, avoiding wrapper divs, legacy svelte projects, slot content organization, path: svelte/legacy-svelte-fragment
- title: <svelte:component>, use_cases: dynamic components, component switching, conditional rendering, legacy mode migration, tabbed interfaces, multi-step forms, path: svelte/legacy-svelte-component
- title: <svelte:self>, use_cases: recursive components, tree structures, nested menus, file explorers, comment threads, hierarchical data, path: svelte/legacy-svelte-self
- title: Imperative component API, use_cases: migration from svelte 3/4 to 5, legacy component api, maintaining old projects, understanding deprecated patterns, path: svelte/legacy-component-api
</available-docs>
Every time you write a Svelte component or a Svelte module you MUST invoke the `svelte-autofixer` tool providing the code. The tool will return a list of issues or suggestions. If there are any issues or suggestions you MUST fix them and call the tool again with the updated code. You MUST keep doing this until the tool returns no issues or suggestions. Only then you can return the code to the user.
This is the task you will work on:
<task>
[YOUR TASK HERE]
</task>
If you are not writing the code into a file, once you have the final version of the code ask the user if it wants to generate a playground link to quickly check the code in it and if it answer yes call the `playground-link` tool and return the url to the user nicely formatted. The playground link MUST be generated only once you have the final version of the code and you are ready to share it, it MUST include an entry point file called `App.svelte` where the main component should live. If you have multiple files to include in the playground link you can include them all at the root.

19
.cursor/mcp.json Normal file
View File

@@ -0,0 +1,19 @@
{
"mcpServers": {
"svelte": {
"url": "https://mcp.svelte.dev/mcp"
},
"context7": {
"url": "https://mcp.context7.com/mcp"
},
"convex": {
"command": "npx",
"args": [
"-y",
"convex@latest",
"mcp",
"start"
]
}
}
}

View File

@@ -0,0 +1,27 @@
---
alwaysApply: true
---
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 issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 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

@@ -0,0 +1,117 @@
---
description: Guidelines for TypeScript usage, including type safety rules and Convex query typing
globs: **/*.ts,**/*.svelte
alwaysApply: false
---
# TypeScript Guidelines
## Type Safety Rules
### Avoid `any` Type
- **NEVER** use the `any` type in production code
- The only exception is in test files (files matching `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
- Instead of `any`, use:
- Proper type definitions
- `unknown` for truly unknown types (with type guards)
- Generic types (`<T>`) when appropriate
- Union types when multiple types are possible
- `Record<string, unknown>` for objects with unknown structure
### Examples
**❌ Bad:**
```typescript
function processData(data: any) {
return data.value;
}
```
**✅ Good:**
```typescript
function processData(data: { value: string }) {
return data.value;
}
// Or with generics
function processData<T extends { value: unknown }>(data: T) {
return data.value;
}
// Or with unknown and type guards
function processData(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: string }).value;
}
throw new Error('Invalid data');
}
```
**✅ Exception (tests only):**
```typescript
// test.ts or *.spec.ts
it('should handle any input', () => {
const input: any = getMockData();
expect(process(input)).toBeDefined();
});
```
## Convex Query Typing
### Frontend Query Usage
- **DO NOT** create manual type definitions for Convex query results in the frontend
- Convex queries already return properly typed results based on their `returns` validator
- The TypeScript types are automatically inferred from the query's return validator
- Simply use the query result directly - TypeScript will infer the correct type
### Examples
**❌ Bad:**
```typescript
// Don't manually type the result
type UserListResult = Array<{
_id: Id<'users'>;
name: string;
}>;
const users: UserListResult = useQuery(api.users.list);
```
**✅ Good:**
```typescript
// Let TypeScript infer the type from the query
const users = useQuery(api.users.list);
// TypeScript automatically knows the type based on the query's returns validator
// You can still use it with type inference
if (users !== undefined) {
users.forEach((user) => {
// TypeScript knows user._id is Id<"users"> and user.name is string
console.log(user.name);
});
}
```
**✅ Good (with explicit type if needed for clarity):**
```typescript
// Only if you need to export or explicitly annotate for documentation
import type { FunctionReturnType } from 'convex/server';
import type { api } from './convex/_generated/api';
type UserListResult = FunctionReturnType<typeof api.users.list>;
const users = useQuery(api.users.list);
```
### Best Practices
- Trust Convex's type inference - it's based on your schema and validators
- If you need type annotations, use `FunctionReturnType` from Convex's type utilities
- Only create manual types if you're doing complex transformations that need intermediate types

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

3
.gitignore vendored
View File

@@ -47,4 +47,5 @@ coverage
*.tgz
.cache
tmp
temp
temp
.eslintcache

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb

18
.prettierrc Normal file
View File

@@ -0,0 +1,18 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

View File

@@ -1 +1 @@
nodejs 25.0.0
nodejs 22.21.1

29
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,29 @@
{
// "editor.formatOnSave": true,
// "editor.defaultFormatter": "biomejs.biome",
// "editor.codeActionsOnSave": {
// "source.fixAll.biome": "always"
// },
// "[typescript]": {
// "editor.defaultFormatter": "biomejs.biome"
// },
// "[svelte]": {
// "editor.defaultFormatter": "biomejs.biome"
// },
"eslint.useFlatConfig": true,
"eslint.workingDirectories": [
{ "pattern": "apps/*" },
{ "pattern": "packages/*" }
],
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"svelte"
],
"eslint.options": {
"cache": true,
"cacheLocation": ".eslintcache"
}
}

23
AGENTS.md Normal file
View File

@@ -0,0 +1,23 @@
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 issues and suggestions.
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
### 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

@@ -126,8 +126,8 @@ npx convex dev
### **Banco vazio:**
```powershell
cd packages\backend
npx convex run seed:clearDatabase
npx convex run seed:seedDatabase
npx convex run seed:limparBanco
npx convex run seed:popularBanco
```
**Mais soluções:** Veja `TESTAR_SISTEMA_COMPLETO.md` seção "Problemas Comuns"

View File

@@ -0,0 +1,29 @@
# ⚙️ Configuração de Variáveis de Ambiente
## 📁 Arquivo .env
Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis:
```env
# Google Maps API Key (opcional)
# Obtenha sua chave em: https://console.cloud.google.com/
# Ative a "Geocoding API" para buscar coordenadas por endereço
# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave)
VITE_GOOGLE_MAPS_API_KEY=
# VAPID Public Key para Push Notifications (opcional)
VITE_VAPID_PUBLIC_KEY=
```
## 📖 Documentação Completa
Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte:
📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)**
## ⚠️ Importante
- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore)
- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend
- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env`

View File

@@ -0,0 +1,174 @@
# 📍 Configuração do Google Maps API para Busca de Coordenadas
Este guia explica como configurar a API do Google Maps para obter coordenadas GPS de forma automática e precisa no sistema de Endereços de Marcação.
## 🎯 Por que usar Google Maps?
-**Maior Precisão**: Resultados mais exatos para endereços brasileiros
-**Melhor Cobertura**: Banco de dados mais completo e atualizado
-**Geocoding Avançado**: Entende melhor endereços incompletos ou parciais
> **Nota**: O sistema funciona perfeitamente sem a API key do Google Maps, usando OpenStreetMap (gratuito). A configuração do Google Maps é opcional.
---
## 📋 Passo a Passo
### 1. Criar Projeto no Google Cloud Platform
1. Acesse [Google Cloud Console](https://console.cloud.google.com/)
2. Clique em **"Criar Projeto"** ou selecione um projeto existente
3. Preencha o nome do projeto (ex: "SGSE-App")
4. Clique em **"Criar"**
### 2. Ativar a Geocoding API
1. No menu lateral, vá em **"APIs e Serviços"** > **"Biblioteca"**
2. Procure por **"Geocoding API"**
3. Clique no resultado e depois em **"Ativar"**
4. Aguarde alguns segundos para a ativação
### 3. Criar Chave de API
1. Ainda em **"APIs e Serviços"**, vá em **"Credenciais"**
2. Clique em **"Criar Credenciais"** > **"Chave de API"**
3. Copie a chave gerada (você precisará dela depois)
### 4. Configurar Restrições de Segurança (Recomendado)
Para proteger sua chave de API:
1. Clique na chave criada para editá-la
2. Em **"Restrições de API"**:
- Selecione **"Restringir chave"**
- Escolha **"Geocoding API"**
3. Em **"Restrições de aplicativo"**:
- Para desenvolvimento local: escolha **"Referenciadores de sites HTTP"**
- Adicione: `http://localhost:*` e `http://127.0.0.1:*`
- Para produção: adicione o domínio do seu site
4. Clique em **"Salvar"**
### 5. Configurar no Projeto
1. No diretório `apps/web/`, copie o arquivo de exemplo:
```bash
cp .env.example .env
```
2. Abra o arquivo `.env` e adicione sua chave:
```env
VITE_GOOGLE_MAPS_API_KEY=sua_chave_aqui
```
3. Reinicie o servidor de desenvolvimento:
```bash
npm run dev
```
### 6. Verificar se está funcionando
1. Acesse a página de **Endereços de Marcação** (`/ti/configuracoes-ponto/enderecos`)
2. Clique em **"Novo Endereço"**
3. Preencha um endereço e clique em **"Buscar GPS"**
4. Se configurado corretamente, verá a mensagem: *"Coordenadas encontradas via Google Maps!"*
---
## 💰 Custos
### Google Maps Geocoding API
- **$5.00 por 1.000 requisições** (primeiros 40.000 são gratuitos por mês)
- **$0.005 por requisição** após os 40.000 gratuitos
> 💡 Para a maioria dos casos de uso, os 40.000 gratuitos são suficientes!
### OpenStreetMap (Fallback)
- **100% Gratuito** e ilimitado
- Sem necessidade de configuração
- Precisão levemente menor, mas ainda muito boa
---
## 🔄 Como funciona o sistema
O sistema foi projetado para usar uma estratégia de **fallback inteligente**:
1. **Primeiro**: Tenta buscar via Google Maps (se API key configurada)
2. **Se falhar ou não tiver API key**: Usa automaticamente OpenStreetMap
3. **Feedback**: Informa qual serviço foi usado na mensagem de sucesso
Isso garante que o sistema sempre funcione, mesmo sem a API key do Google Maps.
---
## 🔒 Segurança
### ⚠️ Importante
- **Nunca** commite o arquivo `.env` no Git (já está no .gitignore)
- **Nunca** compartilhe sua chave de API publicamente
- Configure **restrições de API** no Google Cloud Console
- Para produção, use variáveis de ambiente seguras no seu provedor de hospedagem
### Configuração em Produção
Para ambientes de produção (Vercel, Netlify, etc.):
1. Acesse as configurações do projeto no seu provedor
2. Vá em **"Environment Variables"** ou **"Variáveis de Ambiente"**
3. Adicione: `VITE_GOOGLE_MAPS_API_KEY` com o valor da sua chave
4. Faça o deploy novamente
---
## ❓ Solução de Problemas
### A busca não está usando Google Maps
- Verifique se a variável `VITE_GOOGLE_MAPS_API_KEY` está no arquivo `.env`
- Reinicie o servidor de desenvolvimento
- Verifique no console do navegador se há erros
### Erro: "This API project is not authorized to use this API"
- Verifique se a **Geocoding API** está ativada no projeto
- Aguarde alguns minutos após a ativação (pode levar até 5 minutos)
### Erro: "API key not valid"
- Verifique se copiou a chave corretamente
- Verifique se as restrições de API permitem o uso da Geocoding API
- Verifique se as restrições de aplicativo permitem seu domínio/endereço
### Mensagem: "Coordenadas encontradas via OpenStreetMap"
- Isso é normal se:
- Não há API key configurada
- A API key não é válida
- O Google Maps falhou na busca
- O sistema continua funcionando normalmente com OpenStreetMap
---
## 📚 Recursos Úteis
- [Google Cloud Console](https://console.cloud.google.com/)
- [Documentação Geocoding API](https://developers.google.com/maps/documentation/geocoding)
- [Preços Google Maps](https://developers.google.com/maps/billing-and-pricing/pricing)
- [OpenStreetMap Nominatim](https://nominatim.org/)
---
## ✅ Resumo
1. ✅ Crie projeto no Google Cloud
2. ✅ Ative Geocoding API
3. ✅ Crie chave de API
4. ✅ Configure restrições (recomendado)
5. ✅ Adicione `VITE_GOOGLE_MAPS_API_KEY` no `.env`
6. ✅ Reinicie o servidor
**Pronto!** O sistema agora usará Google Maps para busca de coordenadas com maior precisão.

28
apps/web/eslint.config.js Normal file
View File

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

View File

@@ -12,6 +12,7 @@
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@sgse-app/eslint-config": "*",
"@sveltejs/adapter-auto": "^6.1.0",
"@sveltejs/kit": "^2.31.1",
"@sveltejs/vite-plugin-svelte": "^6.1.2",
@@ -23,24 +24,35 @@
"svelte": "^5.38.1",
"svelte-check": "^4.3.1",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2",
"typescript": "catalog:",
"vite": "^7.1.2"
},
"dependencies": {
"@convex-dev/better-auth": "^0.9.6",
"eslint": "catalog:",
"@convex-dev/better-auth": "^0.9.7",
"@dicebear/collection": "^9.2.4",
"@dicebear/core": "^9.2.4",
"@fullcalendar/core": "^6.1.19",
"@fullcalendar/daygrid": "^6.1.19",
"@fullcalendar/interaction": "^6.1.19",
"@fullcalendar/list": "^6.1.19",
"@fullcalendar/multimonth": "^6.1.19",
"@internationalized/date": "^3.10.0",
"@mmailaender/convex-better-auth-svelte": "^0.2.0",
"@sgse-app/backend": "*",
"@tanstack/svelte-form": "^1.19.2",
"better-auth": "1.3.27",
"convex": "^1.28.0",
"convex-svelte": "^0.0.11",
"@types/papaparse": "^5.3.14",
"better-auth": "catalog:",
"convex": "catalog:",
"convex-svelte": "^0.0.12",
"date-fns": "^4.1.0",
"emoji-picker-element": "^1.27.0",
"is-network-error": "^1.3.0",
"jspdf": "^3.0.3",
"jspdf-autotable": "^5.0.2",
"zod": "^4.0.17"
"lucide-svelte": "^0.552.0",
"papaparse": "^5.4.1",
"svelte-sonner": "^1.0.5",
"zod": "^4.1.12"
}
}
}

View File

@@ -1,6 +1,8 @@
@import "tailwindcss";
@plugin "daisyui";
/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */
/* Estilo padrão dos botões - mesmo estilo do sidebar */
.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;
@@ -17,4 +19,59 @@
.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;
}
:where(.card, .card-hover) {
position: relative;
overflow: hidden;
transform: translateY(0);
transition: transform 220ms ease, box-shadow 220ms ease;
}
:where(.card, .card-hover)::before {
content: "";
position: absolute;
inset: -2px;
border-radius: 1.15rem;
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.04),
0 14px 32px -22px rgba(15, 23, 42, 0.45),
0 6px 18px -16px rgba(102, 126, 234, 0.35);
opacity: 0.55;
transition: opacity 220ms ease, transform 220ms ease;
pointer-events: none;
z-index: 0;
}
:where(.card, .card-hover)::after {
content: "";
position: absolute;
inset: 0;
border-radius: 1rem;
background: linear-gradient(135deg, rgba(102, 126, 234, 0.12), rgba(118, 75, 162, 0.12));
opacity: 0;
transform: scale(0.96);
transition: opacity 220ms ease, transform 220ms ease;
pointer-events: none;
z-index: 1;
}
:where(.card, .card-hover):hover {
transform: translateY(-6px);
box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.35);
}
:where(.card, .card-hover):hover::before {
opacity: 0.9;
transform: scale(1);
}
:where(.card, .card-hover):hover::after {
opacity: 1;
transform: scale(1);
}
:where(.card, .card-hover) > * {
position: relative;
z-index: 2;
}

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

@@ -1,13 +1,9 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
interface Locals {
token: string | undefined;
}
}
}
export {};

View File

@@ -1,9 +1,9 @@
import type { Handle } from "@sveltejs/kit";
// Middleware desabilitado - proteção de rotas feita no lado do cliente
// para compatibilidade com localStorage do authStore
import { createAuth } from "@sgse-app/backend/convex/auth";
import { getToken } from "@mmailaender/convex-better-auth-svelte/sveltekit";
export const handle: Handle = async ({ event, resolve }) => {
event.locals.token = await getToken(createAuth, event.cookies);
return resolve(event);
};

View File

@@ -1,7 +1,18 @@
import { createAuthClient } from "better-auth/client";
/**
* Cliente Better Auth para frontend SvelteKit
*
* Configurado para trabalhar com Convex via plugin convexClient.
* Este cliente será usado para autenticação quando Better Auth estiver ativo.
*/
import { createAuthClient } from "better-auth/svelte";
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({
baseURL: "http://localhost:5173",
// 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

@@ -0,0 +1,73 @@
<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,276 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
solicitacao: PeriodoFerias;
usuarioId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let processando = $state(false);
let erro = $state('');
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info',
EmFérias: 'badge-info',
Cancelado_RH: 'badge-error'
};
return badges[status] || 'badge-neutral';
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: 'Aguardando Aprovação',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
EmFérias: 'Em Férias',
Cancelado_RH: 'Cancelado RH'
};
return textos[status] || status;
}
async function cancelarPorRH() {
try {
processando = true;
erro = '';
await client.mutation(api.ferias.atualizarStatus, {
feriasId: solicitacao._id,
novoStatus: 'Cancelado_RH',
usuarioId: usuarioId
});
if (onSucesso) onSucesso();
} catch (e) {
erro = e instanceof Error ? e.message : String(e);
} finally {
processando = false;
}
}
function formatarData(data: number) {
return new Date(data).toLocaleString('pt-BR');
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-start justify-between">
<div>
<h2 class="card-title text-2xl">
{solicitacao.funcionario?.nome || 'Funcionário'}
</h2>
<p class="text-base-content/70 mt-1 text-sm">
Ano de Referência: {solicitacao.anoReferencia}
</p>
</div>
<div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
<!-- Período Solicitado -->
<div class="mt-4">
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3>
<div class="bg-base-200 rounded-lg p-4">
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{solicitacao.diasFerias}</span>
</div>
</div>
</div>
</div>
<!-- Observações -->
{#if solicitacao.observacao}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Observações</h3>
<div class="bg-base-200 rounded-lg p-3 text-sm">
{solicitacao.observacao}
</div>
</div>
{/if}
<!-- Histórico -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Histórico</h3>
<div class="space-y-1">
{#each solicitacao.historicoAlteracoes as hist (hist.data)}
<div class="text-base-content/70 flex items-center gap-2 text-xs">
<svg
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>-</span>
<span>{hist.acao}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Ação: Cancelar por RH -->
{#if solicitacao.status !== 'Cancelado_RH'}
<div class="divider mt-6"></div>
<div class="alert alert-warning">
<svg
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="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"
></path>
</svg>
<div>
<h3 class="font-bold">Cancelar Férias</h3>
<div class="text-sm">
Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não poderá mais ser processada.
</div>
</div>
</div>
<div class="card-actions mt-4 justify-end">
<button
type="button"
class="btn btn-error gap-2"
onclick={cancelarPorRH}
disabled={processando}
>
<svg
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>
Cancelar Férias (RH)
</button>
</div>
{:else}
<div class="divider mt-6"></div>
<div class="alert alert-error">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current h-6 w-6 shrink-0"
>
<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"
></path>
</svg>
<span>Esta solicitação já foi cancelada pelo RH.</span>
</div>
{/if}
<!-- Motivo Reprovação (se reprovado) -->
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
<div class="alert alert-error mt-4">
<svg
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 class="font-bold">Motivo da Reprovação:</div>
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
</div>
</div>
{/if}
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<svg
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>
</div>
{/if}
<!-- Botão Fechar -->
{#if onCancelar}
<div class="card-actions mt-4 justify-end">
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
Cancelar
</button>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,428 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import ErrorModal from './ErrorModal.svelte';
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
solicitacao: SolicitacaoAusencia;
gestorId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let motivoReprovacao = $state('');
let processando = $state(false);
let erro = $state('');
let mostrarModalErro = $state(false);
let mensagemErroModal = $state('');
function calcularDias(dataInicio: string, dataFim: string): number {
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
}
const totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim));
async function aprovar() {
try {
processando = true;
erro = '';
mostrarModalErro = false;
await client.mutation(api.ausencias.aprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId
});
if (onSucesso) onSucesso();
} catch (e) {
const mensagemErro = e instanceof Error ? e.message : String(e);
// Verificar se é erro de permissão
if (
mensagemErro.includes('permissão') ||
mensagemErro.includes('permission') ||
mensagemErro.includes('Você não tem permissão')
) {
mensagemErroModal =
'Você não tem permissão para aprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.';
mostrarModalErro = true;
} else {
erro = mensagemErro;
}
} finally {
processando = false;
}
}
async function reprovar() {
if (!motivoReprovacao.trim()) {
erro = 'Informe o motivo da reprovação';
return;
}
try {
processando = true;
erro = '';
mostrarModalErro = false;
await client.mutation(api.ausencias.reprovar, {
solicitacaoId: solicitacao._id,
gestorId: gestorId,
motivoReprovacao: motivoReprovacao.trim()
});
if (onSucesso) onSucesso();
} catch (e) {
const mensagemErro = e instanceof Error ? e.message : String(e);
// Verificar se é erro de permissão
if (
mensagemErro.includes('permissão') ||
mensagemErro.includes('permission') ||
mensagemErro.includes('Você não tem permissão')
) {
mensagemErroModal =
'Você não tem permissão para reprovar esta solicitação de ausência. Apenas o gestor responsável pelo time do funcionário pode realizar esta ação.';
mostrarModalErro = true;
} else {
erro = mensagemErro;
}
} finally {
processando = false;
}
}
function fecharModalErro() {
mostrarModalErro = false;
mensagemErroModal = '';
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error'
};
return badges[status] || 'badge-neutral';
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: 'Aguardando Aprovação',
aprovado: 'Aprovado',
reprovado: 'Reprovado'
};
return textos[status] || status;
}
</script>
<div class="aprovar-ausencia">
<!-- Header -->
<div class="mb-6">
<h2 class="text-primary mb-2 text-3xl font-bold">Aprovar/Reprovar Ausência</h2>
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
</div>
<!-- Card Principal -->
<div class="card bg-base-100 border-t-4 border-primary shadow-2xl">
<div class="card-body p-8">
<!-- Informações do Funcionário -->
<div class="mb-8">
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
<div class="rounded-lg bg-primary/10 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-6 w-6"
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>
</div>
Funcionário
</h3>
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<div class="rounded-xl bg-base-200/50 p-4 transition-all hover:bg-base-200">
<p class="mb-2 text-sm font-semibold uppercase tracking-wide text-base-content/60">
Nome
</p>
<p class="text-lg font-bold text-base-content">
{solicitacao.funcionario?.nome || 'N/A'}
</p>
</div>
{#if solicitacao.time}
<div class="rounded-xl bg-base-200/50 p-4 transition-all hover:bg-base-200">
<p class="mb-2 text-sm font-semibold uppercase tracking-wide text-base-content/60">
Time
</p>
<div
class="badge badge-lg font-semibold"
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
.cor}; color: {solicitacao.time.cor}"
>
{solicitacao.time.nome}
</div>
</div>
{/if}
</div>
</div>
<div class="divider my-6"></div>
<!-- Período da Ausência -->
<div class="mb-8">
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
<div class="rounded-lg bg-primary/10 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-6 w-6"
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>
</div>
Período da Ausência
</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div
class="stat rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
>
<div class="stat-title text-base-content/70">Data Início</div>
<div class="stat-value text-2xl text-primary">
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
</div>
</div>
<div
class="stat rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
>
<div class="stat-title text-base-content/70">Data Fim</div>
<div class="stat-value text-2xl text-primary">
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
</div>
</div>
<div
class="stat rounded-xl border-2 border-primary/30 bg-gradient-to-br from-primary/10 to-primary/15 shadow-md transition-all hover:border-primary/40 hover:shadow-lg"
>
<div class="stat-title text-base-content/70">Total de Dias</div>
<div class="stat-value text-3xl font-bold text-primary">
{totalDias}
</div>
<div class="stat-desc text-base-content/60">dias corridos</div>
</div>
</div>
</div>
<div class="divider my-6"></div>
<!-- Motivo -->
<div class="mb-8">
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
<div class="rounded-lg bg-primary/10 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-6 w-6"
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>
</div>
Motivo da Ausência
</h3>
<div class="card rounded-xl border-2 border-primary/10 bg-base-200/50 shadow-sm">
<div class="card-body p-5">
<p class="whitespace-pre-wrap leading-relaxed text-base-content">
{solicitacao.motivo}
</p>
</div>
</div>
</div>
<!-- Status Atual -->
<div class="mb-8 rounded-xl bg-base-200/30 p-4">
<div class="flex items-center gap-3">
<span class="text-sm font-semibold uppercase tracking-wide text-base-content/70"
>Status:</span
>
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
</div>
<!-- Erro -->
{#if erro}
<div class="alert alert-error mb-6 shadow-lg">
<svg
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>
</div>
{/if}
<!-- Ações -->
{#if solicitacao.status === 'aguardando_aprovacao'}
<div class="card-actions mt-8 justify-end gap-4">
<button
type="button"
class="btn btn-error btn-lg gap-2"
onclick={reprovar}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner"></span>
{:else}
<svg
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}
Reprovar
</button>
<button
type="button"
class="btn btn-success btn-lg gap-2"
onclick={aprovar}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner"></span>
{:else}
<svg
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}
Aprovar
</button>
</div>
<!-- Modal de Reprovação -->
{#if motivoReprovacao !== undefined}
<div class="mt-6 rounded-xl border-2 border-error/20 bg-error/5 p-5">
<div class="form-control">
<label class="label" for="motivo-reprovacao">
<span class="label-text font-bold text-error">Motivo da Reprovação</span>
</label>
<textarea
id="motivo-reprovacao"
class="textarea textarea-bordered h-24 focus:border-error focus:outline-error"
placeholder="Informe o motivo da reprovação..."
bind:value={motivoReprovacao}
></textarea>
</div>
</div>
{/if}
{:else}
<div class="alert alert-info shadow-lg">
<svg
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>
<span>Esta solicitação já foi processada.</span>
</div>
{/if}
<!-- Botão Cancelar -->
<div class="mt-6 text-center">
<button
type="button"
class="btn btn-ghost"
onclick={() => {
if (onCancelar) onCancelar();
}}
disabled={processando}
>
Fechar
</button>
</div>
</div>
</div>
</div>
<!-- Modal de Erro -->
<ErrorModal
open={mostrarModalErro}
title="Erro de Permissão"
message={mensagemErroModal || 'Você não tem permissão para realizar esta ação.'}
onClose={fecharModalErro}
/>
<style>
.aprovar-ausencia {
max-width: 900px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,521 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
periodo: PeriodoFerias;
gestorId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let modoAjuste = $state(false);
let novaDataInicio = $state(periodo.dataInicio);
let novaDataFim = $state(periodo.dataFim);
let motivoReprovacao = $state('');
let processando = $state(false);
let erro = $state('');
// Calcular dias do período ajustado
const diasAjustados = $derived.by(() => {
if (!novaDataInicio || !novaDataFim) return 0;
const inicio = new Date(novaDataInicio);
const fim = new Date(novaDataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
});
function calcularDias(dataInicio: string, dataFim: string): number {
if (!dataInicio || !dataFim) return 0;
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
if (fim < inicio) {
erro = 'Data final não pode ser anterior à data inicial';
return 0;
}
const diff = fim.getTime() - inicio.getTime();
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
erro = '';
return dias;
}
async function aprovar() {
try {
processando = true;
erro = '';
// Validar se as datas e condições estão dentro do regime do funcionário
if (!periodo.funcionario?._id) {
erro = 'Funcionário não encontrado';
processando = false;
return;
}
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
funcionarioId: periodo.funcionario._id,
anoReferencia: periodo.anoReferencia,
periodos: [{
dataInicio: periodo.dataInicio,
dataFim: periodo.dataFim
}]
});
if (!validacao.valido) {
erro = `Não é possível aprovar: ${validacao.erros.join('; ')}`;
processando = false;
return;
}
await client.mutation(api.ferias.aprovar, {
feriasId: periodo._id,
gestorId: gestorId
});
if (onSucesso) onSucesso();
} catch (e) {
erro = e instanceof Error ? e.message : String(e);
} finally {
processando = false;
}
}
async function reprovar() {
if (!motivoReprovacao.trim()) {
erro = 'Informe o motivo da reprovação';
return;
}
try {
processando = true;
erro = '';
await client.mutation(api.ferias.reprovar, {
feriasId: periodo._id,
gestorId: gestorId,
motivoReprovacao
});
if (onSucesso) onSucesso();
} catch (e) {
erro = e instanceof Error ? e.message : String(e);
} finally {
processando = false;
}
}
async function ajustarEAprovar() {
try {
processando = true;
erro = '';
// Validar se as datas ajustadas e condições estão dentro do regime do funcionário
if (!periodo.funcionario?._id) {
erro = 'Funcionário não encontrado';
processando = false;
return;
}
// Validar datas ajustadas
if (!novaDataInicio || !novaDataFim) {
erro = 'Informe as novas datas de início e fim';
processando = false;
return;
}
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
funcionarioId: periodo.funcionario._id,
anoReferencia: periodo.anoReferencia,
periodos: [{
dataInicio: novaDataInicio,
dataFim: novaDataFim
}],
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
});
if (!validacao.valido) {
erro = `Não é possível aprovar com ajuste: ${validacao.erros.join('; ')}`;
processando = false;
return;
}
await client.mutation(api.ferias.ajustarEAprovar, {
feriasId: periodo._id,
gestorId: gestorId,
novaDataInicio,
novaDataFim
});
if (onSucesso) onSucesso();
} catch (e) {
erro = e instanceof Error ? e.message : String(e);
} finally {
processando = false;
}
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
aguardando_aprovacao: 'badge-warning',
aprovado: 'badge-success',
reprovado: 'badge-error',
data_ajustada_aprovada: 'badge-info',
EmFérias: 'badge-info'
};
return badges[status] || 'badge-neutral';
}
function getStatusTexto(status: string) {
const textos: Record<string, string> = {
aguardando_aprovacao: 'Aguardando Aprovação',
aprovado: 'Aprovado',
reprovado: 'Reprovado',
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
EmFérias: 'Em Férias'
};
return textos[status] || status;
}
function formatarData(data: number) {
return new Date(data).toLocaleString('pt-BR');
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
$effect(() => {
if (modoAjuste) {
novaDataInicio = periodo.dataInicio;
novaDataFim = periodo.dataFim;
}
});
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-start justify-between">
<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 class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
{getStatusTexto(periodo.status)}
</div>
</div>
<!-- Período Solicitado -->
<div class="mt-4">
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3>
<div class="bg-base-200 rounded-lg p-4">
<div class="grid grid-cols-3 gap-4 text-sm">
<div>
<span class="text-base-content/70">Início:</span>
<span class="ml-1 font-semibold"
>{formatarDataString(periodo.dataInicio)}</span
>
</div>
<div>
<span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold"
>{formatarDataString(periodo.dataFim)}</span
>
</div>
<div>
<span class="text-base-content/70">Dias:</span>
<span class="text-primary ml-1 font-bold">{periodo.diasFerias}</span>
</div>
</div>
</div>
</div>
<!-- Observações -->
{#if periodo.observacao}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Observações</h3>
<div class="bg-base-200 rounded-lg p-3 text-sm">
{periodo.observacao}
</div>
</div>
{/if}
<!-- Histórico -->
{#if periodo.historicoAlteracoes && periodo.historicoAlteracoes.length > 0}
<div class="mt-4">
<h3 class="mb-2 font-semibold">Histórico</h3>
<div class="space-y-1">
{#each periodo.historicoAlteracoes as hist}
<div class="text-base-content/70 flex items-center gap-2 text-xs">
<svg
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>-</span>
<span>{hist.acao}</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Ações (apenas para status aguardando_aprovacao) -->
{#if periodo.status === 'aguardando_aprovacao'}
<div class="divider mt-6"></div>
{#if !modoAjuste}
<!-- Modo Normal -->
<div class="space-y-4">
<div class="flex flex-wrap gap-2">
<button
type="button"
class="btn btn-success gap-2"
onclick={aprovar}
disabled={processando}
>
<svg
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
</button>
<button
type="button"
class="btn btn-info gap-2"
onclick={() => (modoAjuste = true)}
disabled={processando}
>
<svg
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
</button>
</div>
<!-- Reprovar -->
<div class="card bg-base-200">
<div class="card-body p-4">
<h4 class="mb-2 text-sm font-semibold">Reprovar Período</h4>
<textarea
class="textarea textarea-bordered textarea-sm mb-2"
placeholder="Motivo da reprovação..."
bind:value={motivoReprovacao}
rows="2"
></textarea>
<button
type="button"
class="btn btn-error btn-sm gap-2"
onclick={reprovar}
disabled={processando || !motivoReprovacao.trim()}
>
<svg
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
</button>
</div>
</div>
</div>
{:else}
<!-- Modo Ajuste -->
<div class="space-y-4">
<h4 class="font-semibold">Ajustar Período</h4>
<div class="card bg-base-200">
<div class="card-body p-4">
<div class="grid grid-cols-3 gap-3">
<div class="form-control">
<label class="label" for="ajuste-inicio">
<span class="label-text text-xs">Início</span>
</label>
<input
id="ajuste-inicio"
type="date"
class="input input-bordered input-sm"
bind:value={novaDataInicio}
/>
</div>
<div class="form-control">
<label class="label" for="ajuste-fim">
<span class="label-text text-xs">Fim</span>
</label>
<input
id="ajuste-fim"
type="date"
class="input input-bordered input-sm"
bind:value={novaDataFim}
/>
</div>
<div class="form-control">
<label class="label" for="ajuste-dias">
<span class="label-text text-xs">Dias</span>
</label>
<div
id="ajuste-dias"
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
role="textbox"
aria-readonly="true"
>
<span class="font-bold">{diasAjustados}</span>
<span class="ml-2 text-xs opacity-70">dias</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-sm"
onclick={() => (modoAjuste = false)}
disabled={processando}
>
Cancelar Ajuste
</button>
<button
type="button"
class="btn btn-primary btn-sm gap-2"
onclick={ajustarEAprovar}
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0}
>
<svg
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
</button>
</div>
</div>
{/if}
{/if}
<!-- Motivo Reprovação (se reprovado) -->
{#if periodo.status === 'reprovado' && periodo.motivoReprovacao}
<div class="alert alert-error mt-4">
<svg
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 class="font-bold">Motivo da Reprovação:</div>
<div class="text-sm">{periodo.motivoReprovacao}</div>
</div>
</div>
{/if}
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<svg
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>
</div>
{/if}
<!-- Botão Fechar -->
{#if onCancelar}
<div class="card-actions mt-4 justify-end">
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
Fechar
</button>
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,540 @@
<script lang="ts">
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
import type { EventInput } from "@fullcalendar/core/index.js";
interface Props {
eventos: Array<{
id: string;
title: string;
start: string;
end: string;
color: string;
tipo: string;
funcionarioNome: string;
funcionarioId: string;
}>;
tipoFiltro?: string;
}
let { eventos, tipoFiltro = "todos" }: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
let filtroAtivo = $state<string>(tipoFiltro);
let showModal = $state(false);
let eventoSelecionado = $state<{
title: string;
start: string;
end: string;
tipo: string;
funcionarioNome: string;
} | null>(null);
// Eventos filtrados
const eventosFiltrados = $derived.by(() => {
if (filtroAtivo === "todos") return eventos;
return eventos.filter((e) => e.tipo === filtroAtivo);
});
// Converter eventos para formato FullCalendar
const eventosFullCalendar = $derived.by(() => {
return eventosFiltrados.map((evento) => ({
id: evento.id,
title: evento.title,
start: evento.start,
end: evento.end,
backgroundColor: evento.color,
borderColor: evento.color,
textColor: "#ffffff",
extendedProps: {
tipo: evento.tipo,
funcionarioNome: evento.funcionarioNome,
funcionarioId: evento.funcionarioId,
},
})) as EventInput[];
});
onMount(() => {
if (!calendarEl) return;
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
locale: ptBrLocale,
firstDay: 0, // Domingo
headerToolbar: {
left: "prev,next today",
center: "title",
right: "dayGridMonth",
},
buttonText: {
today: "Hoje",
month: "Mês",
week: "Semana",
day: "Dia",
},
events: eventosFullCalendar,
eventClick: (info) => {
eventoSelecionado = {
title: info.event.title,
start: info.event.startStr || "",
end: info.event.endStr || "",
tipo: info.event.extendedProps.tipo as string,
funcionarioNome: info.event.extendedProps.funcionarioNome as string,
};
showModal = true;
},
eventDisplay: "block",
dayMaxEvents: 3,
moreLinkClick: "popover",
height: "auto",
contentHeight: "auto",
aspectRatio: 1.8,
eventMouseEnter: (info) => {
info.el.style.cursor = "pointer";
info.el.style.opacity = "0.9";
},
eventMouseLeave: (info) => {
info.el.style.opacity = "1";
},
});
calendar.render();
return () => {
if (calendar) {
calendar.destroy();
}
};
});
// Atualizar eventos quando mudarem
$effect(() => {
if (calendar) {
calendar.removeAllEvents();
calendar.addEventSource(eventosFullCalendar);
calendar.refetchEvents();
}
});
function formatarData(data: string): string {
return new Date(data).toLocaleDateString("pt-BR", {
day: "2-digit",
month: "long",
year: "numeric",
});
}
function getTipoNome(tipo: string): string {
const nomes: Record<string, string> = {
atestado_medico: "Atestado Médico",
declaracao_comparecimento: "Declaração de Comparecimento",
maternidade: "Licença Maternidade",
paternidade: "Licença Paternidade",
ferias: "Férias",
};
return nomes[tipo] || tipo;
}
function getTipoCor(tipo: string): string {
const cores: Record<string, string> = {
atestado_medico: "text-error",
declaracao_comparecimento: "text-warning",
maternidade: "text-secondary",
paternidade: "text-info",
ferias: "text-success",
};
return cores[tipo] || "text-base-content";
}
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<!-- Header com filtros -->
<div
class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6"
>
<h2 class="card-title text-2xl">Calendário de Afastamentos</h2>
<!-- Filtros -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium text-base-content/70">Filtrar:</span>
<div class="join">
<button
class="join-item btn btn-sm {filtroAtivo === 'todos'
? 'btn-active btn-primary'
: 'btn-ghost'}"
onclick={() => (filtroAtivo = "todos")}
>
Todos
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'atestado_medico'
? 'btn-active btn-error'
: 'btn-ghost'}"
onclick={() => (filtroAtivo = "atestado_medico")}
>
Atestados
</button>
<button
class="join-item btn btn-sm {filtroAtivo ===
'declaracao_comparecimento'
? 'btn-active btn-warning'
: 'btn-ghost'}"
onclick={() => (filtroAtivo = "declaracao_comparecimento")}
>
Declarações
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'maternidade'
? 'btn-active btn-secondary'
: 'btn-ghost'}"
onclick={() => (filtroAtivo = "maternidade")}
>
Maternidade
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'paternidade'
? 'btn-active btn-info'
: 'btn-ghost'}"
onclick={() => (filtroAtivo = "paternidade")}
>
Paternidade
</button>
<button
class="join-item btn btn-sm {filtroAtivo === 'ferias'
? 'btn-active btn-success'
: 'btn-ghost'}"
onclick={() => (filtroAtivo = "ferias")}
>
Férias
</button>
</div>
</div>
</div>
<!-- Legenda -->
<div class="flex flex-wrap gap-4 mb-4 p-4 bg-base-200/50 rounded-lg">
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-error"></div>
<span class="text-sm">Atestado Médico</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-warning"></div>
<span class="text-sm">Declaração</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-secondary"></div>
<span class="text-sm">Licença Maternidade</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-info"></div>
<span class="text-sm">Licença Paternidade</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 rounded bg-success"></div>
<span class="text-sm">Férias</span>
</div>
</div>
<!-- Calendário -->
<div class="w-full overflow-x-auto">
<div bind:this={calendarEl} class="calendar-container"></div>
</div>
<!-- Modal de Detalhes -->
{#if showModal && eventoSelecionado}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onclick={() => (showModal = false)}
role="dialog"
aria-modal="true"
>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-md mx-4 transform transition-all"
onclick={(e) => e.stopPropagation()}
>
<!-- Header do Modal -->
<div
class="p-6 border-b border-base-300 bg-linear-to-r from-primary/10 to-secondary/10"
>
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-xl font-bold text-base-content mb-2">
{eventoSelecionado.funcionarioNome}
</h3>
<p
class="text-sm {getTipoCor(
eventoSelecionado.tipo,
)} font-medium"
>
{getTipoNome(eventoSelecionado.tipo)}
</p>
</div>
<button
class="btn btn-sm btn-circle btn-ghost"
onclick={() => (showModal = false)}
aria-label="Fechar"
>
<svg
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>
</button>
</div>
</div>
<!-- Conteúdo do Modal -->
<div class="p-6 space-y-4">
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
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>
<div>
<p class="text-sm text-base-content/60">Data Início</p>
<p class="font-semibold">
{formatarData(eventoSelecionado.start)}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-secondary"
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>
<div>
<p class="text-sm text-base-content/60">Data Fim</p>
<p class="font-semibold">
{formatarData(eventoSelecionado.end)}
</p>
</div>
</div>
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-accent"
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>
<div>
<p class="text-sm text-base-content/60">Duração</p>
<p class="font-semibold">
{(() => {
const inicio = new Date(eventoSelecionado.start);
const fim = new Date(eventoSelecionado.end);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays =
Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return `${diffDays} ${diffDays === 1 ? "dia" : "dias"}`;
})()}
</p>
</div>
</div>
</div>
<!-- Footer do Modal -->
<div class="p-6 border-t border-base-300 flex justify-end">
<button class="btn btn-primary" onclick={() => (showModal = false)}>
Fechar
</button>
</div>
</div>
</div>
{/if}
</div>
</div>
<style>
:global(.calendar-container) {
font-family: inherit;
}
:global(.fc) {
font-family: inherit;
}
:global(.fc-header-toolbar) {
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 0.5rem;
}
:global(.fc-button) {
background-color: hsl(var(--p));
border-color: hsl(var(--p));
color: hsl(var(--pc));
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 500;
transition: all 0.2s;
}
:global(.fc-button:hover) {
background-color: hsl(var(--pf));
border-color: hsl(var(--pf));
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
:global(.fc-button:active) {
transform: translateY(0);
}
:global(.fc-button-active) {
background-color: hsl(var(--a));
border-color: hsl(var(--a));
color: hsl(var(--ac));
}
:global(.fc-today-button) {
background-color: hsl(var(--s));
border-color: hsl(var(--s));
}
:global(.fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 500;
}
:global(.fc-day-today) {
background-color: hsl(var(--p) / 0.1) !important;
}
:global(.fc-day-today .fc-daygrid-day-number) {
background-color: hsl(var(--p));
color: hsl(var(--pc));
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
}
:global(.fc-event) {
border-radius: 0.375rem;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
}
:global(.fc-event:hover) {
transform: translateY(-1px);
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
opacity: 0.9;
}
:global(.fc-event-title) {
font-weight: 600;
padding: 0;
}
:global(.fc-daygrid-event) {
margin: 0.125rem 0;
}
:global(.fc-daygrid-day-frame) {
min-height: 100px;
}
:global(.fc-col-header-cell) {
padding: 0.75rem 0;
background-color: hsl(var(--b2));
font-weight: 600;
text-transform: uppercase;
font-size: 0.875rem;
color: hsl(var(--bc));
}
:global(.fc-daygrid-day) {
border-color: hsl(var(--b3));
}
:global(.fc-scrollgrid) {
border-color: hsl(var(--b3));
}
:global(.fc-daygrid-day-frame) {
padding: 0.25rem;
}
:global(.fc-more-link) {
font-weight: 600;
color: hsl(var(--p));
background-color: hsl(var(--p) / 0.1);
padding: 0.25rem 0.5rem;
border-radius: 0.375rem;
margin-top: 0.25rem;
}
:global(.fc-popover) {
background-color: hsl(var(--b1));
border-color: hsl(var(--b3));
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
border-radius: 0.5rem;
}
:global(.fc-popover-header) {
background-color: hsl(var(--b2));
border-color: hsl(var(--b3));
padding: 0.75rem;
font-weight: 600;
}
:global(.fc-popover-body) {
padding: 0.5rem;
}
</style>

View File

@@ -0,0 +1,73 @@
<script lang="ts">
import { AlertCircle, X } from 'lucide-svelte';
interface Props {
open: boolean;
title?: string;
message: string;
details?: string;
onClose: () => void;
}
let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props();
let modalRef: HTMLDialogElement;
function handleClose() {
open = false;
onClose();
}
$effect(() => {
if (open && modalRef) {
modalRef.showModal();
} else if (!open && modalRef) {
modalRef.close();
}
});
</script>
{#if open}
<dialog
bind:this={modalRef}
class="modal"
onclick={(e) => e.target === e.currentTarget && handleClose()}
>
<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-error flex items-center gap-2 text-xl font-bold">
<AlertCircle class="h-5 w-5" strokeWidth={2} />
{title}
</h2>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={handleClose}
aria-label="Fechar"
>
<X class="h-5 w-5" />
</button>
</div>
<!-- Content -->
<div class="px-6 py-6">
<p class="text-base-content mb-4">{message}</p>
{#if details}
<div class="bg-base-200 mb-4 rounded-lg p-4">
<p class="text-base-content/70 text-sm whitespace-pre-line">{details}</p>
</div>
{/if}
</div>
<!-- Footer -->
<div class="modal-action px-6 pb-6">
<button class="btn btn-primary" onclick={handleClose}> Fechar </button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={handleClose}>fechar</button>
</form>
</dialog>
{/if}

View File

@@ -1,273 +1,343 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
interface Props {
label: string;
helpUrl?: string;
value?: string; // storageId
disabled?: boolean;
onUpload: (file: File) => Promise<void>;
onRemove: () => Promise<void>;
}
let {
label,
helpUrl,
value = $bindable(),
disabled = false,
onUpload,
onRemove,
}: Props = $props();
const client = useConvexClient();
let fileInput: HTMLInputElement;
let uploading = $state(false);
let error = $state<string | null>(null);
let fileName = $state<string>("");
let fileType = $state<string>("");
let previewUrl = $state<string | null>(null);
let fileUrl = $state<string | null>(null);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = [
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
];
// Buscar URL do arquivo quando houver um storageId
$effect(() => {
if (value && !fileName) {
// Tem storageId mas não é um upload recente
loadExistingFile(value);
}
});
async function loadExistingFile(storageId: string) {
try {
const url = await client.storage.getUrl(storageId as any);
if (url) {
fileUrl = url;
fileName = "Documento anexado";
// Detectar tipo pelo URL ou assumir PDF
if (url.includes(".pdf") || url.includes("application/pdf")) {
fileType = "application/pdf";
} else {
fileType = "image/jpeg";
previewUrl = url; // Para imagens, a URL serve como preview
}
}
} catch (err) {
console.error("Erro ao carregar arquivo existente:", err);
}
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
error = null;
// Validate file size
if (file.size > MAX_FILE_SIZE) {
error = "Arquivo muito grande. Tamanho máximo: 10MB";
target.value = "";
return;
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
error = "Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)";
target.value = "";
return;
}
try {
uploading = true;
fileName = file.name;
fileType = file.type;
// Create preview for images
if (file.type.startsWith("image/")) {
const reader = new FileReader();
reader.onload = (e) => {
previewUrl = e.target?.result as string;
};
reader.readAsDataURL(file);
}
await onUpload(file);
} catch (err: any) {
error = err?.message || "Erro ao fazer upload do arquivo";
previewUrl = null;
} finally {
uploading = false;
target.value = "";
}
}
async function handleRemove() {
if (!confirm("Tem certeza que deseja remover este arquivo?")) {
return;
}
try {
uploading = true;
await onRemove();
fileName = "";
fileType = "";
previewUrl = null;
fileUrl = null;
} catch (err: any) {
error = err?.message || "Erro ao remover arquivo";
} finally {
uploading = false;
}
}
function handleView() {
if (fileUrl) {
window.open(fileUrl, '_blank');
}
}
function openFileDialog() {
fileInput?.click();
}
</script>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium flex items-center gap-2">
{label}
{#if helpUrl}
<div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
<a
href={helpUrl}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-focus transition-colors"
aria-label="Acessar link"
>
<svg 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="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</div>
{/if}
</span>
</label>
<input
type="file"
bind:this={fileInput}
onchange={handleFileSelect}
accept=".pdf,.jpg,.jpeg,.png"
class="hidden"
{disabled}
/>
{#if value || fileName}
<div class="flex items-center gap-2 p-3 border border-base-300 rounded-lg bg-base-100">
<!-- Preview -->
<div class="flex-shrink-0">
{#if previewUrl}
<img src={previewUrl} alt="Preview" class="w-12 h-12 object-cover rounded" />
{:else if fileType === "application/pdf" || fileName.endsWith(".pdf")}
<div class="w-12 h-12 bg-error/10 rounded flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" 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>
{:else}
<div class="w-12 h-12 bg-success/10 rounded flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" 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>
</div>
{/if}
</div>
<!-- File info -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{fileName || "Arquivo anexado"}</p>
<p class="text-xs text-base-content/60">
{#if uploading}
Carregando...
{:else}
Enviado com sucesso
{/if}
</p>
</div>
<!-- Actions -->
<div class="flex gap-2">
{#if fileUrl}
<button
type="button"
onclick={handleView}
class="btn btn-sm btn-ghost text-info"
disabled={uploading || disabled}
title="Visualizar arquivo"
>
<svg 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
{/if}
<button
type="button"
onclick={openFileDialog}
class="btn btn-sm btn-ghost"
disabled={uploading || disabled}
title="Substituir arquivo"
>
<svg 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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg>
</button>
<button
type="button"
onclick={handleRemove}
class="btn btn-sm btn-ghost text-error"
disabled={uploading || disabled}
title="Remover arquivo"
>
<svg 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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={openFileDialog}
class="btn btn-outline btn-block justify-start gap-2"
disabled={uploading || disabled}
>
{#if uploading}
<span class="loading loading-spinner loading-sm"></span>
Carregando...
{:else}
<svg 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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
Selecionar arquivo (PDF ou imagem, máx. 10MB)
{/if}
</button>
{/if}
{#if error}
<label class="label">
<span class="label-text-alt text-error">{error}</span>
</label>
{/if}
</div>
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { resolve } from '$app/paths';
import {
ExternalLink,
FileText,
File as FileIcon,
Upload,
Trash2,
Eye,
RefreshCw
} from 'lucide-svelte';
interface Props {
label: string;
helpUrl?: string;
value?: string; // storageId
disabled?: boolean;
required?: boolean;
onUpload: (file: globalThis.File) => Promise<void>;
onRemove: () => Promise<void>;
}
let {
label,
helpUrl,
value = $bindable(),
disabled = false,
required = false,
onUpload,
onRemove
}: Props = $props();
const client = useConvexClient() as unknown as {
storage: {
getUrl: (id: string) => Promise<string | null>;
};
};
let fileInput: HTMLInputElement | null = null;
let uploading = $state(false);
let error = $state<string | null>(null);
let fileName = $state<string>('');
let fileType = $state<string>('');
let previewUrl = $state<string | null>(null);
let fileUrl = $state<string | null>(null);
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
// Buscar URL do arquivo quando houver um storageId
$effect(() => {
if (value && !fileName) {
// Tem storageId mas não é um upload recente
void loadExistingFile(value);
}
let cancelled = false;
const storageId = value;
if (!storageId) {
return;
}
(async () => {
try {
const url = await client.storage.getUrl(storageId);
if (!url || cancelled) {
return;
}
fileUrl = url;
const path = url.split('?')[0] ?? '';
const nameFromUrl = path.split('/').pop() ?? 'arquivo';
fileName = decodeURIComponent(nameFromUrl);
const extension = fileName.toLowerCase().split('.').pop();
const isPdf =
extension === 'pdf' || url.includes('.pdf') || url.includes('application/pdf');
if (isPdf) {
fileType = 'application/pdf';
previewUrl = null;
} else {
fileType = 'image/jpeg';
previewUrl = url;
}
} catch (err) {
if (!cancelled) {
console.error('Erro ao carregar arquivo existente:', err);
}
}
})();
return () => {
cancelled = true;
};
});
async function loadExistingFile(storageId: string) {
try {
const url = await client.storage.getUrl(storageId);
if (url) {
fileUrl = url;
// Detectar tipo pelo URL ou assumir PDF
if (url.includes('.pdf') || url.includes('application/pdf')) {
fileType = 'application/pdf';
} else {
fileType = 'image/jpeg';
// Para imagens, a URL serve como preview
previewUrl = url;
}
}
} catch (err) {
console.error('Erro ao carregar arquivo existente:', err);
}
}
async function handleFileSelect(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) {
return;
}
error = null;
// Validate file size
if (file.size > MAX_FILE_SIZE) {
error = 'Arquivo muito grande. Tamanho máximo: 10MB';
target.value = '';
return;
}
// Validate file type
if (!ALLOWED_TYPES.includes(file.type)) {
error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
target.value = '';
return;
}
try {
uploading = true;
fileName = file.name;
fileType = file.type;
// Create preview for images
if (file.type.startsWith('image/')) {
const reader = new FileReader();
reader.onload = (e) => {
const result = e.target?.result;
if (typeof result === 'string') {
previewUrl = result;
}
};
reader.readAsDataURL(file);
} else {
previewUrl = null;
}
await onUpload(file);
} catch (err: unknown) {
if (err instanceof Error) {
error = err.message || 'Erro ao fazer upload do arquivo';
} else {
error = 'Erro ao fazer upload do arquivo';
}
previewUrl = null;
} finally {
uploading = false;
target.value = '';
}
}
async function handleRemove() {
if (!confirm('Tem certeza que deseja remover este arquivo?')) {
return;
}
try {
uploading = true;
await onRemove();
fileName = '';
fileType = '';
previewUrl = null;
fileUrl = null;
error = null;
} catch (err: unknown) {
if (err instanceof Error) {
error = err.message || 'Erro ao remover arquivo';
} else {
error = 'Erro ao remover arquivo';
}
} finally {
uploading = false;
}
}
function handleView() {
if (fileUrl) {
window.open(fileUrl, '_blank');
}
}
function openFileDialog() {
fileInput?.click();
}
function setFileInput(node: HTMLInputElement) {
fileInput = node;
return {
destroy() {
if (fileInput === node) {
fileInput = null;
}
}
};
}
</script>
<div class="form-control w-full">
<label class="label" for="file-upload-input">
<span class="label-text flex items-center gap-2 font-medium">
{label}
{#if required}
<span class="text-error">*</span>
{/if}
{#if helpUrl}
<div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
<a
href={helpUrl ?? '/'}
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-focus transition-colors"
aria-label="Acessar link"
>
<ExternalLink class="h-4 w-4" strokeWidth={2} />
</a>
</div>
{/if}
</span>
</label>
<input
id="file-upload-input"
type="file"
use:setFileInput
onchange={handleFileSelect}
accept=".pdf,.jpg,.jpeg,.png"
class="hidden"
{disabled}
/>
{#if value || fileName}
<div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3">
<!-- Preview -->
<div class="shrink-0">
{#if previewUrl}
<img src={previewUrl} alt="Preview" class="h-12 w-12 rounded object-cover" />
{:else if fileType === 'application/pdf' || fileName.endsWith('.pdf')}
<div class="bg-error/10 flex h-12 w-12 items-center justify-center rounded">
<FileText class="text-error h-6 w-6" strokeWidth={2} />
</div>
{:else}
<div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded">
<FileIcon class="text-success h-6 w-6" strokeWidth={2} />
</div>
{/if}
</div>
<!-- File info -->
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium">
{fileName || 'Arquivo anexado'}
</p>
<p class="text-base-content/60 text-xs">
{#if uploading}
Carregando...
{:else}
Enviado com sucesso
{/if}
</p>
</div>
<!-- Actions -->
<div class="flex gap-2">
{#if fileUrl}
<button
type="button"
onclick={handleView}
class="btn btn-sm btn-ghost text-info"
disabled={uploading || disabled}
title="Visualizar arquivo"
>
<Eye class="h-4 w-4" strokeWidth={2} />
</button>
{/if}
<button
type="button"
onclick={openFileDialog}
class="btn btn-sm btn-ghost"
disabled={uploading || disabled}
title="Substituir arquivo"
>
<RefreshCw class="h-4 w-4" strokeWidth={2} />
</button>
<button
type="button"
onclick={handleRemove}
class="btn btn-sm btn-ghost text-error"
disabled={uploading || disabled}
title="Remover arquivo"
>
<Trash2 class="h-4 w-4" strokeWidth={2} />
</button>
</div>
</div>
{:else}
<button
type="button"
onclick={openFileDialog}
class="btn btn-outline btn-block justify-start gap-2"
disabled={uploading || disabled}
>
{#if uploading}
<span class="loading loading-spinner loading-sm"></span>
Carregando...
{:else}
<Upload class="h-5 w-5" strokeWidth={2} />
Selecionar arquivo (PDF ou imagem, máx. 10MB)
{/if}
</button>
{/if}
{#if error}
<div class="label">
<span class="label-text-alt text-error">{error}</span>
</div>
{/if}
</div>

View File

@@ -0,0 +1,187 @@
<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';
interface Props {
value?: string; // Id do funcionário selecionado
placeholder?: string;
disabled?: boolean;
required?: boolean;
}
let {
value = $bindable(),
placeholder = 'Selecione um funcionário',
disabled = false,
required = false
}: Props = $props();
let busca = $state('');
let mostrarDropdown = $state(false);
// Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
// Filtrar funcionários baseado na busca
const funcionariosFiltrados = $derived.by(() => {
if (!busca.trim()) return funcionarios;
const termo = busca.toLowerCase().trim();
return funcionarios.filter((f) => {
const nomeMatch = f.nome?.toLowerCase().includes(termo);
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
const cpfMatch = f.cpf?.replace(/\D/g, '').includes(termo.replace(/\D/g, ''));
return nomeMatch || matriculaMatch || cpfMatch;
});
});
// Funcionário selecionado
const funcionarioSelecionado = $derived.by(() => {
if (!value) return null;
return funcionarios.find((f) => f._id === value);
});
function selecionarFuncionario(funcionarioId: string) {
value = funcionarioId;
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
busca = funcionario?.nome || '';
mostrarDropdown = false;
}
function limpar() {
value = undefined;
busca = '';
mostrarDropdown = false;
}
// Atualizar busca quando funcionário selecionado mudar externamente
$effect(() => {
if (value && !busca) {
const funcionario = funcionarios.find((f) => f._id === value);
busca = funcionario?.nome || '';
}
});
function handleFocus() {
if (!disabled) {
mostrarDropdown = true;
}
}
function handleBlur() {
// Delay para permitir click no dropdown
setTimeout(() => {
mostrarDropdown = false;
}, 200);
}
</script>
<div class="form-control relative w-full">
<label class="label">
<span class="label-text font-medium">
Funcionário
{#if required}
<span class="text-error">*</span>
{/if}
</span>
</label>
<div class="relative">
<input
type="text"
bind:value={busca}
{placeholder}
{disabled}
onfocus={handleFocus}
onblur={handleBlur}
class="input input-bordered w-full pr-10"
autocomplete="off"
/>
{#if value}
<button
type="button"
onclick={limpar}
class="btn btn-xs btn-circle absolute top-1/2 right-2 -translate-y-1/2"
{disabled}
>
<svg
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>
</button>
{:else}
<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}
{#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._id)}
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 && busca && 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"
>
Nenhum funcionário encontrado
</div>
{/if}
</div>
{#if funcionarioSelecionado}
<div class="text-base-content/60 mt-1 text-xs">
Selecionado: {funcionarioSelecionado.nome}
{#if funcionarioSelecionado.matricula}
- {funcionarioSelecionado.matricula}
{/if}
</div>
{/if}
</div>

View File

@@ -2,7 +2,6 @@
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 { authStore } from "$lib/stores/auth.svelte";
import { loginModalStore } from "$lib/stores/loginModal.svelte";
import { onMount } from "svelte";
import { goto } from "$app/navigation";
@@ -26,13 +25,14 @@
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(
authStore.usuario
currentUser?.data
? useQuery(api.menuPermissoes.verificarAcesso, {
usuarioId: authStore.usuario._id as Id<"usuarios">,
usuarioId: currentUser.data._id as Id<"usuarios">,
menuPath: menuPath,
})
: null
: null,
);
onMount(() => {
@@ -40,10 +40,8 @@
});
$effect(() => {
// Re-verificar quando o status de autenticação mudar
if (authStore.autenticado !== undefined) {
verificarPermissoes();
}
// Re-verificar quando o status do usuário atual mudar
verificarPermissoes();
});
$effect(() => {
@@ -54,23 +52,23 @@
});
function verificarPermissoes() {
// Dashboard e Solicitar Acesso são públicos
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
// Dashboard e abertura de chamados são públicos
if (menuPath === "/" || menuPath === "/abrir-chamado") {
verificando = false;
temPermissao = true;
return;
}
// Se não está autenticado
if (!authStore.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;
@@ -112,11 +110,24 @@
<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
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>
<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.
@@ -133,13 +144,25 @@
<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
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>
<p class="text-base-content/70">
Você não tem permissão para acessar esta página.
</p>
</div>
</div>
{/if}

View File

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

View File

@@ -1,463 +1,584 @@
<script lang="ts">
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { maskCPF, maskCEP, maskPhone } from "$lib/utils/masks";
import {
SEXO_OPTIONS, ESTADO_CIVIL_OPTIONS, GRAU_INSTRUCAO_OPTIONS,
GRUPO_SANGUINEO_OPTIONS, FATOR_RH_OPTIONS, APOSENTADO_OPTIONS
} from "$lib/utils/constants";
import logoGovPE from "$lib/assets/logo_governo_PE.png";
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import { maskCPF, maskCEP, maskPhone } from '$lib/utils/masks';
import {
SEXO_OPTIONS,
ESTADO_CIVIL_OPTIONS,
GRAU_INSTRUCAO_OPTIONS,
GRUPO_SANGUINEO_OPTIONS,
FATOR_RH_OPTIONS,
APOSENTADO_OPTIONS
} from '$lib/utils/constants';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
import { CheckCircle2, X, Printer } from 'lucide-svelte';
interface Props {
funcionario: any;
onClose: () => void;
}
interface Props {
funcionario: any;
onClose: () => void;
}
let { funcionario, onClose }: Props = $props();
let { funcionario, onClose }: Props = $props();
let modalRef: HTMLDialogElement;
let generating = $state(false);
let modalRef: HTMLDialogElement;
let generating = $state(false);
// Seções selecionáveis
let sections = $state({
dadosPessoais: true,
filiacao: true,
naturalidade: true,
documentos: true,
formacao: true,
saude: true,
endereco: true,
contato: true,
cargo: true,
bancario: true,
});
// Seções selecionáveis
let sections = $state({
dadosPessoais: true,
filiacao: true,
naturalidade: true,
documentos: true,
formacao: true,
saude: true,
endereco: true,
contato: true,
cargo: true,
financeiro: true,
bancario: true
});
function getLabelFromOptions(value: string | undefined, options: Array<{value: string, label: string}>): string {
if (!value) return "-";
return options.find(opt => opt.value === value)?.label || value;
}
const REGIME_LABELS: Record<string, string> = {
clt: 'CLT',
estatutario_municipal: 'Estatutário Municipal',
estatutario_pe: 'Estatutário PE',
estatutario_federal: 'Estatutário Federal'
};
function selectAll() {
Object.keys(sections).forEach(key => {
sections[key as keyof typeof sections] = true;
});
}
function getLabelFromOptions(
value: string | undefined,
options: Array<{ value: string; label: string }>
): string {
if (!value) return '-';
return options.find((opt) => opt.value === value)?.label || value;
}
function deselectAll() {
Object.keys(sections).forEach(key => {
sections[key as keyof typeof sections] = false;
});
}
function getRegimeLabel(value?: string) {
if (!value) return '-';
return REGIME_LABELS[value] ?? value;
}
async function gerarPDF() {
try {
generating = true;
function selectAll() {
Object.keys(sections).forEach((key) => {
sections[key as keyof typeof sections] = true;
});
}
const doc = new jsPDF();
function deselectAll() {
Object.keys(sections).forEach((key) => {
sections[key as keyof typeof sections] = false;
});
}
// Logo no canto superior esquerdo (proporcional)
let yPosition = 20;
try {
const logoImg = new Image();
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000); // timeout após 3s
});
// Logo proporcional: largura 25mm, altura ajustada automaticamente
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
// Ajustar posição inicial do texto para ficar ao lado da logo
yPosition = Math.max(20, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
async function gerarPDF() {
try {
generating = true;
// Cabeçalho (alinhado com a logo)
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text('Secretaria de Esportes', 50, yPosition);
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.text('Governo de Pernambuco', 50, yPosition + 7);
yPosition = Math.max(45, yPosition + 25);
// Título da ficha
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, { align: 'center' });
yPosition += 8;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 105, yPosition, { align: 'center' });
yPosition += 12;
const doc = new jsPDF();
// Dados Pessoais
if (sections.dadosPessoais) {
const dadosPessoais: any[] = [
['Nome', funcionario.nome],
['Matrícula', funcionario.matricula],
['CPF', maskCPF(funcionario.cpf)],
['RG', funcionario.rg],
['Data Nascimento', funcionario.nascimento],
];
// Logo no canto superior esquerdo (proporcional)
let yPosition = 20;
try {
const logoImg = new Image();
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000); // timeout após 3s
});
if (funcionario.rgOrgaoExpedidor) dadosPessoais.push(['Órgão Expedidor RG', funcionario.rgOrgaoExpedidor]);
if (funcionario.rgDataEmissao) dadosPessoais.push(['Data Emissão RG', funcionario.rgDataEmissao]);
if (funcionario.sexo) dadosPessoais.push(['Sexo', getLabelFromOptions(funcionario.sexo, SEXO_OPTIONS)]);
if (funcionario.estadoCivil) dadosPessoais.push(['Estado Civil', getLabelFromOptions(funcionario.estadoCivil, ESTADO_CIVIL_OPTIONS)]);
if (funcionario.nacionalidade) dadosPessoais.push(['Nacionalidade', funcionario.nacionalidade]);
// Logo proporcional: largura 25mm, altura ajustada automaticamente
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
autoTable(doc, {
startY: yPosition,
head: [['DADOS PESSOAIS', '']],
body: dadosPessoais,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Ajustar posição inicial do texto para ficar ao lado da logo
yPosition = Math.max(20, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
// Filiação
if (sections.filiacao && (funcionario.nomePai || funcionario.nomeMae)) {
const filiacao: any[] = [];
if (funcionario.nomePai) filiacao.push(['Nome do Pai', funcionario.nomePai]);
if (funcionario.nomeMae) filiacao.push(['Nome da Mãe', funcionario.nomeMae]);
// Cabeçalho (alinhado com a logo)
doc.setFontSize(16);
doc.setFont('helvetica', 'bold');
doc.text('Secretaria de Esportes', 50, yPosition);
doc.setFontSize(12);
doc.setFont('helvetica', 'normal');
doc.text('Governo de Pernambuco', 50, yPosition + 7);
autoTable(doc, {
startY: yPosition,
head: [['FILIAÇÃO', '']],
body: filiacao,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
yPosition = Math.max(45, yPosition + 25);
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Título da ficha
doc.setFontSize(18);
doc.setFont('helvetica', 'bold');
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, { align: 'center' });
// Naturalidade
if (sections.naturalidade && (funcionario.naturalidade || funcionario.naturalidadeUF)) {
const naturalidade: any[] = [];
if (funcionario.naturalidade) naturalidade.push(['Cidade', funcionario.naturalidade]);
if (funcionario.naturalidadeUF) naturalidade.push(['UF', funcionario.naturalidadeUF]);
yPosition += 8;
doc.setFontSize(10);
doc.setFont('helvetica', 'normal');
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 105, yPosition, {
align: 'center'
});
autoTable(doc, {
startY: yPosition,
head: [['NATURALIDADE', '']],
body: naturalidade,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
yPosition += 12;
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Dados Pessoais
if (sections.dadosPessoais) {
const dadosPessoais: any[] = [
['Nome', funcionario.nome],
['Matrícula', funcionario.matricula],
['CPF', maskCPF(funcionario.cpf)],
['RG', funcionario.rg],
['Data Nascimento', funcionario.nascimento]
];
// Documentos
if (sections.documentos) {
const documentosData: any[] = [];
if (funcionario.carteiraProfissionalNumero) {
documentosData.push(['Cart. Profissional', `Nº ${funcionario.carteiraProfissionalNumero}${funcionario.carteiraProfissionalSerie ? ' - Série: ' + funcionario.carteiraProfissionalSerie : ''}`]);
}
if (funcionario.reservistaNumero) {
documentosData.push(['Reservista', `Nº ${funcionario.reservistaNumero}${funcionario.reservistaSerie ? ' - Série: ' + funcionario.reservistaSerie : ''}`]);
}
if (funcionario.tituloEleitorNumero) {
let titulo = `Nº ${funcionario.tituloEleitorNumero}`;
if (funcionario.tituloEleitorZona) titulo += ` - Zona: ${funcionario.tituloEleitorZona}`;
if (funcionario.tituloEleitorSecao) titulo += ` - Seção: ${funcionario.tituloEleitorSecao}`;
documentosData.push(['Título Eleitor', titulo]);
}
if (funcionario.pisNumero) documentosData.push(['PIS/PASEP', funcionario.pisNumero]);
if (funcionario.rgOrgaoExpedidor)
dadosPessoais.push(['Órgão Expedidor RG', funcionario.rgOrgaoExpedidor]);
if (funcionario.rgDataEmissao)
dadosPessoais.push(['Data Emissão RG', funcionario.rgDataEmissao]);
if (funcionario.sexo)
dadosPessoais.push(['Sexo', getLabelFromOptions(funcionario.sexo, SEXO_OPTIONS)]);
if (funcionario.estadoCivil)
dadosPessoais.push([
'Estado Civil',
getLabelFromOptions(funcionario.estadoCivil, ESTADO_CIVIL_OPTIONS)
]);
if (funcionario.nacionalidade)
dadosPessoais.push(['Nacionalidade', funcionario.nacionalidade]);
if (documentosData.length > 0) {
autoTable(doc, {
startY: yPosition,
head: [['DOCUMENTOS', '']],
body: documentosData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
autoTable(doc, {
startY: yPosition,
head: [['DADOS PESSOAIS', '']],
body: dadosPessoais,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
}
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Formação
if (sections.formacao && (funcionario.grauInstrucao || funcionario.formacao)) {
const formacaoData: any[] = [];
if (funcionario.grauInstrucao) formacaoData.push(['Grau Instrução', getLabelFromOptions(funcionario.grauInstrucao, GRAU_INSTRUCAO_OPTIONS)]);
if (funcionario.formacao) formacaoData.push(['Formação', funcionario.formacao]);
if (funcionario.formacaoRegistro) formacaoData.push(['Registro Nº', funcionario.formacaoRegistro]);
// Filiação
if (sections.filiacao && (funcionario.nomePai || funcionario.nomeMae)) {
const filiacao: any[] = [];
if (funcionario.nomePai) filiacao.push(['Nome do Pai', funcionario.nomePai]);
if (funcionario.nomeMae) filiacao.push(['Nome da Mãe', funcionario.nomeMae]);
autoTable(doc, {
startY: yPosition,
head: [['FORMAÇÃO', '']],
body: formacaoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
autoTable(doc, {
startY: yPosition,
head: [['FILIAÇÃO', '']],
body: filiacao,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Saúde
if (sections.saude && (funcionario.grupoSanguineo || funcionario.fatorRH)) {
const saudeData: any[] = [];
if (funcionario.grupoSanguineo) saudeData.push(['Grupo Sanguíneo', funcionario.grupoSanguineo]);
if (funcionario.fatorRH) saudeData.push(['Fator RH', getLabelFromOptions(funcionario.fatorRH, FATOR_RH_OPTIONS)]);
// Naturalidade
if (sections.naturalidade && (funcionario.naturalidade || funcionario.naturalidadeUF)) {
const naturalidade: any[] = [];
if (funcionario.naturalidade) naturalidade.push(['Cidade', funcionario.naturalidade]);
if (funcionario.naturalidadeUF) naturalidade.push(['UF', funcionario.naturalidadeUF]);
autoTable(doc, {
startY: yPosition,
head: [['SAÚDE', '']],
body: saudeData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
autoTable(doc, {
startY: yPosition,
head: [['NATURALIDADE', '']],
body: naturalidade,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Endereço
if (sections.endereco) {
const enderecoData: any[] = [
['Endereço', funcionario.endereco],
['Cidade', funcionario.cidade],
['UF', funcionario.uf],
['CEP', maskCEP(funcionario.cep)],
];
// Documentos
if (sections.documentos) {
const documentosData: any[] = [];
autoTable(doc, {
startY: yPosition,
head: [['ENDEREÇO', '']],
body: enderecoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
if (funcionario.carteiraProfissionalNumero) {
documentosData.push([
'Cart. Profissional',
`Nº ${funcionario.carteiraProfissionalNumero}${funcionario.carteiraProfissionalSerie ? ' - Série: ' + funcionario.carteiraProfissionalSerie : ''}`
]);
}
if (funcionario.carteiraProfissionalDataEmissao) {
documentosData.push([
'Emissão Cart. Profissional',
funcionario.carteiraProfissionalDataEmissao
]);
}
if (funcionario.reservistaNumero) {
documentosData.push([
'Reservista',
`Nº ${funcionario.reservistaNumero}${funcionario.reservistaSerie ? ' - Série: ' + funcionario.reservistaSerie : ''}`
]);
}
if (funcionario.tituloEleitorNumero) {
let titulo = `Nº ${funcionario.tituloEleitorNumero}`;
if (funcionario.tituloEleitorZona) titulo += ` - Zona: ${funcionario.tituloEleitorZona}`;
if (funcionario.tituloEleitorSecao)
titulo += ` - Seção: ${funcionario.tituloEleitorSecao}`;
documentosData.push(['Título Eleitor', titulo]);
}
if (funcionario.pisNumero) documentosData.push(['PIS/PASEP', funcionario.pisNumero]);
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
if (documentosData.length > 0) {
autoTable(doc, {
startY: yPosition,
head: [['DOCUMENTOS', '']],
body: documentosData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
// Contato
if (sections.contato) {
const contatoData: any[] = [
['E-mail', funcionario.email],
['Telefone', maskPhone(funcionario.telefone)],
];
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
}
autoTable(doc, {
startY: yPosition,
head: [['CONTATO', '']],
body: contatoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
// Formação
if (sections.formacao && (funcionario.grauInstrucao || funcionario.formacao)) {
const formacaoData: any[] = [];
if (funcionario.grauInstrucao)
formacaoData.push([
'Grau Instrução',
getLabelFromOptions(funcionario.grauInstrucao, GRAU_INSTRUCAO_OPTIONS)
]);
if (funcionario.formacao) formacaoData.push(['Formação', funcionario.formacao]);
if (funcionario.formacaoRegistro)
formacaoData.push(['Registro Nº', funcionario.formacaoRegistro]);
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
autoTable(doc, {
startY: yPosition,
head: [['FORMAÇÃO', '']],
body: formacaoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
// Nova página para cargo
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Cargo e Vínculo
if (sections.cargo) {
const cargoData: any[] = [
['Tipo', funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'],
];
// Saúde
if (sections.saude && (funcionario.grupoSanguineo || funcionario.fatorRH)) {
const saudeData: any[] = [];
if (funcionario.grupoSanguineo)
saudeData.push(['Grupo Sanguíneo', funcionario.grupoSanguineo]);
if (funcionario.fatorRH)
saudeData.push(['Fator RH', getLabelFromOptions(funcionario.fatorRH, FATOR_RH_OPTIONS)]);
if (funcionario.simbolo) {
cargoData.push(['Símbolo', funcionario.simbolo.nome]);
}
if (funcionario.descricaoCargo) cargoData.push(['Descrição', funcionario.descricaoCargo]);
if (funcionario.admissaoData) cargoData.push(['Data Admissão', funcionario.admissaoData]);
if (funcionario.nomeacaoPortaria) cargoData.push(['Portaria', funcionario.nomeacaoPortaria]);
if (funcionario.nomeacaoData) cargoData.push(['Data Nomeação', funcionario.nomeacaoData]);
if (funcionario.nomeacaoDOE) cargoData.push(['DOE', funcionario.nomeacaoDOE]);
if (funcionario.pertenceOrgaoPublico) {
cargoData.push(['Pertence Órgão Público', 'Sim']);
if (funcionario.orgaoOrigem) cargoData.push(['Órgão Origem', funcionario.orgaoOrigem]);
}
if (funcionario.aposentado && funcionario.aposentado !== 'nao') {
cargoData.push(['Aposentado', getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)]);
}
autoTable(doc, {
startY: yPosition,
head: [['SAÚDE', '']],
body: saudeData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
autoTable(doc, {
startY: yPosition,
head: [['CARGO E VÍNCULO', '']],
body: cargoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Endereço
if (sections.endereco) {
const enderecoData: any[] = [
['Endereço', funcionario.endereco],
['Cidade', funcionario.cidade],
['UF', funcionario.uf],
['CEP', maskCEP(funcionario.cep)]
];
// Dados Bancários
if (sections.bancario && funcionario.contaBradescoNumero) {
const bancarioData: any[] = [
['Conta', `${funcionario.contaBradescoNumero}${funcionario.contaBradescoDV ? '-' + funcionario.contaBradescoDV : ''}`],
];
if (funcionario.contaBradescoAgencia) bancarioData.push(['Agência', funcionario.contaBradescoAgencia]);
autoTable(doc, {
startY: yPosition,
head: [['ENDEREÇO', '']],
body: enderecoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
autoTable(doc, {
startY: yPosition,
head: [['DADOS BANCÁRIOS - BRADESCO', '']],
body: bancarioData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 },
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Contato
if (sections.contato) {
const contatoData: any[] = [
['E-mail', funcionario.email],
['Telefone', maskPhone(funcionario.telefone)]
];
// Adicionar rodapé em todas as páginas
const pageCount = (doc as any).internal.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(128, 128, 128);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });
}
autoTable(doc, {
startY: yPosition,
head: [['CONTATO', '']],
body: contatoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
// Salvar PDF
doc.save(`Ficha_${funcionario.nome.replace(/ /g, '_')}_${new Date().getTime()}.pdf`);
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
onClose();
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar PDF. Verifique o console para mais detalhes.');
} finally {
generating = false;
}
}
// Nova página para cargo
if (yPosition > 200) {
doc.addPage();
yPosition = 20;
}
$effect(() => {
if (modalRef) {
modalRef.showModal();
}
});
// Cargo e Vínculo
if (sections.cargo) {
const cargoData: any[] = [
[
'Tipo',
funcionario.simboloTipo === 'cargo_comissionado'
? 'Cargo Comissionado'
: 'Função Gratificada'
]
];
const simboloInfo =
funcionario.simbolo ?? funcionario.simboloDetalhes ?? funcionario.simboloDados;
if (simboloInfo) {
cargoData.push(['Símbolo', simboloInfo.nome]);
if (simboloInfo.descricao)
cargoData.push(['Descrição do Símbolo', simboloInfo.descricao]);
}
if (funcionario.descricaoCargo) cargoData.push(['Descrição', funcionario.descricaoCargo]);
if (funcionario.regimeTrabalho)
cargoData.push(['Regime do Funcionário', getRegimeLabel(funcionario.regimeTrabalho)]);
if (funcionario.admissaoData) cargoData.push(['Data Admissão', funcionario.admissaoData]);
if (funcionario.nomeacaoPortaria)
cargoData.push(['Portaria', funcionario.nomeacaoPortaria]);
if (funcionario.nomeacaoData) cargoData.push(['Data Nomeação', funcionario.nomeacaoData]);
if (funcionario.nomeacaoDOE) cargoData.push(['DOE', funcionario.nomeacaoDOE]);
cargoData.push([
'Pertence Órgão Público',
funcionario.pertenceOrgaoPublico ? 'Sim' : 'Não'
]);
if (funcionario.pertenceOrgaoPublico && funcionario.orgaoOrigem)
cargoData.push(['Órgão Origem', funcionario.orgaoOrigem]);
if (funcionario.aposentado && funcionario.aposentado !== 'nao') {
cargoData.push([
'Aposentado',
getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)
]);
}
autoTable(doc, {
startY: yPosition,
head: [['CARGO E VÍNCULO', '']],
body: cargoData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Dados Financeiros
if (sections.financeiro && funcionario.simbolo) {
const simbolo = funcionario.simbolo;
const financeiroData: any[] = [
['Símbolo', simbolo.nome],
[
'Tipo',
simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'
],
['Remuneração Total', `R$ ${simbolo.valor}`]
];
if (funcionario.simboloTipo === 'cargo_comissionado') {
if (simbolo.vencValor) {
financeiroData.push(['Vencimento', `R$ ${simbolo.vencValor}`]);
}
if (simbolo.repValor) {
financeiroData.push(['Representação', `R$ ${simbolo.repValor}`]);
}
}
autoTable(doc, {
startY: yPosition,
head: [['DADOS FINANCEIROS', '']],
body: financeiroData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Dados Bancários
if (sections.bancario && funcionario.contaBradescoNumero) {
const bancarioData: any[] = [
[
'Conta',
`${funcionario.contaBradescoNumero}${funcionario.contaBradescoDV ? '-' + funcionario.contaBradescoDV : ''}`
]
];
if (funcionario.contaBradescoAgencia)
bancarioData.push(['Agência', funcionario.contaBradescoAgencia]);
autoTable(doc, {
startY: yPosition,
head: [['DADOS BANCÁRIOS - BRADESCO', '']],
body: bancarioData,
theme: 'grid',
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
styles: { fontSize: 9 }
});
yPosition = (doc as any).lastAutoTable.finalY + 10;
}
// Adicionar rodapé em todas as páginas
const pageCount = (doc as any).internal.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(9);
doc.setFont('helvetica', 'normal');
doc.setTextColor(128, 128, 128);
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
align: 'center'
});
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });
}
// Salvar PDF
doc.save(`Ficha_${funcionario.nome.replace(/ /g, '_')}_${new Date().getTime()}.pdf`);
onClose();
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar PDF. Verifique o console para mais detalhes.');
} finally {
generating = false;
}
}
$effect(() => {
if (modalRef) {
modalRef.showModal();
}
});
</script>
<dialog bind:this={modalRef} class="modal">
<div class="modal-box max-w-4xl">
<h3 class="font-bold text-2xl mb-4">Imprimir Ficha Cadastral</h3>
<p class="text-sm text-base-content/70 mb-6">Selecione as seções que deseja incluir no PDF</p>
<div class="modal-box max-w-4xl">
<h3 class="mb-4 text-2xl font-bold">Imprimir Ficha Cadastral</h3>
<p class="text-base-content/70 mb-6 text-sm">Selecione as seções que deseja incluir no PDF</p>
<!-- Botões de seleção -->
<div class="flex gap-2 mb-6">
<button type="button" class="btn btn-sm btn-outline" onclick={selectAll}>
<svg 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Selecionar Todos
</button>
<button type="button" class="btn btn-sm btn-outline" onclick={deselectAll}>
<svg 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>
Desmarcar Todos
</button>
</div>
<!-- Botões de seleção -->
<div class="mb-6 flex gap-2">
<button type="button" class="btn btn-sm btn-outline" onclick={selectAll}>
<CheckCircle2 class="h-4 w-4" strokeWidth={2} />
Selecionar Todos
</button>
<button type="button" class="btn btn-sm btn-outline" onclick={deselectAll}>
<X class="h-4 w-4" strokeWidth={2} />
Desmarcar Todos
</button>
</div>
<!-- Grid de checkboxes -->
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6 max-h-96 overflow-y-auto p-2 border rounded-lg bg-base-200">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.dadosPessoais} />
<span class="label-text">Dados Pessoais</span>
</label>
<!-- Grid de checkboxes -->
<div
class="bg-base-200 mb-6 grid max-h-96 grid-cols-2 gap-4 overflow-y-auto rounded-lg border p-2 md:grid-cols-3"
>
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.dadosPessoais}
/>
<span class="label-text">Dados Pessoais</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.filiacao} />
<span class="label-text">Filiação</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.filiacao} />
<span class="label-text">Filiação</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.naturalidade} />
<span class="label-text">Naturalidade</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.naturalidade}
/>
<span class="label-text">Naturalidade</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.documentos} />
<span class="label-text">Documentos</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.documentos}
/>
<span class="label-text">Documentos</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.formacao} />
<span class="label-text">Formação</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.formacao} />
<span class="label-text">Formação</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.saude} />
<span class="label-text">Saúde</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.saude} />
<span class="label-text">Saúde</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.endereco} />
<span class="label-text">Endereço</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.endereco} />
<span class="label-text">Endereço</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.contato} />
<span class="label-text">Contato</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.contato} />
<span class="label-text">Contato</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.cargo} />
<span class="label-text">Cargo e Vínculo</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.cargo} />
<span class="label-text">Cargo e Vínculo</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.bancario} />
<span class="label-text">Dados Bancários</span>
</label>
</div>
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={sections.financeiro}
/>
<span class="label-text">Dados Financeiros</span>
</label>
<!-- Ações -->
<div class="modal-action">
<button type="button" class="btn btn-ghost" onclick={onClose} disabled={generating}>
Cancelar
</button>
<button type="button" class="btn btn-primary gap-2" onclick={gerarPDF} disabled={generating}>
{#if generating}
<span class="loading loading-spinner loading-sm"></span>
Gerando PDF...
{:else}
<svg 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="M17 17h2a2 2 0 002-2v-4a2 2 0 00-2-2H5a2 2 0 00-2 2v4a2 2 0 002 2h2m2 4h6a2 2 0 002-2v-4a2 2 0 00-2-2H9a2 2 0 00-2 2v4a2 2 0 002 2zm8-12V5a2 2 0 00-2-2H9a2 2 0 00-2 2v4h10z" />
</svg>
Gerar PDF
{/if}
</button>
</div>
</div>
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.bancario} />
<span class="label-text">Dados Bancários</span>
</label>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
<!-- Ações -->
<div class="modal-action">
<button type="button" class="btn" onclick={onClose} disabled={generating}> Cancelar </button>
<button type="button" class="btn btn-primary gap-2" onclick={gerarPDF} disabled={generating}>
{#if generating}
<span class="loading loading-spinner loading-sm"></span>
Gerando PDF...
{:else}
<Printer class="h-5 w-5" strokeWidth={2} />
Gerar PDF
{/if}
</button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

@@ -1,74 +1,131 @@
<script lang="ts">
import { authStore } from "$lib/stores/auth.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { page } from "$app/stores";
import type { Snippet } from "svelte";
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
let {
children,
requireAuth = true,
allowedRoles = [],
maxLevel = 3,
redirectTo = "/"
}: {
children: Snippet;
requireAuth?: boolean;
allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string;
} = $props();
let {
children,
requireAuth = true,
allowedRoles = [],
maxLevel = 3,
redirectTo = '/'
}: {
children: Snippet;
requireAuth?: boolean;
allowedRoles?: string[];
maxLevel?: number;
redirectTo?: string;
} = $props();
let isChecking = $state(true);
let hasAccess = $state(false);
let isChecking = $state(true);
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, {});
onMount(() => {
checkAccess();
});
// Usar $effect para reagir apenas às mudanças na query currentUser
$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() {
isChecking = true;
function checkAccess() {
// Limpar timeout anterior se existir
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
// Aguardar um pouco para o authStore carregar do localStorage
setTimeout(() => {
// Verificar autenticação
if (requireAuth && !authStore.autenticado) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
return;
}
// Se a query ainda está carregando (undefined), aguardar
if (currentUser === undefined) {
isChecking = true;
hasAccess = false;
return;
}
// Verificar roles
if (allowedRoles.length > 0 && authStore.usuario) {
const hasRole = allowedRoles.includes(authStore.usuario.role.nome);
if (!hasRole) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
}
// Marcar que já verificou pelo menos uma vez
hasCheckedOnce = true;
// Verificar nível
if (authStore.usuario && authStore.usuario.role.nivel > maxLevel) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
// Se a query retornou dados, verificar autenticação
if (currentUser?.data) {
// Verificar roles
if (allowedRoles.length > 0) {
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
if (!hasRole) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
}
hasAccess = true;
isChecking = false;
}, 100);
}
// Verificar nível
if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
const currentPath = window.location.pathname;
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
return;
}
// Se chegou aqui, permitir acesso
hasAccess = true;
isChecking = false;
return;
}
// 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;
// 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;
}
// Se ainda está carregando (undefined), aguardar
isChecking = true;
hasAccess = false;
return;
}
// Se não requer autenticação, permitir acesso
if (!requireAuth) {
hasAccess = true;
isChecking = false;
}
}
</script>
{#if isChecking}
<div class="flex justify-center items-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>
<div class="flex min-h-screen items-center justify-center">
<div class="text-center">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
</div>
</div>
{:else if hasAccess}
{@render children()}
{@render children()}
{/if}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,353 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
interface Periodo {
id: string;
dataInicio: string;
dataFim: string;
diasCorridos: number;
}
interface Props {
funcionarioId: string;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
const client = useConvexClient();
let anoReferencia = $state(new Date().getFullYear());
let observacao = $state('');
let periodos = $state<Periodo[]>([]);
let processando = $state(false);
let erro = $state('');
// Adicionar primeiro período ao carregar
$effect(() => {
if (periodos.length === 0) {
adicionarPeriodo();
}
});
function adicionarPeriodo() {
if (periodos.length >= 3) {
erro = 'Máximo de 3 períodos permitidos';
return;
}
periodos.push({
id: crypto.randomUUID(),
dataInicio: '',
dataFim: '',
diasCorridos: 0
});
}
function removerPeriodo(id: string) {
periodos = periodos.filter((p) => p.id !== id);
}
function calcularDias(periodo: Periodo) {
if (!periodo.dataInicio || !periodo.dataFim) {
periodo.diasCorridos = 0;
return;
}
const inicio = new Date(periodo.dataInicio);
const fim = new Date(periodo.dataFim);
if (fim < inicio) {
erro = 'Data final não pode ser anterior à data inicial';
periodo.diasCorridos = 0;
return;
}
const diff = fim.getTime() - inicio.getTime();
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
periodo.diasCorridos = dias;
erro = '';
}
function validarPeriodos(): boolean {
if (periodos.length === 0) {
erro = 'Adicione pelo menos 1 período';
return false;
}
for (const periodo of periodos) {
if (!periodo.dataInicio || !periodo.dataFim) {
erro = 'Preencha as datas de todos os períodos';
return false;
}
if (periodo.diasCorridos <= 0) {
erro = 'Todos os períodos devem ter pelo menos 1 dia';
return false;
}
}
// Verificar sobreposição de períodos
for (let i = 0; i < periodos.length; i++) {
for (let j = i + 1; j < periodos.length; j++) {
const p1Inicio = new Date(periodos[i].dataInicio);
const p1Fim = new Date(periodos[i].dataFim);
const p2Inicio = new Date(periodos[j].dataInicio);
const p2Fim = new Date(periodos[j].dataFim);
if (
(p2Inicio >= p1Inicio && p2Inicio <= p1Fim) ||
(p2Fim >= p1Inicio && p2Fim <= p1Fim) ||
(p1Inicio >= p2Inicio && p1Inicio <= p2Fim)
) {
erro = 'Os períodos não podem se sobrepor';
return false;
}
}
}
return true;
}
async function enviarSolicitacao() {
if (!validarPeriodos()) return;
try {
processando = true;
erro = '';
await client.mutation(api.ferias.criarSolicitacao, {
funcionarioId: funcionarioId as any,
anoReferencia,
periodos: periodos.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim,
diasCorridos: p.diasCorridos
})),
observacao: observacao || undefined
});
if (onSucesso) onSucesso();
} catch (e: any) {
erro = e.message || 'Erro ao enviar solicitação';
} finally {
processando = false;
}
}
$effect(() => {
periodos.forEach((p) => calcularDias(p));
});
</script>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title mb-4 text-2xl">
<svg
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="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>
Solicitar Férias
</h2>
<!-- Ano de Referência -->
<div class="form-control">
<label class="label" for="ano-referencia">
<span class="label-text font-semibold">Ano de Referência</span>
</label>
<input
id="ano-referencia"
type="number"
class="input input-bordered w-full max-w-xs"
bind:value={anoReferencia}
min={new Date().getFullYear()}
max={new Date().getFullYear() + 2}
/>
</div>
<!-- Períodos -->
<div class="mt-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-lg font-semibold">Períodos ({periodos.length}/3)</h3>
{#if periodos.length < 3}
<button type="button" class="btn btn-sm btn-primary gap-2" onclick={adicionarPeriodo}>
<svg
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="M12 4v16m8-8H4"
/>
</svg>
Adicionar Período
</button>
{/if}
</div>
<div class="space-y-4">
{#each periodos as periodo, index}
<div class="card bg-base-200 border-base-300 border">
<div class="card-body p-4">
<div class="mb-3 flex items-center justify-between">
<h4 class="font-medium">Período {index + 1}</h4>
{#if periodos.length > 1}
<button
type="button"
class="btn btn-xs btn-error"
aria-label="Remover período"
onclick={() => removerPeriodo(periodo.id)}
>
<svg
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="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control">
<label class="label" for={`inicio-${periodo.id}`}>
<span class="label-text">Data Início</span>
</label>
<input
id={`inicio-${periodo.id}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataInicio}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`fim-${periodo.id}`}>
<span class="label-text">Data Fim</span>
</label>
<input
id={`fim-${periodo.id}`}
type="date"
class="input input-bordered input-sm"
bind:value={periodo.dataFim}
onchange={() => calcularDias(periodo)}
/>
</div>
<div class="form-control">
<label class="label" for={`dias-${periodo.id}`}>
<span class="label-text">Dias Corridos</span>
</label>
<div
id={`dias-${periodo.id}`}
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
role="textbox"
aria-readonly="true"
>
<span class="text-lg font-bold">{periodo.diasCorridos}</span>
<span class="ml-1 text-sm">dias</span>
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
</div>
<!-- Observações -->
<div class="form-control mt-6">
<label class="label" for="observacao">
<span class="label-text font-semibold">Observações (opcional)</span>
</label>
<textarea
id="observacao"
class="textarea textarea-bordered h-24"
placeholder="Adicione observações sobre sua solicitação..."
bind:value={observacao}
></textarea>
</div>
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<svg
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>
</div>
{/if}
<!-- Ações -->
<div class="card-actions mt-6 justify-end">
{#if onCancelar}
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
Cancelar
</button>
{/if}
<button
type="button"
class="btn btn-primary gap-2"
onclick={enviarSolicitacao}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
<svg
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
{/if}
</button>
</div>
</div>
</div>

View File

@@ -0,0 +1,993 @@
<script lang="ts">
import { onMount } from "svelte";
import { Calendar } from "@fullcalendar/core";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import multiMonthPlugin from "@fullcalendar/multimonth";
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
import { SvelteDate } from "svelte/reactivity";
interface Props {
dataInicio?: string;
dataFim?: string;
ausenciasExistentes?: Array<{
dataInicio: string;
dataFim: string;
status: "aguardando_aprovacao" | "aprovado" | "reprovado";
}>;
onPeriodoSelecionado?: (periodo: {
dataInicio: string;
dataFim: string;
}) => void;
modoVisualizacao?: "month" | "multiMonth";
readonly?: boolean;
}
let {
dataInicio,
dataFim,
ausenciasExistentes = [],
onPeriodoSelecionado,
modoVisualizacao = "month",
readonly = false,
}: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
let selecionando = $state(false); // Flag para evitar atualizações durante seleção
// Cores por status
const coresStatus: Record<
string,
{ bg: string; border: string; text: string }
> = {
aguardando_aprovacao: { bg: "#f59e0b", border: "#d97706", text: "#ffffff" }, // Laranja
aprovado: { bg: "#10b981", border: "#059669", text: "#ffffff" }, // Verde
reprovado: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }, // Vermelho
};
// Converter ausências existentes em eventos
let eventos = $derived.by(() => {
const novosEventos: Array<{
id: string;
title: string;
start: string;
end: string;
backgroundColor: string;
borderColor: string;
textColor: string;
extendedProps: {
status: string;
};
}> = ausenciasExistentes.map((ausencia, index) => {
const cor =
coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao;
return {
id: `ausencia-${index}`,
title: `${getStatusTexto(ausencia.status)} - ${calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias`,
start: ausencia.dataInicio,
end: calcularDataFim(ausencia.dataFim),
backgroundColor: cor.bg,
borderColor: cor.border,
textColor: cor.text,
extendedProps: {
status: ausencia.status,
},
};
});
// Adicionar período selecionado atual se existir
if (dataInicio && dataFim) {
novosEventos.push({
id: "periodo-selecionado",
title: `Selecionado - ${calcularDias(dataInicio, dataFim)} dias`,
start: dataInicio,
end: calcularDataFim(dataFim),
backgroundColor: "#667eea",
borderColor: "#5568d3",
textColor: "#ffffff",
extendedProps: {
status: "selecionado",
},
});
}
return novosEventos;
});
function getStatusTexto(status: string): string {
const textos: Record<string, string> = {
aguardando_aprovacao: "Aguardando",
aprovado: "Aprovado",
reprovado: "Reprovado",
};
return textos[status] || status;
}
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
function calcularDataFim(dataFim: string): string {
const data = new SvelteDate(dataFim);
data.setDate(data.getDate() + 1);
return data.toISOString().split("T")[0];
}
// Helper: Calcular dias entre datas (inclusivo)
function calcularDias(inicio: string, fim: string): number {
const dInicio = new SvelteDate(inicio);
const dFim = new SvelteDate(fim);
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Helper: Verificar se há sobreposição de datas
function verificarSobreposicao(
inicio1: SvelteDate,
fim1: SvelteDate,
inicio2: string,
fim2: string,
): boolean {
const d2Inicio = new SvelteDate(inicio2);
const d2Fim = new SvelteDate(fim2);
// Verificar sobreposição: início1 <= fim2 && início2 <= fim1
return inicio1 <= d2Fim && d2Inicio <= fim1;
}
// Helper: Verificar se período selecionado sobrepõe com ausências existentes
function verificarSobreposicaoComAusencias(
inicio: SvelteDate,
fim: SvelteDate,
): boolean {
if (!ausenciasExistentes || ausenciasExistentes.length === 0) return false;
// Verificar apenas ausências aprovadas ou aguardando aprovação
const ausenciasBloqueantes = ausenciasExistentes.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
);
return ausenciasBloqueantes.some((ausencia) =>
verificarSobreposicao(inicio, fim, ausencia.dataInicio, ausencia.dataFim),
);
}
interface FullCalendarDayCellInfo {
el: HTMLElement;
date: Date;
}
// Helper: Atualizar classe de seleção em uma célula
function atualizarClasseSelecionado(info: FullCalendarDayCellInfo) {
if (dataInicio && dataFim && !readonly) {
const cellDate = new SvelteDate(info.date);
const inicio = new SvelteDate(dataInicio);
const fim = new SvelteDate(dataFim);
cellDate.setHours(0, 0, 0, 0);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
if (cellDate >= inicio && cellDate <= fim) {
info.el.classList.add("fc-day-selected");
} else {
info.el.classList.remove("fc-day-selected");
}
} else {
info.el.classList.remove("fc-day-selected");
}
}
// Helper: Atualizar classe de bloqueio para dias com ausências existentes
function atualizarClasseBloqueado(info: FullCalendarDayCellInfo) {
if (readonly || !ausenciasExistentes || ausenciasExistentes.length === 0) {
info.el.classList.remove("fc-day-blocked");
return;
}
const cellDate = new SvelteDate(info.date);
cellDate.setHours(0, 0, 0, 0);
// Verificar se a data está dentro de alguma ausência aprovada ou aguardando aprovação
const estaBloqueado = ausenciasExistentes
.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
)
.some((ausencia) => {
const inicio = new SvelteDate(ausencia.dataInicio);
const fim = new SvelteDate(ausencia.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
return cellDate >= inicio && cellDate <= fim;
});
if (estaBloqueado) {
info.el.classList.add("fc-day-blocked");
} else {
info.el.classList.remove("fc-day-blocked");
}
}
// Helper: Atualizar todos os dias selecionados no calendário
function atualizarDiasSelecionados() {
if (!calendar || !calendarEl || !dataInicio || !dataFim || readonly) return;
// Usar a API do FullCalendar para iterar sobre todas as células visíveis
const view = calendar.view;
if (!view) return;
const inicio = new SvelteDate(dataInicio);
const fim = new SvelteDate(dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
// O FullCalendar renderiza as células, então podemos usar dayCellDidMount
// Mas também precisamos atualizar células existentes
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
cells.forEach((cell) => {
// Remover classe primeiro
cell.classList.remove("fc-day-selected");
// Tentar obter a data do aria-label ou do elemento
const ariaLabel = cell.getAttribute("aria-label");
if (ariaLabel) {
// Formato: "dia mês ano" ou similar
try {
const cellDate = new SvelteDate(ariaLabel);
if (!isNaN(cellDate.getTime())) {
cellDate.setHours(0, 0, 0, 0);
if (cellDate >= inicio && cellDate <= fim) {
cell.classList.add("fc-day-selected");
}
}
} catch {
// Ignorar erros de parsing
}
}
});
}
// Helper: Atualizar todos os dias bloqueados no calendário
function atualizarDiasBloqueados() {
if (
!calendar ||
!calendarEl ||
readonly ||
!ausenciasExistentes ||
ausenciasExistentes.length === 0
) {
// Remover classes de bloqueio se não houver ausências
if (calendarEl) {
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
cells.forEach((cell) => cell.classList.remove("fc-day-blocked"));
}
return;
}
const calendarInstance = calendar;
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
const ausenciasBloqueantes = ausenciasExistentes.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
);
if (ausenciasBloqueantes.length === 0) {
cells.forEach((cell) => cell.classList.remove("fc-day-blocked"));
return;
}
cells.forEach((cell) => {
cell.classList.remove("fc-day-blocked");
// Tentar obter a data de diferentes formas
let cellDate: SvelteDate | null = null;
// Método 1: aria-label
const ariaLabel = cell.getAttribute("aria-label");
if (ariaLabel) {
try {
const parsed = new SvelteDate(ariaLabel);
if (!isNaN(parsed.getTime())) {
cellDate = parsed;
}
} catch {
// Ignorar
}
}
// Método 2: data-date attribute
if (!cellDate) {
const dataDate = cell.getAttribute("data-date");
if (dataDate) {
try {
const parsed = new SvelteDate(dataDate);
if (!isNaN(parsed.getTime())) {
cellDate = parsed;
}
} catch {
// Ignorar
}
}
}
// Método 3: Tentar obter do número do dia e contexto do calendário
if (!cellDate && calendarInstance.view) {
const dayNumberEl = cell.querySelector(".fc-daygrid-day-number");
if (dayNumberEl) {
const dayNumber = parseInt(dayNumberEl.textContent || "0");
if (dayNumber > 0 && dayNumber <= 31) {
// Usar a data da view atual e o número do dia
const viewStart = new SvelteDate(calendarInstance.view.activeStart);
const cellIndex = Array.from(cells).indexOf(cell);
if (cellIndex >= 0) {
const possibleDate = new SvelteDate(viewStart);
possibleDate.setDate(viewStart.getDate() + cellIndex);
// Verificar se o número do dia corresponde
if (possibleDate.getDate() === dayNumber) {
cellDate = possibleDate;
}
}
}
}
}
if (cellDate) {
cellDate.setHours(0, 0, 0, 0);
const estaBloqueado = ausenciasBloqueantes.some((ausencia) => {
const inicio = new SvelteDate(ausencia.dataInicio);
const fim = new SvelteDate(ausencia.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
return cellDate! >= inicio && cellDate! <= fim;
});
if (estaBloqueado) {
cell.classList.add("fc-day-blocked");
}
}
});
}
// Atualizar eventos quando mudanças ocorrem (evitar loop infinito)
$effect(() => {
if (!calendar || selecionando) return; // Não atualizar durante seleção
// Garantir que temos as ausências antes de atualizar
void ausenciasExistentes;
// Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção
requestAnimationFrame(() => {
if (calendar && !selecionando) {
calendar.removeAllEvents();
calendar.addEventSource(eventos);
// Atualizar classes de seleção e bloqueio quando as datas mudarem
setTimeout(() => {
atualizarDiasSelecionados();
atualizarDiasBloqueados();
}, 150);
}
});
});
// Efeito separado para atualizar quando ausências mudarem
$effect(() => {
if (!calendar || readonly) return;
const ausencias = ausenciasExistentes;
const ausenciasBloqueantes =
ausencias?.filter(
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
) || [];
// Se houver ausências bloqueantes, forçar atualização
if (ausenciasBloqueantes.length > 0) {
setTimeout(() => {
if (calendar && calendarEl) {
atualizarDiasBloqueados();
// Forçar re-render para aplicar classes via dayCellClassNames
calendar.render();
}
}, 200);
}
});
onMount(() => {
if (!calendarEl) return;
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
initialView:
modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
locale: ptBrLocale,
headerToolbar: {
left: "prev,next today",
center: "title",
right:
modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
},
height: "auto",
selectable: !readonly,
selectMirror: true,
unselectAuto: false,
selectOverlap: false,
selectConstraint: undefined, // Permite seleção entre meses diferentes
validRange: {
start: new SvelteDate().toISOString().split("T")[0], // Não permite selecionar datas passadas
},
events: eventos,
// Estilo customizado
buttonText: {
today: "Hoje",
month: "Mês",
multiMonthYear: "Ano",
},
// Seleção de período
select: (info) => {
if (readonly) return;
selecionando = true; // Marcar que está selecionando
// Usar setTimeout para evitar conflito com atualizações de estado
setTimeout(() => {
const inicio = new SvelteDate(info.startStr);
const fim = new SvelteDate(info.endStr);
fim.setDate(fim.getDate() - 1); // FullCalendar usa exclusive end
// Validar que não é no passado
const hoje = new SvelteDate();
hoje.setHours(0, 0, 0, 0);
if (inicio < hoje) {
alert("A data de início não pode ser no passado");
calendar?.unselect();
selecionando = false;
return;
}
// Validar que fim >= início
if (fim < inicio) {
alert("A data de fim deve ser maior ou igual à data de início");
calendar?.unselect();
selecionando = false;
return;
}
// Validar sobreposição com ausências existentes
if (verificarSobreposicaoComAusencias(inicio, fim)) {
alert(
"Este período sobrepõe com uma ausência já aprovada ou aguardando aprovação. Por favor, escolha outro período.",
);
calendar?.unselect();
selecionando = false;
return;
}
// Chamar callback de forma assíncrona para evitar loop
if (onPeriodoSelecionado) {
onPeriodoSelecionado({
dataInicio: info.startStr,
dataFim: fim.toISOString().split("T")[0],
});
}
// Não remover seleção imediatamente para manter visualização
// calendar?.unselect();
// Liberar flag após um pequeno delay para garantir que o estado foi atualizado
setTimeout(() => {
selecionando = false;
}, 100);
}, 0);
},
// Click em evento para visualizar detalhes (readonly)
eventClick: (info) => {
if (readonly) {
const status = info.event.extendedProps.status;
const texto = getStatusTexto(status);
alert(
`Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString("pt-BR")} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString("pt-BR")}`,
);
}
},
// Tooltip ao passar mouse
eventDidMount: (info) => {
const status = info.event.extendedProps.status;
if (status === "selecionado") {
info.el.title = `Período selecionado\n${info.event.title}`;
} else {
info.el.title = `${info.event.title}`;
}
info.el.style.cursor = readonly ? "default" : "pointer";
},
// Desabilitar datas passadas e períodos que sobrepõem com ausências existentes
selectAllow: (selectInfo) => {
const hoje = new SvelteDate();
hoje.setHours(0, 0, 0, 0);
// Bloquear datas passadas
if (new SvelteDate(selectInfo.start) < hoje) {
return false;
}
// Verificar sobreposição com ausências existentes
if (
!readonly &&
ausenciasExistentes &&
ausenciasExistentes.length > 0
) {
const inicioSelecao = new SvelteDate(selectInfo.start);
const fimSelecao = new SvelteDate(selectInfo.end);
fimSelecao.setDate(fimSelecao.getDate() - 1); // FullCalendar usa exclusive end
inicioSelecao.setHours(0, 0, 0, 0);
fimSelecao.setHours(0, 0, 0, 0);
if (verificarSobreposicaoComAusencias(inicioSelecao, fimSelecao)) {
return false;
}
}
return true;
},
// Adicionar classe CSS aos dias selecionados e bloqueados
dayCellDidMount: (info) => {
atualizarClasseSelecionado(info);
atualizarClasseBloqueado(info);
},
// Atualizar quando as datas mudarem (navegação do calendário)
datesSet: () => {
setTimeout(() => {
atualizarDiasSelecionados();
atualizarDiasBloqueados();
}, 100);
},
// Garantir que as classes sejam aplicadas após renderização inicial
viewDidMount: () => {
setTimeout(() => {
if (calendar && calendarEl) {
atualizarDiasSelecionados();
atualizarDiasBloqueados();
}
}, 100);
},
// Highlight de fim de semana e aplicar classe de bloqueio
dayCellClassNames: (arg) => {
const classes: string[] = [];
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
classes.push("fc-day-weekend-custom");
}
// Verificar se o dia está bloqueado
if (
!readonly &&
ausenciasExistentes &&
ausenciasExistentes.length > 0
) {
const cellDate = new SvelteDate(arg.date);
cellDate.setHours(0, 0, 0, 0);
const ausenciasBloqueantes = ausenciasExistentes.filter(
(a) =>
a.status === "aprovado" || a.status === "aguardando_aprovacao",
);
const estaBloqueado = ausenciasBloqueantes.some((ausencia) => {
const inicio = new SvelteDate(ausencia.dataInicio);
const fim = new SvelteDate(ausencia.dataFim);
inicio.setHours(0, 0, 0, 0);
fim.setHours(0, 0, 0, 0);
return cellDate >= inicio && cellDate <= fim;
});
if (estaBloqueado) {
classes.push("fc-day-blocked");
}
}
return classes;
},
});
calendar.render();
return () => {
calendar?.destroy();
};
});
</script>
<div class="calendario-ausencias-wrapper">
<!-- Header com instruções -->
{#if !readonly}
<div class="space-y-4 mb-4">
<div class="alert alert-info shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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 class="text-sm">
<p class="font-bold">Como usar:</p>
<ul class="list-disc list-inside mt-1">
<li>
Clique e arraste no calendário para selecionar o período de
ausência
</li>
<li>
Você pode visualizar suas ausências já solicitadas no calendário
</li>
<li>A data de início não pode ser no passado</li>
</ul>
</div>
</div>
<!-- Alerta sobre dias bloqueados -->
{#if ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
<div class="alert alert-warning shadow-lg border-2 border-warning/50">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
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 class="flex-1">
<h3 class="font-bold">Atenção: Períodos Indisponíveis</h3>
<div class="text-sm mt-1">
<p>
Os dias marcados em <span class="font-bold text-error"
>vermelho</span
>
estão bloqueados porque você já possui solicitações
<strong>aprovadas</strong>
ou <strong>aguardando aprovação</strong> para esses períodos.
</p>
<p class="mt-2">
Você não pode criar novas solicitações que sobreponham esses
períodos. Escolha um período diferente.
</p>
</div>
</div>
</div>
{/if}
</div>
{/if}
<!-- Calendário -->
<div
bind:this={calendarEl}
class="calendario-ausencias shadow-2xl rounded-2xl overflow-hidden border-2 border-orange-500/10"
></div>
<!-- Legenda de status -->
{#if ausenciasExistentes.length > 0 || readonly}
<div class="mt-6 space-y-4">
<div class="flex flex-wrap gap-4 justify-center">
<div
class="badge badge-lg gap-2"
style="background-color: #f59e0b; border-color: #d97706; color: white;"
>
<div class="w-3 h-3 rounded-full bg-white"></div>
Aguardando Aprovação
</div>
<div
class="badge badge-lg gap-2"
style="background-color: #10b981; border-color: #059669; color: white;"
>
<div class="w-3 h-3 rounded-full bg-white"></div>
Aprovado
</div>
<div
class="badge badge-lg gap-2"
style="background-color: #ef4444; border-color: #dc2626; color: white;"
>
<div class="w-3 h-3 rounded-full bg-white"></div>
Reprovado
</div>
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
<div
class="badge badge-lg gap-2"
style="background-color: rgba(239, 68, 68, 0.2); border-color: #ef4444; color: #dc2626;"
>
<div
class="w-3 h-3 rounded-full"
style="background-color: #ef4444;"
></div>
Dias Bloqueados (Indisponíveis)
</div>
{/if}
</div>
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
<div class="text-center">
<p class="text-sm text-base-content/70">
<span class="font-semibold text-error">Dias bloqueados</span> não podem
ser selecionados para novas solicitações
</p>
</div>
{/if}
</div>
{/if}
<!-- Informação do período selecionado -->
{#if dataInicio && dataFim && !readonly}
<div
class="mt-6 card shadow-lg border border-orange-400"
>
<div class="card-body">
<h3 class="card-title">
<svg
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="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 Selecionado
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
<div>
<p class="text-sm text-base-content/70">Data Início</p>
<p class="font-bold text-lg">
{new Date(dataInicio).toLocaleDateString("pt-BR")}
</p>
</div>
<div>
<p class="text-sm text-base-content/70">Data Fim</p>
<p class="font-bold text-lg">
{new Date(dataFim).toLocaleDateString("pt-BR")}
</p>
</div>
<div>
<p class="text-sm text-base-content/70">Total de Dias</p>
<p class="font-bold text-2xl text-orange-600 dark:text-orange-400">
{calcularDias(dataInicio, dataFim)} dias
</p>
</div>
</div>
</div>
</div>
{/if}
</div>
<style>
/* Calendário Premium */
.calendario-ausencias {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
}
/* Toolbar moderna com cores laranja/amarelo */
:global(.calendario-ausencias .fc .fc-toolbar) {
background: linear-gradient(135deg, #f59e0b 0%, #f97316 100%);
padding: 1rem;
border-radius: 1rem 1rem 0 0;
color: white !important;
}
:global(.calendario-ausencias .fc .fc-toolbar-title) {
color: white !important;
font-weight: 700;
font-size: 1.5rem;
}
:global(.calendario-ausencias .fc .fc-button) {
background: rgba(255, 255, 255, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
font-weight: 600;
text-transform: capitalize;
transition: all 0.3s ease;
}
:global(.calendario-ausencias .fc .fc-button:hover) {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
:global(.calendario-ausencias .fc .fc-button-active) {
background: rgba(255, 255, 255, 0.4) !important;
}
/* Cabeçalho dos dias */
:global(.calendario-ausencias .fc .fc-col-header-cell) {
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
padding: 0.75rem 0.5rem;
color: #495057;
}
/* Células dos dias */
:global(.calendario-ausencias .fc .fc-daygrid-day) {
transition: all 0.2s ease;
}
:global(.calendario-ausencias .fc .fc-daygrid-day:hover) {
background: rgba(245, 158, 11, 0.05);
}
:global(.calendario-ausencias .fc .fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 600;
color: #495057;
}
/* Fim de semana */
:global(.calendario-ausencias .fc .fc-day-weekend-custom) {
background: rgba(255, 193, 7, 0.05);
}
/* Hoje */
:global(.calendario-ausencias .fc .fc-day-today) {
background: rgba(245, 158, 11, 0.1) !important;
border: 2px solid #f59e0b !important;
}
/* Eventos (ausências) */
:global(.calendario-ausencias .fc .fc-event) {
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
cursor: pointer;
}
:global(.calendario-ausencias .fc .fc-event:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* Seleção (arrastar) */
:global(.calendario-ausencias .fc .fc-highlight) {
background: rgba(245, 158, 11, 0.3) !important;
border: 2px dashed #f59e0b;
}
/* Dias selecionados (período confirmado) */
:global(.calendario-ausencias .fc .fc-day-selected) {
background: rgba(102, 126, 234, 0.2) !important;
border: 2px solid #667eea !important;
position: relative;
}
:global(.calendario-ausencias .fc .fc-day-selected .fc-daygrid-day-number) {
color: #667eea !important;
font-weight: 700 !important;
background: rgba(102, 126, 234, 0.1);
border-radius: 50%;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
}
/* Primeiro e último dia do período selecionado */
:global(.calendario-ausencias .fc .fc-day-selected:first-child),
:global(.calendario-ausencias .fc .fc-day-selected:last-child) {
background: rgba(102, 126, 234, 0.3) !important;
border-color: #667eea !important;
}
/* Dias bloqueados (com ausências aprovadas ou aguardando aprovação) */
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked) {
background-color: rgba(239, 68, 68, 0.2) !important;
position: relative !important;
}
:global(
.calendario-ausencias .fc-daygrid-day.fc-day-blocked .fc-daygrid-day-frame
) {
background-color: rgba(239, 68, 68, 0.2) !important;
border-color: rgba(239, 68, 68, 0.4) !important;
}
:global(
.calendario-ausencias
.fc-daygrid-day.fc-day-blocked
.fc-daygrid-day-number
) {
color: #dc2626 !important;
font-weight: 700 !important;
text-decoration: line-through !important;
background-color: rgba(239, 68, 68, 0.1) !important;
border-radius: 50% !important;
padding: 0.25rem !important;
}
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked::before) {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: repeating-linear-gradient(
45deg,
transparent,
transparent 6px,
rgba(239, 68, 68, 0.15) 6px,
rgba(239, 68, 68, 0.15) 12px
);
pointer-events: none;
z-index: 1;
border-radius: inherit;
}
/* Datas desabilitadas (passado) */
:global(.calendario-ausencias .fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;
}
/* Remover bordas padrão */
:global(.calendario-ausencias .fc .fc-scrollgrid) {
border: none !important;
}
:global(.calendario-ausencias .fc .fc-scrollgrid-section > td) {
border: none !important;
}
/* Grid moderno */
:global(.calendario-ausencias .fc .fc-daygrid-day-frame) {
border: 1px solid #e9ecef;
min-height: 80px;
}
/* Responsivo */
@media (max-width: 768px) {
:global(.calendario-ausencias .fc .fc-toolbar) {
flex-direction: column;
gap: 0.75rem;
}
:global(.calendario-ausencias .fc .fc-toolbar-title) {
font-size: 1.25rem;
}
:global(.calendario-ausencias .fc .fc-button) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,487 @@
<script lang="ts">
import { useConvexClient, useQuery } from 'convex-svelte';
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 { SvelteDate } from 'svelte/reactivity';
interface Props {
funcionarioId: Id<'funcionarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
// Cliente Convex
const client = useConvexClient();
// Estado do wizard
let passoAtual = $state(1);
const totalPassos = 2;
// Dados da solicitação
let dataInicio = $state<string>('');
let dataFim = $state<string>('');
let motivo = $state('');
let processando = $state(false);
// Estados para modal de erro
let mostrarModalErro = $state(false);
let mensagemErroModal = $state('');
let detalhesErroModal = $state('');
// Buscar ausências existentes para exibir no calendário
const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, {
funcionarioId
});
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
const ausenciasExistentes = $derived(
(ausenciasExistentesQuery?.data || [])
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
.map((a) => ({
dataInicio: a.dataInicio,
dataFim: a.dataFim,
status: a.status as 'aguardando_aprovacao' | 'aprovado'
}))
);
// Calcular dias selecionados
function calcularDias(inicio: string, fim: string): number {
if (!inicio || !fim) return 0;
const dInicio = new Date(inicio);
const dFim = new Date(fim);
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
}
const totalDias = $derived(calcularDias(dataInicio, dataFim));
// Funções de navegação
function proximoPasso() {
if (passoAtual === 1) {
if (!dataInicio || !dataFim) {
toast.error('Selecione o período de ausência no calendário');
return;
}
const hoje = new SvelteDate();
hoje.setHours(0, 0, 0, 0);
const inicio = new Date(dataInicio);
if (inicio < hoje) {
toast.error('A data de início não pode ser no passado');
return;
}
if (new Date(dataFim) < new Date(dataInicio)) {
toast.error('A data de fim deve ser maior ou igual à data de início');
return;
}
}
if (passoAtual < totalPassos) {
passoAtual++;
}
}
function passoAnterior() {
if (passoAtual > 1) {
passoAtual--;
}
}
async function enviarSolicitacao() {
if (!dataInicio || !dataFim) {
toast.error('Selecione o período de ausência');
return;
}
if (!motivo.trim() || motivo.trim().length < 10) {
toast.error('O motivo deve ter no mínimo 10 caracteres');
return;
}
try {
processando = true;
mostrarModalErro = false;
mensagemErroModal = '';
await client.mutation(api.ausencias.criarSolicitacao, {
funcionarioId,
dataInicio,
dataFim,
motivo: motivo.trim()
});
toast.success('Solicitação de ausência criada com sucesso!');
if (onSucesso) {
onSucesso();
}
} catch (error) {
console.error('Erro ao criar solicitação:', error);
const mensagemErro = error instanceof Error ? error.message : String(error);
// Verificar se é erro de sobreposição de período
if (
mensagemErro.includes('Já existe uma solicitação') ||
mensagemErro.includes('já existe') ||
mensagemErro.includes('solicitação aprovada ou pendente')
) {
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.`;
mostrarModalErro = true;
} else {
// Outros erros continuam usando toast
toast.error(mensagemErro);
}
} finally {
processando = false;
}
}
function fecharModalErro() {
mostrarModalErro = false;
mensagemErroModal = '';
detalhesErroModal = '';
}
function handlePeriodoSelecionado(periodo: { dataInicio: string; dataFim: string }) {
dataInicio = periodo.dataInicio;
dataFim = periodo.dataFim;
}
</script>
<div class="wizard-ausencia">
<!-- Header -->
<div class="mb-6">
<p class="text-base-content/70">Solicite uma ausência para assuntos particulares</p>
</div>
<!-- Indicador de progresso -->
<div class="steps mb-8">
<div class="step {passoAtual >= 1 ? 'step-primary' : ''}">
<div class="step-item">
<div class="step-marker">
{#if passoAtual > 1}
<svg
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}
{passoAtual}
{/if}
</div>
<div class="step-content">
<div class="step-title">Selecionar Período</div>
<div class="step-description">Escolha as datas no calendário</div>
</div>
</div>
</div>
<div class="step {passoAtual >= 2 ? 'step-primary' : ''}">
<div class="step-item">
<div class="step-marker">
{#if passoAtual > 2}
<svg
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}
2
{/if}
</div>
<div class="step-content">
<div class="step-title">Informar Motivo</div>
<div class="step-description">Descreva o motivo da ausência</div>
</div>
</div>
</div>
</div>
<!-- Conteúdo dos passos -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{#if passoAtual === 1}
<!-- Passo 1: Selecionar Período -->
<div class="space-y-6">
<div>
<h3 class="mb-2 text-2xl font-bold">Selecione o Período</h3>
<p class="text-base-content/70">
Clique e arraste no calendário para selecionar o período de ausência
</p>
</div>
{#if ausenciasExistentesQuery === undefined}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-base-content/70 ml-4">Carregando ausências existentes...</span>
</div>
{:else}
<CalendarioAusencias
{dataInicio}
{dataFim}
{ausenciasExistentes}
onPeriodoSelecionado={handlePeriodoSelecionado}
/>
{/if}
{#if dataInicio && dataFim}
<div class="alert alert-success shadow-lg">
<svg
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>
<h4 class="font-bold">Período selecionado!</h4>
<p>
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
</p>
</div>
</div>
{/if}
</div>
{:else if passoAtual === 2}
<!-- Passo 2: Informar Motivo -->
<div class="space-y-6">
<div>
<h3 class="mb-2 text-2xl font-bold">Informe o Motivo</h3>
<p class="text-base-content/70">
Descreva o motivo da sua solicitação de ausência (mínimo 10 caracteres)
</p>
</div>
<!-- Resumo do período -->
{#if dataInicio && dataFim}
<div
class="card border-2 border-base-content/20"
>
<div class="card-body">
<h4 class="card-title text-orange-700 dark:text-orange-400">
<svg
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
</h4>
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
<div>
<p class="text-base-content/70 text-sm">Data Início</p>
<p class="font-bold">
{new Date(dataInicio).toLocaleDateString('pt-BR')}
</p>
</div>
<div>
<p class="text-base-content/70 text-sm">Data Fim</p>
<p class="font-bold">
{new Date(dataFim).toLocaleDateString('pt-BR')}
</p>
</div>
<div>
<p class="text-base-content/70 text-sm">Total de Dias</p>
<p class="text-xl font-bold text-orange-600 dark:text-orange-400">
{totalDias} dias
</p>
</div>
</div>
</div>
</div>
{/if}
<!-- Campo de motivo -->
<div class="form-control">
<label class="label" for="motivo">
<span class="label-text font-bold">Motivo da Ausência</span>
<span class="label-text-alt">
{motivo.trim().length}/10 caracteres mínimos
</span>
</label>
<textarea
id="motivo"
class="textarea textarea-bordered h-32 text-lg"
placeholder="Descreva o motivo da sua solicitação de ausência..."
bind:value={motivo}
maxlength={500}
></textarea>
<label class="label" for="motivo">
<span class="label-text-alt text-base-content/70">
Mínimo 10 caracteres. Seja claro e objetivo.
</span>
</label>
</div>
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
<div class="alert alert-warning shadow-lg">
<svg
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>
</div>
{/if}
</div>
{/if}
<!-- Botões de navegação -->
<div class="card-actions mt-6 justify-between">
<button
type="button"
class="btn"
onclick={passoAnterior}
disabled={passoAtual === 1 || processando}
>
<svg
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
</button>
{#if passoAtual < totalPassos}
<button
type="button"
class="btn btn-primary"
onclick={proximoPasso}
disabled={processando}
>
Próximo
<svg
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>
{:else}
<button
type="button"
class="btn btn-success"
onclick={enviarSolicitacao}
disabled={processando || motivo.trim().length < 10}
>
{#if processando}
<span class="loading loading-spinner"></span>
Enviando...
{:else}
<svg
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
{/if}
</button>
{/if}
</div>
<!-- Botão cancelar -->
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-sm"
onclick={() => {
if (onCancelar) onCancelar();
}}
disabled={processando}
>
Cancelar
</button>
</div>
</div>
</div>
</div>
<!-- Modal de Erro -->
<ErrorModal
open={mostrarModalErro}
title="Período Indisponível"
message={mensagemErroModal || 'Já existe uma solicitação para este período.'}
details={detalhesErroModal}
onClose={fecharModalErro}
/>
<style>
.wizard-ausencia {
max-width: 1000px;
margin: 0 auto;
}
</style>

View File

@@ -0,0 +1,183 @@
<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,107 @@
<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-xs uppercase tracking-wide text-base-content/50">
Ticket {ticket.numero}
</p>
<h3 class="text-lg font-semibold text-base-content">{ticket.titulo}</h3>
</div>
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
</div>
<p class="text-base-content/60 mt-2 text-sm line-clamp-2">{ticket.descricao}</p>
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
<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="mt-4 space-y-1 text-xs text-base-content/50">
<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,308 @@
<script lang="ts">
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
import { createEventDispatcher } from "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 font-semibold text-base-content">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 font-semibold text-base-content">Tipo de solicitação</span>
</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-base-300 bg-base-200/30 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="text-base shrink-0">{opcao.icon}</span>
<span class="text-sm font-medium flex-1 text-center">{opcao.label}</span>
</label>
{/each}
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold text-base-content">Prioridade</span>
</label>
<div class="grid grid-cols-2 gap-2 rounded-xl border border-base-300 bg-base-200/30 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 font-semibold text-base-content">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 font-semibold text-base-content">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="space-y-4 rounded-xl border border-base-300 bg-base-200/30 p-4">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-base-content">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">
<svg
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="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
/>
</svg>
Selecionar arquivos
<input type="file" class="hidden" multiple accept=".pdf,.png,.jpg,.jpeg" onchange={handleFiles} />
</label>
</div>
{#if anexos.length > 0}
<div class="space-y-2 rounded-2xl border border-base-200 bg-base-100/70 p-4">
{#each anexos as file, index (file.name + index)}
<div class="flex items-center justify-between gap-3 rounded-xl border border-base-200 bg-base-100 px-3 py-2">
<div>
<p class="text-sm font-medium">{file.name}</p>
<p class="text-xs text-base-content/60">
{(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="rounded-2xl border border-dashed border-base-300 bg-base-100/50 p-6 text-center text-sm text-base-content/60">
Nenhum arquivo selecionado.
</div>
{/if}
</section>
<!-- Ações do Formulário -->
<section class="flex flex-wrap gap-3 border-t border-base-300 pt-6">
<button
type="submit"
class="btn btn-primary flex-1 min-w-[200px] shadow-lg"
disabled={loading}
>
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
<svg
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 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
Registrar chamado
{/if}
</button>
<button
type="button"
class="btn btn-ghost"
onclick={resetForm}
disabled={loading}
>
<svg
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>
Limpar
</button>
</section>
</form>

View File

@@ -0,0 +1,86 @@
<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>();
const 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="flex-1 rounded-2xl border border-base-200 bg-base-100/80 p-4 shadow-sm">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-base-content">
{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 uppercase tracking-wide">
{getPrazoDescricao(entry)}
</p>
</div>
</div>
{/each}
{/if}
</div>

View File

@@ -6,39 +6,95 @@
import { ptBR } from "date-fns/locale";
import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte";
import NewConversationModal from "./NewConversationModal.svelte";
const client = useConvexClient();
// Buscar todos os usuários para o chat
const usuarios = useQuery(api.usuarios.listarParaChat, {});
// Buscar o perfil do usuário logado
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
// Buscar conversas (grupos e salas de reunião)
const conversas = useQuery(api.chat.listarConversas, {});
let searchQuery = $state("");
let activeTab = $state<"usuarios" | "conversas">("usuarios");
// Debug: monitorar carregamento de dados
$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: 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(() => {
if (!usuarios?.data || !Array.isArray(usuarios.data) || !meuPerfil?.data) return [];
// Filtrar o próprio usuário da lista
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuPerfil.data._id);
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
if (!meuPerfil?.data) {
console.log("⏳ [ChatList] Aguardando perfil do usuário...");
return [];
}
const meuId = meuPerfil.data._id;
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
// Log se ainda estiver na lista após filtro (não deveria acontecer)
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId);
if (aindaNaLista) {
console.error(
"❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!",
);
}
// Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
listaFiltrada = listaFiltrada.filter((u: any) =>
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
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
return listaFiltrada.sort((a: any, b: 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;
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;
if (statusA !== statusB) return statusA - statusB;
return a.nome.localeCompare(b.nome);
});
@@ -56,18 +112,47 @@
}
}
let processando = $state(false);
let showNewConversationModal = $state(false);
async function handleClickUsuario(usuario: any) {
if (processando) {
console.log("⏳ Já está processando uma ação, aguarde...");
return;
}
try {
processando = true;
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
// Criar ou buscar conversa individual com este usuário
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
outroUsuarioId: usuario._id,
});
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
const conversaId = await client.mutation(
api.chat.criarOuBuscarConversaIndividual,
{
outroUsuarioId: usuario._id,
},
);
console.log("✅ Conversa criada/encontrada. ID:", conversaId);
// Abrir a conversa
console.log("📂 Abrindo conversa...");
abrirConversa(conversaId as any);
console.log("✅ Conversa aberta com sucesso!");
} catch (error) {
console.error("Erro ao abrir conversa:", error);
alert("Erro ao abrir conversa");
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;
}
}
@@ -81,6 +166,38 @@
};
return labels[status || "offline"] || "Offline";
}
// Filtrar conversas por tipo e busca
const conversasFiltradas = $derived(() => {
if (!conversas?.data) return [];
let lista = conversas.data.filter(
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao",
);
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
}
return lista;
});
function handleClickConversa(conversa: any) {
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>
<div class="flex flex-col h-full">
@@ -110,85 +227,288 @@
</div>
</div>
<!-- Título da Lista -->
<div class="p-4 border-b border-base-300 bg-base-200">
<h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wide">
Usuários do Sistema ({usuariosFiltrados.length})
</h3>
</div>
<!-- Tabs e Título -->
<div class="border-b border-base-300 bg-base-200">
<!-- Tabs -->
<div class="tabs tabs-boxed p-2">
<button
type="button"
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
onclick={() => (activeTab = "usuarios")}
>
👥 Usuários ({usuariosFiltrados.length})
</button>
<button
type="button"
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
onclick={() => (activeTab = "conversas")}
>
💬 Conversas ({conversasFiltradas().length})
</button>
</div>
<!-- Lista de usuários -->
<div class="flex-1 overflow-y-auto">
{#if usuarios?.data && usuariosFiltrados.length > 0}
{#each usuariosFiltrados as usuario (usuario._id)}
<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"
onclick={() => handleClickUsuario(usuario)}
>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<!-- Status badge -->
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
</div>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{usuario.nome}
</p>
<span class="text-xs px-2 py-0.5 rounded-full {
usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
'bg-base-300 text-base-content/50'
}">
{getStatusLabel(usuario.statusPresenca)}
</span>
</div>
<div class="flex items-center gap-2">
<p class="text-sm text-base-content/70 truncate">
{usuario.statusMensagem || usuario.email}
</p>
</div>
</div>
</button>
{/each}
{:else if !usuarios?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhum usuário encontrado -->
<div class="flex flex-col items-center justify-center h-full text-center px-4">
<!-- Botão Nova Conversa -->
<div class="px-4 pb-2 flex justify-end">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={() => (showNewConversationModal = true)}
title="Nova conversa (grupo ou sala de reunião)"
aria-label="Nova conversa"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke-width="2"
stroke="currentColor"
class="w-16 h-16 text-base-content/30 mb-4"
class="w-4 h-4 mr-1"
>
<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"
d="M12 4.5v15m7.5-7.5h-15"
/>
</svg>
<p class="text-base-content/70">Nenhum usuário encontrado</p>
</div>
Nova Conversa
</button>
</div>
</div>
<!-- Lista de conteúdo -->
<div class="flex-1 overflow-y-auto">
{#if activeTab === "usuarios"}
<!-- Lista de usuários -->
{#if usuarios?.data && usuariosFiltrados.length > 0}
{#each usuariosFiltrados as usuario (usuario._id)}
<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
? 'opacity-50 cursor-wait'
: 'cursor-pointer'}"
onclick={() => handleClickUsuario(usuario)}
disabled={processando}
>
<!-- Ícone de mensagem -->
<div
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
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);"
>
<svg
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="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 -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<!-- Status badge -->
<div class="absolute bottom-0 right-0">
<UserStatusBadge
status={usuario.statusPresenca || "offline"}
size="sm"
/>
</div>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{usuario.nome}
</p>
<span
class="text-xs px-2 py-0.5 rounded-full {usuario.statusPresenca ===
'online'
? 'bg-success/20 text-success'
: usuario.statusPresenca === 'ausente'
? 'bg-warning/20 text-warning'
: usuario.statusPresenca === 'em_reuniao'
? 'bg-error/20 text-error'
: 'bg-base-300 text-base-content/50'}"
>
{getStatusLabel(usuario.statusPresenca)}
</span>
</div>
<div class="flex items-center gap-2">
<p class="text-sm text-base-content/70 truncate">
{usuario.statusMensagem || usuario.email}
</p>
</div>
</div>
</button>
{/each}
{:else if !usuarios?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhum usuário encontrado -->
<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="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>
<p class="text-base-content/70">Nenhum usuário encontrado</p>
</div>
{/if}
{:else}
<!-- Lista de conversas (grupos e salas) -->
{#if conversas?.data && conversasFiltradas().length > 0}
{#each conversasFiltradas() as conversa (conversa._id)}
<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
? 'opacity-50 cursor-wait'
: 'cursor-pointer'}"
onclick={() => handleClickConversa(conversa)}
disabled={processando}
>
<!-- Í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 -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between mb-1">
<p class="font-semibold text-base-content truncate">
{conversa.nome ||
(conversa.tipo === "sala_reuniao"
? "Sala sem nome"
: "Grupo sem nome")}
</p>
{#if conversa.naoLidas > 0}
<span class="badge badge-primary badge-sm"
>{conversa.naoLidas}</span
>
{/if}
</div>
<div class="flex items-center gap-2">
<span
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo ===
'sala_reuniao'
? 'bg-blue-500/20 text-blue-500'
: 'bg-primary/20 text-primary'}"
>
{conversa.tipo === "sala_reuniao"
? "👑 Sala de Reunião"
: "👥 Grupo"}
</span>
{#if conversa.participantesInfo}
<span class="text-xs text-base-content/50">
{conversa.participantesInfo.length} participante{conversa
.participantesInfo.length !== 1
? "s"
: ""}
</span>
{/if}
</div>
</div>
</button>
{/each}
{:else if !conversas?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhuma conversa encontrada -->
<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>
<!-- Modal de Nova Conversa -->
{#if showNewConversationModal}
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -1,169 +1,483 @@
<script lang="ts">
import { useQuery } from "convex-svelte";
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 MessageList from "./MessageList.svelte";
import MessageInput from "./MessageInput.svelte";
import UserStatusBadge from "./UserStatusBadge.svelte";
import ScheduleMessageModal from "./ScheduleMessageModal.svelte";
import { useQuery, useConvexClient } from 'convex-svelte';
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 MessageList from './MessageList.svelte';
import MessageInput from './MessageInput.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
interface Props {
conversaId: string;
}
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
let { conversaId }: Props = $props();
let showScheduleModal = $state(false);
interface Props {
conversaId: string;
}
const conversas = useQuery(api.chat.listarConversas, {});
let { conversaId }: Props = $props();
const conversa = $derived(() => {
if (!conversas) return null;
return conversas.find((c: any) => c._id === conversaId);
});
const client = useConvexClient();
function getNomeConversa(): string {
const c = conversa();
if (!c) return "Carregando...";
if (c.tipo === "grupo") {
return c.nome || "Grupo sem nome";
}
return c.outroUsuario?.nome || "Usuário";
}
// Token é passado automaticamente via interceptadores em +layout.svelte
function getAvatarConversa(): string {
const c = conversa();
if (!c) return "💬";
if (c.tipo === "grupo") {
return c.avatar || "👥";
}
if (c.outroUsuario?.avatar) {
return c.outroUsuario.avatar;
}
return "👤";
}
let showScheduleModal = $state(false);
let showSalaManager = $state(false);
let showAdminMenu = $state(false);
let showNotificacaoModal = $state(false);
function getStatusConversa(): any {
const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) {
return c.outroUsuario.statusPresenca || "offline";
}
return null;
}
const conversas = useQuery(api.chat.listarConversas, {});
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
conversaId: conversaId as Id<'conversas'>
});
function getStatusMensagem(): string | null {
const c = conversa();
if (c && c.tipo === "individual" && c.outroUsuario) {
return c.outroUsuario.statusMensagem || null;
}
return null;
}
const conversa = $derived(() => {
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
if (!conversas?.data || !Array.isArray(conversas.data)) {
console.log('⚠️ [ChatWindow] conversas.data não é um array ou está vazio');
return null;
}
const encontrada = conversas.data.find((c: { _id: string }) => c._id === conversaId);
console.log('✅ [ChatWindow] Conversa encontrada:', encontrada);
return encontrada;
});
function getNomeConversa(): string {
const c = conversa();
if (!c) return 'Carregando...';
if (c.tipo === 'grupo' || c.tipo === 'sala_reuniao') {
return c.nome || (c.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome');
}
return c.outroUsuario?.nome || 'Usuário';
}
function getAvatarConversa(): string {
const c = conversa();
if (!c) return '💬';
if (c.tipo === 'grupo') {
return '👥';
}
return '👤';
}
function getStatusConversa(): 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao' | null {
const c = conversa();
if (c && c.tipo === 'individual' && c.outroUsuario) {
return (
(c.outroUsuario.statusPresenca as
| 'online'
| 'offline'
| 'ausente'
| 'externo'
| 'em_reuniao') || 'offline'
);
}
return null;
}
function getStatusMensagem(): string | null {
const c = conversa();
if (c && c.tipo === 'individual' && c.outroUsuario) {
return c.outroUsuario.statusMensagem || null;
}
return null;
}
async function handleSairGrupoOuSala() {
const c = conversa();
if (!c || (c.tipo !== 'grupo' && c.tipo !== 'sala_reuniao')) return;
const tipoTexto = c.tipo === 'sala_reuniao' ? 'sala de reunião' : 'grupo';
if (!confirm(`Tem certeza que deseja sair da ${tipoTexto} "${c.nome || 'Sem nome'}"?`)) {
return;
}
try {
const resultado = await client.mutation(api.chat.sairGrupoOuSala, {
conversaId: conversaId as Id<'conversas'>
});
if (resultado.sucesso) {
voltarParaLista();
} else {
alert(resultado.erro || 'Erro ao sair da conversa');
}
} catch (error) {
console.error('Erro ao sair da conversa:', error);
const errorMessage = error instanceof Error ? error.message : 'Erro ao sair da conversa';
alert(errorMessage);
}
}
</script>
<div class="flex flex-col h-full">
<!-- Header -->
<div class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200">
<!-- Botão Voltar -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={voltarParaLista}
aria-label="Voltar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"
/>
</svg>
</button>
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
<!-- Header -->
<div
class="border-base-300 bg-base-200 flex items-center gap-3 border-b px-4 py-3"
onclick={(e) => e.stopPropagation()}
>
<!-- Botão Voltar -->
<button
type="button"
class="btn btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
onclick={voltarParaLista}
aria-label="Voltar"
title="Voltar para lista de conversas"
>
<ArrowLeft class="text-primary h-6 w-6" strokeWidth={2.5} />
</button>
<!-- Avatar e Info -->
<div class="relative flex-shrink-0">
<div
class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-xl"
>
{getAvatarConversa()}
</div>
{#if getStatusConversa()}
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={getStatusConversa()} size="sm" />
</div>
{/if}
</div>
<!-- Avatar e Info -->
<div class="relative shrink-0">
{#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario}
<UserAvatar
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
size="md"
/>
{:else}
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
{getAvatarConversa()}
</div>
{/if}
{#if getStatusConversa()}
<div class="absolute right-0 bottom-0">
<UserStatusBadge status={getStatusConversa()} size="sm" />
</div>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="font-semibold text-base-content truncate">{getNomeConversa()}</p>
{#if getStatusMensagem()}
<p class="text-xs text-base-content/60 truncate">{getStatusMensagem()}</p>
{:else if getStatusConversa()}
<p class="text-xs text-base-content/60">
{getStatusConversa() === "online"
? "Online"
: getStatusConversa() === "ausente"
? "Ausente"
: getStatusConversa() === "em_reuniao"
? "Em reunião"
: getStatusConversa() === "externo"
? "Externo"
: "Offline"}
</p>
{/if}
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content truncate font-semibold">
{getNomeConversa()}
</p>
{#if getStatusMensagem()}
<p class="text-base-content/60 truncate text-xs">
{getStatusMensagem()}
</p>
{:else if getStatusConversa()}
<p class="text-base-content/60 text-xs">
{getStatusConversa() === 'online'
? 'Online'
: getStatusConversa() === 'ausente'
? 'Ausente'
: getStatusConversa() === 'em_reuniao'
? 'Em reunião'
: getStatusConversa() === 'externo'
? 'Externo'
: 'Offline'}
</p>
{:else if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<div class="mt-1 flex items-center gap-2">
<p class="text-base-content/60 text-xs">
{conversa()?.participantesInfo?.length || 0}
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
</p>
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
<div class="flex items-center gap-2">
<div class="flex -space-x-2">
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
<div
class="border-base-200 bg-base-200 relative h-5 w-5 overflow-hidden rounded-full border-2"
title={participante.nome}
>
{#if participante.fotoPerfilUrl}
<img
src={participante.fotoPerfilUrl}
alt={participante.nome}
class="h-full w-full object-cover"
/>
{:else}
<div
class="bg-base-200 flex h-full w-full items-center justify-center text-xs font-semibold"
>
{participante.nome.substring(0, 2).toUpperCase()}
</div>
{/if}
</div>
{/each}
{#if conversa()?.participantesInfo.length > 5}
<div
class="border-base-200 bg-base-300 text-base-content/70 flex h-5 w-5 items-center justify-center rounded-full border-2 text-[8px] font-semibold"
title={`+${conversa()?.participantesInfo.length - 5} mais`}
>
+{conversa()?.participantesInfo.length - 5}
</div>
{/if}
</div>
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<span
class="text-primary ml-1 text-[10px] font-semibold whitespace-nowrap"
title="Você é administrador desta sala">• Admin</span
>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão Agendar -->
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={() => (showScheduleModal = true)}
aria-label="Agendar mensagem"
title="Agendar mensagem"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</button>
</div>
</div>
<!-- Botões de ação -->
<div class="flex items-center gap-1">
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
<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(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={(e) => {
e.stopPropagation();
handleSairGrupoOuSala();
}}
aria-label="Sair"
title="Sair da conversa"
>
<div
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/10"
></div>
<LogOut
class="relative z-10 h-5 w-5 text-red-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
{/if}
<!-- Mensagens -->
<div class="flex-1 overflow-hidden">
<MessageList conversaId={conversaId as any} />
</div>
<!-- Botão Menu Administrativo (apenas para salas de reunião e apenas para admins) -->
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<div class="admin-menu-container relative">
<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(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2);"
onclick={(e) => {
e.stopPropagation();
showAdminMenu = !showAdminMenu;
}}
aria-label="Menu administrativo"
title="Recursos administrativos"
>
<div
class="absolute inset-0 bg-blue-500/0 transition-colors duration-300 group-hover:bg-blue-500/10"
></div>
<MoreVertical
class="relative z-10 h-5 w-5 text-blue-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
{#if showAdminMenu}
<ul
class="bg-base-100 border-base-300 absolute top-full right-0 z-[100] mt-2 w-56 overflow-hidden rounded-lg border shadow-xl"
onclick={(e) => e.stopPropagation()}
>
<li>
<button
type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
onclick={(e) => {
e.stopPropagation();
showSalaManager = true;
showAdminMenu = false;
}}
>
<Users class="h-4 w-4" strokeWidth={2} />
Gerenciar Participantes
</button>
</li>
<li>
<button
type="button"
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
onclick={(e) => {
e.stopPropagation();
showNotificacaoModal = true;
showAdminMenu = false;
}}
>
<Bell class="h-4 w-4" strokeWidth={2} />
Enviar Notificação
</button>
</li>
<li>
<button
type="button"
class="hover:bg-error/10 text-error flex w-full items-center gap-2 px-4 py-3 text-left transition-colors"
onclick={(e) => {
e.stopPropagation();
(async () => {
if (
!confirm(
'Tem certeza que deseja encerrar esta reunião? Todos os participantes serão removidos.'
)
)
return;
try {
const resultado = await client.mutation(api.chat.encerrarReuniao, {
conversaId: conversaId as Id<'conversas'>
});
if (resultado.sucesso) {
alert('Reunião encerrada com sucesso!');
voltarParaLista();
} else {
alert(resultado.erro || 'Erro ao encerrar reunião');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Erro ao encerrar reunião';
alert(errorMessage);
}
showAdminMenu = false;
})();
}}
>
<XCircle class="h-4 w-4" strokeWidth={2} />
Encerrar Reunião
</button>
</li>
</ul>
{/if}
</div>
{/if}
<!-- Input -->
<div class="border-t border-base-300">
<MessageInput conversaId={conversaId as any} />
</div>
<!-- Botão Agendar MODERNO -->
<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(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.2);"
onclick={() => (showScheduleModal = true)}
aria-label="Agendar mensagem"
title="Agendar mensagem"
>
<div
class="absolute inset-0 bg-purple-500/0 transition-colors duration-300 group-hover:bg-purple-500/10"
></div>
<Clock
class="relative z-10 h-5 w-5 text-purple-500 transition-transform group-hover:scale-110"
strokeWidth={2}
/>
</button>
</div>
</div>
<!-- Mensagens -->
<div class="min-h-0 flex-1 overflow-hidden">
<MessageList conversaId={conversaId as Id<'conversas'>} />
</div>
<!-- Input -->
<div class="border-base-300 shrink-0 border-t">
<MessageInput conversaId={conversaId as Id<'conversas'>} />
</div>
</div>
<!-- Modal de Agendamento -->
{#if showScheduleModal}
<ScheduleMessageModal
conversaId={conversaId as any}
onClose={() => (showScheduleModal = false)}
/>
<ScheduleMessageModal
conversaId={conversaId as Id<'conversas'>}
onClose={() => (showScheduleModal = false)}
/>
{/if}
<!-- Modal de Gerenciamento de Sala -->
{#if showSalaManager && conversa()?.tipo === 'sala_reuniao'}
<SalaReuniaoManager
conversaId={conversaId as Id<'conversas'>}
isAdmin={isAdmin?.data ?? false}
onClose={() => (showSalaManager = false)}
/>
{/if}
<!-- Modal de Enviar Notificação -->
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
<dialog
class="modal modal-open"
onclick={(e) => e.target === e.currentTarget && (showNotificacaoModal = false)}
>
<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">
<h2 class="flex items-center gap-2 text-xl font-semibold">
<Bell class="text-primary h-5 w-5" />
Enviar Notificação
</h2>
<button
type="button"
class="btn btn-sm btn-circle"
onclick={() => (showNotificacaoModal = false)}
>
<X class="h-5 w-5" />
</button>
</div>
<div class="p-6">
<form
onsubmit={async (e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const titulo = formData.get('titulo') as string;
const mensagem = formData.get('mensagem') as string;
if (!titulo.trim() || !mensagem.trim()) {
alert('Preencha todos os campos');
return;
}
try {
const resultado = await client.mutation(api.chat.enviarNotificacaoReuniao, {
conversaId: conversaId as Id<'conversas'>,
titulo: titulo.trim(),
mensagem: mensagem.trim()
});
if (resultado.sucesso) {
alert('Notificação enviada com sucesso!');
showNotificacaoModal = false;
} else {
alert(resultado.erro || 'Erro ao enviar notificação');
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Erro ao enviar notificação';
alert(errorMessage);
}
}}
>
<div class="mb-4">
<label class="label">
<span class="label-text">Título</span>
</label>
<input
type="text"
name="titulo"
placeholder="Título da notificação"
class="input input-bordered w-full"
required
/>
</div>
<div class="mb-4">
<label class="label">
<span class="label-text">Mensagem</span>
</label>
<textarea
name="mensagem"
placeholder="Mensagem da notificação"
class="textarea textarea-bordered w-full"
rows="4"
required
></textarea>
</div>
<div class="flex gap-2">
<button type="button" class="btn flex-1" onclick={() => (showNotificacaoModal = false)}>
Cancelar
</button>
<button type="submit" class="btn btn-primary flex-1"> Enviar </button>
</div>
</form>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={() => (showNotificacaoModal = false)}>fechar</button>
</form>
</dialog>
{/if}

View File

@@ -1,30 +1,166 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { onMount } from "svelte";
import { Paperclip, Smile, Send } from "lucide-svelte";
interface Props {
conversaId: Id<"conversas">;
}
type ParticipanteInfo = {
_id: Id<"usuarios">;
nome: string;
email?: string;
fotoPerfilUrl?: string;
avatar?: string;
};
type ConversaComParticipantes = {
_id: Id<"conversas">;
tipo: "individual" | "grupo" | "sala_reuniao";
participantesInfo?: ParticipanteInfo[];
};
let { conversaId }: Props = $props();
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
let mensagem = $state("");
let textarea: HTMLTextAreaElement;
let enviando = $state(false);
let uploadingFile = $state(false);
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
let showEmojiPicker = $state(false);
let mensagemRespondendo: {
id: Id<"mensagens">;
conteudo: string;
remetente: string;
} | null = $state(null);
let showMentionsDropdown = $state(false);
let mentionQuery = $state("");
let mentionStartPos = $state(0);
// Auto-resize do textarea
function handleInput() {
// Emojis mais usados
const emojis = [
"😀",
"😃",
"😄",
"😁",
"😅",
"😂",
"🤣",
"😊",
"😇",
"🙂",
"🙃",
"😉",
"😌",
"😍",
"🥰",
"😘",
"😗",
"😙",
"😚",
"😋",
"😛",
"😝",
"😜",
"🤪",
"🤨",
"🧐",
"🤓",
"😎",
"🥳",
"😏",
"👍",
"👎",
"👏",
"🙌",
"🤝",
"🙏",
"💪",
"✨",
"🎉",
"🎊",
"❤️",
"💙",
"💚",
"💛",
"🧡",
"💜",
"🖤",
"🤍",
"💯",
"🔥",
];
function adicionarEmoji(emoji: string) {
mensagem += emoji;
showEmojiPicker = false;
if (textarea) {
textarea.focus();
}
}
// Obter conversa atual
const conversa = $derived((): ConversaComParticipantes | null => {
if (!conversas?.data) return null;
return (
(conversas.data as ConversaComParticipantes[]).find(
(c) => c._id === conversaId,
) || null
);
});
// Obter participantes para menções (apenas grupos e salas)
const participantesParaMencoes = $derived((): ParticipanteInfo[] => {
const c = conversa();
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return [];
return c.participantesInfo || [];
});
// Filtrar participantes para dropdown de menções
const participantesFiltrados = $derived((): ParticipanteInfo[] => {
if (!mentionQuery.trim()) return participantesParaMencoes().slice(0, 5);
const query = mentionQuery.toLowerCase();
return participantesParaMencoes()
.filter(
(p) =>
p.nome?.toLowerCase().includes(query) ||
(p.email && p.email.toLowerCase().includes(query)),
)
.slice(0, 5);
});
// Auto-resize do textarea e detectar menções
function handleInput(e: Event) {
const target = e.target as HTMLTextAreaElement;
if (textarea) {
textarea.style.height = "auto";
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
}
// Detectar menções (@)
const cursorPos = target.selectionStart || 0;
const textBeforeCursor = mensagem.substring(0, cursorPos);
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
if (lastAtIndex !== -1) {
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1);
// Se não há espaço após o @, mostrar dropdown
if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) {
mentionQuery = textAfterAt;
mentionStartPos = lastAtIndex;
showMentionsDropdown = true;
return;
}
}
showMentionsDropdown = false;
// Indicador de digitação (debounce de 1s)
if (digitacaoTimeout) {
clearTimeout(digitacaoTimeout);
@@ -36,30 +172,136 @@
}, 1000);
}
function inserirMencao(participante: ParticipanteInfo) {
const nome = participante.nome.split(" ")[0]; // Usar primeiro nome
const antes = mensagem.substring(0, mentionStartPos);
const depois = mensagem.substring(
textarea.selectionStart || mensagem.length,
);
mensagem = antes + `@${nome} ` + depois;
showMentionsDropdown = false;
mentionQuery = "";
if (textarea) {
textarea.focus();
const newPos = antes.length + nome.length + 2;
setTimeout(() => {
textarea.setSelectionRange(newPos, newPos);
}, 0);
}
}
async function handleEnviar() {
const texto = mensagem.trim();
if (!texto || enviando) return;
// Extrair menções do texto (@nome)
const mencoesIds: Id<"usuarios">[] = [];
const mentionRegex = /@(\w+)/g;
let match;
while ((match = mentionRegex.exec(texto)) !== null) {
const nomeMencionado = match[1];
const participante = participantesParaMencoes().find(
(p) =>
p.nome.split(" ")[0].toLowerCase() === nomeMencionado.toLowerCase(),
);
if (participante) {
mencoesIds.push(participante._id);
}
}
console.log("📤 [MessageInput] Enviando mensagem:", {
conversaId,
conteudo: texto,
tipo: "texto",
respostaPara: mensagemRespondendo?.id,
mencoes: mencoesIds,
});
try {
enviando = true;
await client.mutation(api.chat.enviarMensagem, {
const result = await client.mutation(api.chat.enviarMensagem, {
conversaId,
conteudo: texto,
tipo: "texto",
respostaPara: mensagemRespondendo?.id,
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined,
});
console.log(
"✅ [MessageInput] Mensagem enviada com sucesso! ID:",
result,
);
mensagem = "";
mensagemRespondendo = null;
showMentionsDropdown = false;
mentionQuery = "";
if (textarea) {
textarea.style.height = "auto";
}
} catch (error) {
console.error("Erro ao enviar mensagem:", error);
console.error("❌ [MessageInput] Erro ao enviar mensagem:", error);
alert("Erro ao enviar mensagem");
} finally {
enviando = false;
}
}
function cancelarResposta() {
mensagemRespondendo = null;
}
type MensagemComRemetente = {
_id: Id<"mensagens">;
conteudo: string;
remetente?: { nome: string } | null;
};
// Escutar evento de resposta
onMount(() => {
const handler = (e: Event) => {
const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
// Buscar informações da mensagem para exibir preview
client
.query(api.chat.obterMensagens, { conversaId, limit: 100 })
.then((mensagens) => {
const msg = (mensagens as MensagemComRemetente[]).find(
(m) => m._id === customEvent.detail.mensagemId,
);
if (msg) {
mensagemRespondendo = {
id: msg._id,
conteudo: msg.conteudo.substring(0, 100),
remetente: msg.remetente?.nome || "Usuário",
};
textarea?.focus();
}
});
};
window.addEventListener("responderMensagem", handler);
return () => {
window.removeEventListener("responderMensagem", handler);
};
});
function handleKeyDown(e: KeyboardEvent) {
// Navegar dropdown de menções
if (showMentionsDropdown && participantesFiltrados().length > 0) {
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
e.preventDefault();
// Implementação simples: selecionar primeiro participante
if (e.key === "Enter") {
inserirMencao(participantesFiltrados()[0]);
}
return;
}
if (e.key === "Escape") {
showMentionsDropdown = false;
return;
}
}
// Enter sem Shift = enviar
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
@@ -82,7 +324,9 @@
uploadingFile = true;
// 1. Obter upload URL
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, { conversaId });
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
conversaId,
});
// 2. Upload do arquivo
const result = await fetch(uploadUrl, {
@@ -98,11 +342,13 @@
const { storageId } = await result.json();
// 3. Enviar mensagem com o arquivo
const tipo = file.type.startsWith("image/") ? "imagem" : "arquivo";
const tipo: "imagem" | "arquivo" = file.type.startsWith("image/")
? "imagem"
: "arquivo";
await client.mutation(api.chat.enviarMensagem, {
conversaId,
conteudo: tipo === "imagem" ? "" : file.name,
tipo: tipo as any,
tipo,
arquivoId: storageId,
arquivoNome: file.name,
arquivoTamanho: file.size,
@@ -127,9 +373,37 @@
</script>
<div class="p-4">
<!-- Preview da mensagem respondendo -->
{#if mensagemRespondendo}
<div
class="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between"
>
<div class="flex-1">
<p class="text-xs font-medium text-base-content/70">
Respondendo a {mensagemRespondendo.remetente}
</p>
<p class="text-xs text-base-content/50 truncate">
{mensagemRespondendo.conteudo}
</p>
</div>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={cancelarResposta}
title="Cancelar resposta"
>
</button>
</div>
{/if}
<div class="flex items-end gap-2">
<!-- Botão de anexar arquivo -->
<label class="btn btn-ghost btn-sm btn-circle flex-shrink-0">
<!-- Botão de anexar arquivo MODERNO -->
<label
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden cursor-pointer shrink-0"
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
title="Anexar arquivo"
>
<input
type="file"
class="hidden"
@@ -137,26 +411,61 @@
disabled={uploadingFile || enviando}
accept="*/*"
/>
<div
class="absolute inset-0 bg-primary/0 group-hover:bg-primary/10 transition-colors duration-300"
></div>
{#if uploadingFile}
<span class="loading loading-spinner loading-xs"></span>
<span class="loading loading-spinner loading-sm relative z-10"></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
/>
</svg>
<!-- Ícone de clipe moderno -->
<Paperclip
class="w-5 h-5 text-primary relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
{/if}
</label>
<!-- Botão de EMOJI MODERNO -->
<div class="relative shrink-0">
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.2);"
onclick={() => (showEmojiPicker = !showEmojiPicker)}
disabled={enviando || uploadingFile}
aria-label="Adicionar emoji"
title="Adicionar emoji"
>
<div
class="absolute inset-0 bg-warning/0 group-hover:bg-warning/10 transition-colors duration-300"
></div>
<Smile
class="w-5 h-5 text-warning relative z-10 group-hover:scale-110 transition-transform"
strokeWidth={2}
/>
</button>
<!-- Picker de Emojis -->
{#if showEmojiPicker}
<div
class="absolute bottom-full left-0 mb-2 p-3 bg-base-100 rounded-xl shadow-2xl border border-base-300 z-50"
style="width: 280px; max-height: 200px; overflow-y-auto;"
>
<div class="grid grid-cols-10 gap-1">
{#each emojis as emoji}
<button
type="button"
class="text-2xl hover:scale-125 transition-transform cursor-pointer p-1 hover:bg-base-200 rounded"
onclick={() => adicionarEmoji(emoji)}
>
{emoji}
</button>
{/each}
</div>
</div>
{/if}
</div>
<!-- Textarea -->
<div class="flex-1 relative">
<textarea
@@ -164,45 +473,77 @@
bind:value={mensagem}
oninput={handleInput}
onkeydown={handleKeyDown}
placeholder="Digite uma mensagem..."
placeholder="Digite uma mensagem... (use @ para mencionar)"
class="textarea textarea-bordered w-full resize-none min-h-[44px] max-h-[120px] pr-10"
rows="1"
disabled={enviando || uploadingFile}
></textarea>
<!-- Dropdown de Menções -->
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
<div
class="absolute bottom-full left-0 mb-2 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50 w-64 max-h-48 overflow-y-auto"
>
{#each participantesFiltrados() as participante (participante._id)}
<button
type="button"
class="w-full text-left px-4 py-2 hover:bg-base-200 transition-colors flex items-center gap-2"
onclick={() => inserirMencao(participante)}
>
<div
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center overflow-hidden"
>
{#if participante.fotoPerfilUrl}
<img
src={participante.fotoPerfilUrl}
alt={participante.nome}
class="w-full h-full object-cover"
/>
{:else}
<span class="text-xs font-semibold"
>{participante.nome.charAt(0).toUpperCase()}</span
>
{/if}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate">{participante.nome}</p>
<p class="text-xs text-base-content/60 truncate">
@{participante.nome.split(" ")[0]}
</p>
</div>
</button>
{/each}
</div>
{/if}
</div>
<!-- Botão de enviar -->
<!-- Botão de enviar MODERNO -->
<button
type="button"
class="btn btn-primary btn-circle flex-shrink-0"
class="flex items-center justify-center w-12 h-12 rounded-xl transition-all duration-300 group relative overflow-hidden shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleEnviar}
disabled={!mensagem.trim() || enviando || uploadingFile}
aria-label="Enviar"
>
<div
class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"
></div>
{#if enviando}
<span class="loading loading-spinner loading-sm"></span>
<span
class="loading loading-spinner loading-sm relative z-10 text-white"
></span>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
</svg>
<!-- Ícone de avião de papel moderno -->
<Send
class="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:translate-x-1 transition-all"
/>
{/if}
</button>
</div>
<!-- Informação sobre atalhos -->
<p class="text-xs text-base-content/50 mt-2 text-center">
Pressione Enter para enviar, Shift+Enter para quebrar linha
💡 Enter para enviar Shift+Enter para quebrar linha • 😊 Clique no emoji
</p>
</div>

View File

@@ -13,29 +13,230 @@
let { conversaId }: Props = $props();
const client = useConvexClient();
const mensagens = useQuery(api.chat.obterMensagens, { conversaId, limit: 50 });
const mensagens = useQuery(api.chat.obterMensagens, {
conversaId,
limit: 50,
});
const digitando = useQuery(api.chat.obterDigitando, { conversaId });
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, { conversaId });
const conversas = useQuery(api.chat.listarConversas, {});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let messagesContainer: HTMLDivElement;
let shouldScrollToBottom = true;
let lastMessageCount = 0;
let mensagensNotificadas = $state<Set<string>>(new Set());
let showNotificationPopup = $state(false);
let notificationMessage = $state<{
remetente: string;
conteudo: string;
} | null>(null);
let notificationTimeout: ReturnType<typeof setTimeout> | null = null;
let mensagensCarregadas = $state(false);
// Auto-scroll para a última mensagem
// Obter ID do usuário atual - usar $state para garantir reatividade
let usuarioAtualId = $state<string | null>(null);
// Carregar mensagens já notificadas do localStorage ao montar
$effect(() => {
if (mensagens && shouldScrollToBottom && messagesContainer) {
tick().then(() => {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
});
if (typeof window !== "undefined" && !mensagensCarregadas) {
const saved = localStorage.getItem("chat-mensagens-notificadas");
if (saved) {
try {
const ids = JSON.parse(saved) as string[];
mensagensNotificadas = new Set(ids);
} catch {
mensagensNotificadas = new Set();
}
}
mensagensCarregadas = true;
// Marcar todas as mensagens atuais como já visualizadas (não tocar beep ao abrir)
if (mensagens?.data && mensagens.data.length > 0) {
mensagens.data.forEach((msg) => {
mensagensNotificadas.add(String(msg._id));
});
salvarMensagensNotificadas();
}
}
});
// Salvar mensagens notificadas no localStorage
function salvarMensagensNotificadas() {
if (typeof window !== "undefined") {
const ids = Array.from(mensagensNotificadas);
// Limitar a 1000 IDs para não encher o localStorage
const idsLimitados = ids.slice(-1000);
localStorage.setItem(
"chat-mensagens-notificadas",
JSON.stringify(idsLimitados),
);
}
}
// Atualizar usuarioAtualId sempre que currentUser mudar
$effect(() => {
const usuario = currentUser?.data;
if (usuario?._id) {
const idStr = String(usuario._id).trim();
usuarioAtualId = idStr || null;
} else {
usuarioAtualId = null;
}
});
// Função para tocar som de notificação
function tocarSomNotificacao() {
try {
// Usar AudioContext (requer interação do usuário para iniciar)
const AudioContextClass =
window.AudioContext ||
(window as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!AudioContextClass) return;
let audioContext: AudioContext | null = null;
try {
audioContext = new AudioContext();
} catch (e) {
// Se falhar, tentar resumir contexto existente
console.warn("Não foi possível criar AudioContext:", e);
return;
}
// Resumir contexto se estiver suspenso (necessário após interação do usuário)
if (audioContext.state === "suspended") {
audioContext
.resume()
.then(() => {
const oscillator = audioContext!.createOscillator();
const gainNode = audioContext!.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext!.destination);
oscillator.frequency.value = 800;
oscillator.type = "sine";
gainNode.gain.setValueAtTime(0.2, audioContext!.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext!.currentTime + 0.3,
);
oscillator.start(audioContext!.currentTime);
oscillator.stop(audioContext!.currentTime + 0.3);
})
.catch(() => {
// Ignorar erro se não conseguir resumir
});
} else {
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
oscillator.frequency.value = 800;
oscillator.type = "sine";
gainNode.gain.setValueAtTime(0.2, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(
0.01,
audioContext.currentTime + 0.3,
);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.3);
}
} catch (error) {
console.error("Erro ao tocar som de notificação:", error);
}
}
// Auto-scroll para a última mensagem quando novas mensagens chegam
// E detectar novas mensagens para tocar som e mostrar popup
$effect(() => {
if (mensagens?.data && messagesContainer) {
const currentCount = mensagens.data.length;
const isNewMessage = currentCount > lastMessageCount;
// Detectar nova mensagem de outro usuário
if (isNewMessage && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
const mensagemId = String(ultimaMensagem._id);
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
? String(ultimaMensagem.remetente._id).trim()
: null;
// Se é uma nova mensagem de outro usuário (não minha) E ainda não foi notificada
if (
remetenteIdStr &&
remetenteIdStr !== usuarioAtualId &&
!mensagensNotificadas.has(mensagemId)
) {
// Marcar como notificada antes de tocar som (evita duplicação)
mensagensNotificadas.add(mensagemId);
salvarMensagensNotificadas();
// Tocar som de notificação (apenas uma vez)
tocarSomNotificacao();
// Mostrar popup de notificação
notificationMessage = {
remetente: ultimaMensagem.remetente?.nome || "Usuário",
conteudo:
ultimaMensagem.conteudo.substring(0, 100) +
(ultimaMensagem.conteudo.length > 100 ? "..." : ""),
};
showNotificationPopup = true;
// Ocultar popup após 5 segundos
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
notificationTimeout = setTimeout(() => {
showNotificationPopup = false;
notificationMessage = null;
}, 5000);
}
}
if (isNewMessage || shouldScrollToBottom) {
// Usar requestAnimationFrame para garantir que o DOM foi atualizado
requestAnimationFrame(() => {
tick().then(() => {
if (messagesContainer) {
messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
});
});
}
lastMessageCount = currentCount;
}
});
// Marcar como lida quando mensagens carregam
$effect(() => {
if (mensagens && mensagens.length > 0) {
const ultimaMensagem = mensagens[mensagens.length - 1];
client.mutation(api.chat.marcarComoLida, {
conversaId,
mensagemId: ultimaMensagem._id as any,
});
if (mensagens?.data && mensagens.data.length > 0 && usuarioAtualId) {
const ultimaMensagem = mensagens.data[mensagens.data.length - 1];
const remetenteIdStr = ultimaMensagem.remetenteId
? String(ultimaMensagem.remetenteId).trim()
: ultimaMensagem.remetente?._id
? String(ultimaMensagem.remetente._id).trim()
: null;
// Só marcar como lida se não for minha mensagem
if (remetenteIdStr && remetenteIdStr !== usuarioAtualId) {
client.mutation(api.chat.marcarComoLida, {
conversaId,
mensagemId: ultimaMensagem._id,
});
}
}
});
@@ -55,8 +256,51 @@
}
}
function agruparMensagensPorDia(msgs: any[]): Record<string, any[]> {
const grupos: Record<string, any[]> = {};
interface Mensagem {
_id: Id<"mensagens">;
remetenteId: Id<"usuarios">;
remetente?: {
_id: Id<"usuarios">;
nome: string;
} | null;
conteudo: string;
tipo: "texto" | "arquivo" | "imagem";
enviadaEm: number;
editadaEm?: number;
deletada?: boolean;
agendadaPara?: number;
minutosPara?: number;
respostaPara?: Id<"mensagens">;
mensagemOriginal?: {
_id: Id<"mensagens">;
conteudo: string;
remetente: {
_id: Id<"usuarios">;
nome: string;
} | null;
deletada: boolean;
} | null;
reagiuPor?: Array<{
usuarioId: Id<"usuarios">;
emoji: string;
}>;
arquivoUrl?: string | null;
arquivoNome?: string;
arquivoTamanho?: number;
linkPreview?: {
url: string;
titulo?: string;
descricao?: string;
imagem?: string;
site?: string;
} | null;
lidaPor?: Id<"usuarios">[]; // IDs dos usuários que leram a mensagem
}
function agruparMensagensPorDia(
msgs: Mensagem[],
): Record<string, Mensagem[]> {
const grupos: Record<string, Mensagem[]> = {};
for (const msg of msgs) {
const dia = formatarDiaMensagem(msg.enviadaEm);
if (!grupos[dia]) {
@@ -74,14 +318,16 @@
shouldScrollToBottom = isAtBottom;
}
async function handleReagir(mensagemId: string, emoji: string) {
async function handleReagir(mensagemId: Id<"mensagens">, emoji: string) {
await client.mutation(api.chat.reagirMensagem, {
mensagemId: mensagemId as any,
mensagemId,
emoji,
});
}
function getEmojisReacao(mensagem: any): Array<{ emoji: string; count: number }> {
function getEmojisReacao(
mensagem: Mensagem,
): Array<{ emoji: string; count: number }> {
if (!mensagem.reagiuPor || mensagem.reagiuPor.length === 0) return [];
const emojiMap: Record<string, number> = {};
@@ -91,6 +337,128 @@
return Object.entries(emojiMap).map(([emoji, count]) => ({ emoji, count }));
}
let mensagemEditando: Mensagem | null = $state(null);
let novoConteudoEditado = $state("");
async function editarMensagem(mensagem: Mensagem) {
mensagemEditando = mensagem;
novoConteudoEditado = mensagem.conteudo;
}
async function salvarEdicao() {
if (!mensagemEditando || !novoConteudoEditado.trim()) return;
try {
const resultado = await client.mutation(api.chat.editarMensagem, {
mensagemId: mensagemEditando._id,
novoConteudo: novoConteudoEditado.trim(),
});
if (resultado.sucesso) {
mensagemEditando = null;
novoConteudoEditado = "";
} else {
alert(resultado.erro || "Erro ao editar mensagem");
}
} catch (error) {
console.error("Erro ao editar mensagem:", error);
alert("Erro ao editar mensagem");
}
}
function cancelarEdicao() {
mensagemEditando = null;
novoConteudoEditado = "";
}
async function deletarMensagem(
mensagemId: Id<"mensagens">,
isAdminDeleting: boolean = false,
) {
const mensagemTexto = isAdminDeleting
? "Tem certeza que deseja deletar esta mensagem como administrador? O remetente será notificado."
: "Tem certeza que deseja deletar esta mensagem?";
if (!confirm(mensagemTexto)) {
return;
}
try {
if (isAdminDeleting) {
const resultado = await client.mutation(
api.chat.deletarMensagemComoAdmin,
{
mensagemId,
},
);
if (!resultado.sucesso) {
alert(resultado.erro || "Erro ao deletar mensagem");
}
} else {
await client.mutation(api.chat.deletarMensagem, {
mensagemId,
});
}
} catch (error) {
console.error("Erro ao deletar mensagem:", error);
alert(error.message || "Erro ao deletar mensagem");
}
}
// Função para responder mensagem (será passada via props ou event)
function responderMensagem(mensagem: Mensagem) {
// Disparar evento customizado para o componente pai
const event = new CustomEvent("responderMensagem", {
detail: { mensagemId: mensagem._id },
});
window.dispatchEvent(event);
}
// Obter informações da conversa atual
const conversaAtual = $derived(() => {
if (!conversas?.data) return null;
return (conversas.data as any[]).find((c) => c._id === conversaId) || null;
});
// Função para determinar se uma mensagem foi lida
function mensagemFoiLida(mensagem: Mensagem): boolean {
if (!mensagem.lidaPor || mensagem.lidaPor.length === 0) return false;
if (!conversaAtual() || !usuarioAtualId) return false;
const conversa = conversaAtual();
if (!conversa) return false;
// Converter lidaPor para strings para comparação
const lidaPorStr = mensagem.lidaPor.map((id) => String(id));
// Para conversas individuais: verificar se o outro participante leu
if (conversa.tipo === "individual") {
const outroParticipante = conversa.participantes?.find(
(p: any) => String(p) !== usuarioAtualId,
);
if (outroParticipante) {
return lidaPorStr.includes(String(outroParticipante));
}
}
// Para grupos/salas: verificar se pelo menos um outro participante leu
if (conversa.tipo === "grupo" || conversa.tipo === "sala_reuniao") {
const outrosParticipantes =
conversa.participantes?.filter(
(p: any) =>
String(p) !== usuarioAtualId &&
String(p) !== String(mensagem.remetenteId),
) || [];
if (outrosParticipantes.length === 0) return false;
// Verificar se pelo menos um outro participante leu
return outrosParticipantes.some((p: any) =>
lidaPorStr.includes(String(p)),
);
}
return false;
}
</script>
<div
@@ -98,23 +466,43 @@
bind:this={messagesContainer}
onscroll={handleScroll}
>
{#if mensagens && mensagens.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens)}
{#if mensagens?.data && mensagens.data.length > 0}
{@const gruposPorDia = agruparMensagensPorDia(mensagens.data)}
{#each Object.entries(gruposPorDia) as [dia, mensagensDia]}
<!-- Separador de dia -->
<div class="flex items-center justify-center my-4">
<div class="px-3 py-1 rounded-full bg-base-300 text-base-content/70 text-xs">
<div
class="px-3 py-1 rounded-full bg-base-300 text-base-content/70 text-xs"
>
{dia}
</div>
</div>
<!-- Mensagens do dia -->
{#each mensagensDia as mensagem (mensagem._id)}
{@const isMinha = mensagem.remetente?._id === mensagens[0]?.remetente?._id}
<div class={`flex mb-4 ${isMinha ? "justify-end" : "justify-start"}`}>
<div class={`max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}>
<!-- Nome do remetente (apenas se não for minha) -->
{#if !isMinha}
{@const remetenteIdStr = (() => {
// Priorizar remetenteId direto da mensagem
if (mensagem.remetenteId) {
return String(mensagem.remetenteId).trim();
}
// Fallback para remetente._id
if (mensagem.remetente?._id) {
return String(mensagem.remetente._id).trim();
}
return null;
})()}
{@const isMinha =
usuarioAtualId && remetenteIdStr && remetenteIdStr === usuarioAtualId}
<div
class={`flex mb-4 w-full ${isMinha ? "justify-end" : "justify-start"}`}
>
<div
class={`flex flex-col max-w-[75%] ${isMinha ? "items-end" : "items-start"}`}
>
<!-- Nome do remetente (sempre exibido, mas discreto para mensagens próprias) -->
{#if isMinha}
<p class="text-xs text-base-content/40 mb-1 px-3">Você</p>
{:else}
<p class="text-xs text-base-content/60 mb-1 px-3">
{mensagem.remetente?.nome || "Usuário"}
</p>
@@ -124,14 +512,109 @@
<div
class={`rounded-2xl px-4 py-2 ${
isMinha
? "bg-primary text-primary-content rounded-br-sm"
? "bg-blue-200 text-gray-900 rounded-br-sm"
: "bg-base-200 text-base-content rounded-bl-sm"
}`}
>
{#if mensagem.deletada}
{#if mensagem.mensagemOriginal}
<!-- Preview da mensagem respondida -->
<div
class="mb-2 pl-3 border-l-2 border-base-content/20 opacity-70"
>
<p class="text-xs font-medium">
{mensagem.mensagemOriginal.remetente?.nome || "Usuário"}
</p>
<p class="text-xs truncate">
{mensagem.mensagemOriginal.deletada
? "Mensagem deletada"
: mensagem.mensagemOriginal.conteudo}
</p>
</div>
{/if}
{#if mensagemEditando?._id === mensagem._id}
<!-- Modo de edição -->
<div class="space-y-2">
<textarea
bind:value={novoConteudoEditado}
class="w-full p-2 rounded-lg bg-base-100 text-base-content text-sm resize-none"
rows="3"
onkeydown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
salvarEdicao();
} else if (e.key === "Escape") {
cancelarEdicao();
}
}}
></textarea>
<div class="flex gap-2 justify-end">
<button
class="btn btn-xs btn-ghost"
onclick={cancelarEdicao}
>
Cancelar
</button>
<button
class="btn btn-xs btn-primary"
onclick={salvarEdicao}
>
Salvar
</button>
</div>
</div>
{:else if mensagem.deletada}
<p class="text-sm italic opacity-70">Mensagem deletada</p>
{:else if mensagem.tipo === "texto"}
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
<div class="space-y-2">
<div class="flex items-start gap-2">
<p class="text-sm whitespace-pre-wrap break-words flex-1">
{mensagem.conteudo}
</p>
{#if mensagem.editadaEm}
<span class="text-xs opacity-50 italic" title="Editado"
>(editado)</span
>
{/if}
</div>
<!-- Preview de link -->
{#if mensagem.linkPreview}
<a
href={mensagem.linkPreview.url}
target="_blank"
rel="noopener noreferrer"
class="block border border-base-300 rounded-lg overflow-hidden hover:border-primary transition-colors"
>
{#if mensagem.linkPreview.imagem}
<img
src={mensagem.linkPreview.imagem}
alt={mensagem.linkPreview.titulo || "Preview"}
class="w-full h-48 object-cover"
onerror={(e) => {
(e.target as HTMLImageElement).style.display =
"none";
}}
/>
{/if}
<div class="p-3 bg-base-200">
{#if mensagem.linkPreview.site}
<p class="text-xs text-base-content/50 mb-1">
{mensagem.linkPreview.site}
</p>
{/if}
{#if mensagem.linkPreview.titulo}
<p class="text-sm font-medium text-base-content mb-1">
{mensagem.linkPreview.titulo}
</p>
{/if}
{#if mensagem.linkPreview.descricao}
<p class="text-xs text-base-content/70 line-clamp-2">
{mensagem.linkPreview.descricao}
</p>
{/if}
</div>
</a>
{/if}
</div>
{:else if mensagem.tipo === "imagem"}
<div class="mb-2">
<img
@@ -141,7 +624,9 @@
/>
</div>
{#if mensagem.conteudo}
<p class="text-sm whitespace-pre-wrap break-words">{mensagem.conteudo}</p>
<p class="text-sm whitespace-pre-wrap break-words">
{mensagem.conteudo}
</p>
{/if}
{:else if mensagem.tipo === "arquivo"}
<a
@@ -184,29 +669,122 @@
class="text-xs px-2 py-0.5 rounded-full bg-base-300/50 hover:bg-base-300"
onclick={() => handleReagir(mensagem._id, reacao.emoji)}
>
{reacao.emoji} {reacao.count}
{reacao.emoji}
{reacao.count}
</button>
{/each}
</div>
{/if}
<!-- Botão de responder -->
{#if !mensagem.deletada}
<button
class="text-xs text-base-content/50 hover:text-primary transition-colors mt-1"
onclick={() => responderMensagem(mensagem)}
title="Responder"
>
↪️ Responder
</button>
{/if}
</div>
<!-- Timestamp -->
<p
class={`text-xs text-base-content/50 mt-1 px-3 ${isMinha ? "text-right" : "text-left"}`}
<!-- Timestamp e ações -->
<div
class={`flex items-center gap-2 mt-1 px-3 ${isMinha ? "justify-end" : "justify-start"}`}
>
{formatarDataMensagem(mensagem.enviadaEm)}
</p>
<p class="text-xs text-base-content/50">
{formatarDataMensagem(mensagem.enviadaEm)}
</p>
{#if isMinha && !mensagem.deletada && !mensagem.agendadaPara}
<!-- Indicadores de status de envio e leitura -->
<div class="flex items-center gap-0.5 ml-1">
{#if mensagemFoiLida(mensagem)}
<!-- Dois checks azuis para mensagem lida -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-3.5 h-3.5 text-blue-500"
style="margin-left: -2px;"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-3.5 h-3.5 text-blue-500"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
{:else}
<!-- Um check verde para mensagem enviada -->
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
class="w-3.5 h-3.5 text-green-500"
>
<path
fill-rule="evenodd"
d="M19.916 4.626a.75.75 0 0 1 .208 1.04l-9 13.5a.75.75 0 0 1-1.154.114l-6-6a.75.75 0 0 1 1.06-1.06l5.353 5.353 8.493-12.74a.75.75 0 0 1 1.04-.207Z"
clip-rule="evenodd"
/>
</svg>
{/if}
</div>
{/if}
{#if !mensagem.deletada && !mensagem.agendadaPara}
<div class="flex gap-1">
{#if isMinha}
<!-- Ações para minhas próprias mensagens -->
<button
class="text-xs text-base-content/50 hover:text-primary transition-colors"
onclick={() => editarMensagem(mensagem)}
title="Editar mensagem"
>
✏️
</button>
<button
class="text-xs text-base-content/50 hover:text-error transition-colors"
onclick={() => deletarMensagem(mensagem._id, false)}
title="Deletar mensagem"
>
🗑️
</button>
{:else if isAdmin?.data}
<!-- Ações para admin deletar mensagens de outros -->
<button
class="text-xs text-base-content/50 hover:text-error transition-colors"
onclick={() => deletarMensagem(mensagem._id, true)}
title="Deletar mensagem (como administrador)"
>
🗑️ Admin
</button>
{/if}
</div>
{/if}
</div>
</div>
</div>
{/each}
{/each}
<!-- Indicador de digitação -->
{#if digitando && digitando.length > 0}
{#if digitando?.data && digitando.data.length > 0}
<div class="flex items-center gap-2 mb-4">
<div class="flex items-center gap-1">
<div class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"></div>
<div
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
></div>
<div
class="w-2 h-2 rounded-full bg-base-content/50 animate-bounce"
style="animation-delay: 0.1s;"
@@ -217,13 +795,14 @@
></div>
</div>
<p class="text-xs text-base-content/60">
{digitando.map((u: any) => u.nome).join(", ")} {digitando.length === 1
{digitando.data.map((u: { nome: string }) => u.nome).join(", ")}
{digitando.data.length === 1
? "está digitando"
: "estão digitando"}...
</p>
</div>
{/if}
{:else if !mensagens}
{:else if !mensagens?.data}
<!-- Loading -->
<div class="flex items-center justify-center h-full">
<span class="loading loading-spinner loading-lg"></span>
@@ -246,8 +825,80 @@
/>
</svg>
<p class="text-base-content/70">Nenhuma mensagem ainda</p>
<p class="text-sm text-base-content/50 mt-1">Envie a primeira mensagem!</p>
<p class="text-sm text-base-content/50 mt-1">
Envie a primeira mensagem!
</p>
</div>
{/if}
</div>
<!-- Popup de Notificação de Nova Mensagem -->
{#if showNotificationPopup && notificationMessage}
<div
class="fixed top-4 right-4 z-[1000] bg-base-100 rounded-lg shadow-2xl border border-primary/20 p-4 max-w-sm animate-in slide-in-from-top-5 fade-in duration-300"
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3);"
onclick={() => {
showNotificationPopup = false;
notificationMessage = null;
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
}}
>
<div class="flex items-start gap-3">
<div
class="shrink-0 w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center"
>
<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="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 class="flex-1 min-w-0">
<p class="font-semibold text-base-content text-sm mb-1">
Nova mensagem de {notificationMessage.remetente}
</p>
<p class="text-xs text-base-content/70 line-clamp-2">
{notificationMessage.conteudo}
</p>
</div>
<button
type="button"
class="shrink-0 w-6 h-6 rounded-full hover:bg-base-200 flex items-center justify-center transition-colors"
onclick={(e) => {
e.stopPropagation();
showNotificationPopup = false;
notificationMessage = null;
if (notificationTimeout) {
clearTimeout(notificationTimeout);
}
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18 18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/if}

View File

@@ -1,254 +1,422 @@
<script lang="ts">
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { abrirConversa } from "$lib/stores/chatStore";
import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from "./UserAvatar.svelte";
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { abrirConversa } from '$lib/stores/chatStore';
import UserStatusBadge from './UserStatusBadge.svelte';
import UserAvatar from './UserAvatar.svelte';
import {
MessageSquare,
User,
Users,
Video,
X,
Search,
ChevronRight,
Plus,
UserX
} from 'lucide-svelte';
interface Props {
onClose: () => void;
}
interface Props {
onClose: () => void;
}
let { onClose }: Props = $props();
let { onClose }: Props = $props();
const client = useConvexClient();
const usuarios = useQuery(api.chat.listarTodosUsuarios, {});
const client = useConvexClient();
const usuarios = useQuery(api.usuarios.listarParaChat, {});
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let activeTab = $state<"individual" | "grupo">("individual");
let searchQuery = $state("");
let selectedUsers = $state<string[]>([]);
let groupName = $state("");
let loading = $state(false);
let activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
let searchQuery = $state('');
let selectedUsers = $state<string[]>([]);
let groupName = $state('');
let salaReuniaoName = $state('');
let loading = $state(false);
const usuariosFiltrados = $derived(() => {
if (!usuarios) return [];
if (!searchQuery.trim()) return usuarios;
const usuariosFiltrados = $derived(() => {
if (!usuarios?.data) return [];
const query = searchQuery.toLowerCase();
return usuarios.filter((u: any) =>
u.nome.toLowerCase().includes(query) ||
u.email.toLowerCase().includes(query) ||
u.matricula.toLowerCase().includes(query)
);
});
// Filtrar o próprio usuário
const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
let lista = usuarios.data.filter((u: any) => u._id !== meuId);
function toggleUserSelection(userId: string) {
if (selectedUsers.includes(userId)) {
selectedUsers = selectedUsers.filter((id) => id !== userId);
} else {
selectedUsers = [...selectedUsers, userId];
}
}
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter(
(u: any) =>
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
);
}
async function handleCriarIndividual(userId: string) {
try {
loading = true;
const conversaId = await client.mutation(api.chat.criarConversa, {
tipo: "individual",
participantes: [userId as any],
});
abrirConversa(conversaId);
onClose();
} catch (error) {
console.error("Erro ao criar conversa:", error);
alert("Erro ao criar conversa");
} finally {
loading = false;
}
}
// Ordenar: online primeiro, depois por nome
return lista.sort((a: any, b: 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;
async function handleCriarGrupo() {
if (selectedUsers.length < 2) {
alert("Selecione pelo menos 2 participantes");
return;
}
if (statusA !== statusB) return statusA - statusB;
return (a.nome || '').localeCompare(b.nome || '');
});
});
if (!groupName.trim()) {
alert("Digite um nome para o grupo");
return;
}
function toggleUserSelection(userId: string) {
if (selectedUsers.includes(userId)) {
selectedUsers = selectedUsers.filter((id) => id !== userId);
} else {
selectedUsers = [...selectedUsers, userId];
}
}
try {
loading = true;
const conversaId = await client.mutation(api.chat.criarConversa, {
tipo: "grupo",
participantes: selectedUsers as any,
nome: groupName.trim(),
});
abrirConversa(conversaId);
onClose();
} catch (error) {
console.error("Erro ao criar grupo:", error);
alert("Erro ao criar grupo");
} finally {
loading = false;
}
}
async function handleCriarIndividual(userId: string) {
try {
loading = true;
const conversaId = await client.mutation(api.chat.criarConversa, {
tipo: 'individual',
participantes: [userId as any]
});
abrirConversa(conversaId);
onClose();
} catch (error) {
console.error('Erro ao criar conversa:', error);
alert('Erro ao criar conversa');
} finally {
loading = false;
}
}
async function handleCriarGrupo() {
if (selectedUsers.length < 2) {
alert('Selecione pelo menos 2 participantes');
return;
}
if (!groupName.trim()) {
alert('Digite um nome para o grupo');
return;
}
try {
loading = true;
const conversaId = await client.mutation(api.chat.criarConversa, {
tipo: 'grupo',
participantes: selectedUsers as any,
nome: groupName.trim()
});
abrirConversa(conversaId);
onClose();
} catch (error: any) {
console.error('Erro ao criar grupo:', error);
const mensagem = error?.message || error?.data || 'Erro desconhecido ao criar grupo';
alert(`Erro ao criar grupo: ${mensagem}`);
} finally {
loading = false;
}
}
async function handleCriarSalaReuniao() {
if (selectedUsers.length < 1) {
alert('Selecione pelo menos 1 participante');
return;
}
if (!salaReuniaoName.trim()) {
alert('Digite um nome para a sala de reunião');
return;
}
try {
loading = true;
const conversaId = await client.mutation(api.chat.criarSalaReuniao, {
nome: salaReuniaoName.trim(),
participantes: selectedUsers as any
});
abrirConversa(conversaId);
onClose();
} catch (error: any) {
console.error('Erro ao criar sala de reunião:', error);
const mensagem =
error?.message || error?.data || 'Erro desconhecido ao criar sala de reunião';
alert(`Erro ao criar sala de reunião: ${mensagem}`);
} finally {
loading = false;
}
}
</script>
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
<div
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col m-4"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h2 class="text-xl font-semibold">Nova Conversa</h2>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={onClose}
aria-label="Fechar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
<div
class="modal-box flex max-h-[85vh] max-w-2xl flex-col p-0"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<h2 class="flex items-center gap-2 text-2xl font-bold">
<MessageSquare class="text-primary h-6 w-6" />
Nova Conversa
</h2>
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<!-- Tabs -->
<div class="tabs tabs-boxed p-4">
<button
type="button"
class={`tab ${activeTab === "individual" ? "tab-active" : ""}`}
onclick={() => (activeTab = "individual")}
>
Individual
</button>
<button
type="button"
class={`tab ${activeTab === "grupo" ? "tab-active" : ""}`}
onclick={() => (activeTab = "grupo")}
>
Grupo
</button>
</div>
<!-- Tabs melhoradas -->
<div class="tabs tabs-boxed bg-base-200/50 p-4">
<button
type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === 'individual'
? 'tab-active bg-primary text-primary-content font-semibold'
: 'hover:bg-base-300'
}`}
onclick={() => {
activeTab = 'individual';
selectedUsers = [];
searchQuery = '';
}}
>
<User class="h-4 w-4" />
Individual
</button>
<button
type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === 'grupo'
? 'tab-active bg-primary text-primary-content font-semibold'
: 'hover:bg-base-300'
}`}
onclick={() => {
activeTab = 'grupo';
selectedUsers = [];
searchQuery = '';
}}
>
<Users class="h-4 w-4" />
Grupo
</button>
<button
type="button"
class={`tab flex items-center gap-2 transition-all duration-200 ${
activeTab === 'sala_reuniao'
? 'tab-active bg-primary text-primary-content font-semibold'
: 'hover:bg-base-300'
}`}
onclick={() => {
activeTab = 'sala_reuniao';
selectedUsers = [];
searchQuery = '';
}}
>
<Video class="h-4 w-4" />
Sala de Reunião
</button>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6">
{#if activeTab === "grupo"}
<!-- Criar Grupo -->
<div class="mb-4">
<label class="label">
<span class="label-text">Nome do Grupo</span>
</label>
<input
type="text"
placeholder="Digite o nome do grupo..."
class="input input-bordered w-full"
bind:value={groupName}
maxlength="50"
/>
</div>
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6 py-4">
{#if activeTab === 'grupo'}
<!-- Criar Grupo -->
<div class="mb-4">
<label class="label pb-2">
<span class="label-text font-semibold">Nome do Grupo</span>
</label>
<input
type="text"
placeholder="Digite o nome do grupo..."
class="input input-bordered focus:input-primary w-full transition-colors"
bind:value={groupName}
maxlength="50"
/>
</div>
<div class="mb-2">
<label class="label">
<span class="label-text">
Participantes {selectedUsers.length > 0 ? `(${selectedUsers.length})` : ""}
</span>
</label>
</div>
{/if}
<div class="mb-3">
<label class="label pb-2">
<span class="label-text font-semibold">
Participantes {selectedUsers.length > 0
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
: ''}
</span>
</label>
</div>
{:else if activeTab === 'sala_reuniao'}
<!-- Criar Sala de Reunião -->
<div class="mb-4">
<label class="label pb-2">
<span class="label-text font-semibold">Nome da Sala de Reunião</span>
<span class="label-text-alt text-primary font-medium">👑 Você será o administrador</span
>
</label>
<input
type="text"
placeholder="Digite o nome da sala de reunião..."
class="input input-bordered focus:input-primary w-full transition-colors"
bind:value={salaReuniaoName}
maxlength="50"
/>
</div>
<!-- Search -->
<div class="mb-4">
<input
type="text"
placeholder="Buscar usuários..."
class="input input-bordered w-full"
bind:value={searchQuery}
/>
</div>
<div class="mb-3">
<label class="label pb-2">
<span class="label-text font-semibold">
Participantes {selectedUsers.length > 0
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
: ''}
</span>
</label>
</div>
{/if}
<!-- Lista de usuários -->
<div class="space-y-2">
{#if usuarios && usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (usuario._id)}
<button
type="button"
class={`w-full text-left px-4 py-3 rounded-lg border transition-colors flex items-center gap-3 ${
activeTab === "grupo" && selectedUsers.includes(usuario._id)
? "border-primary bg-primary/10"
: "border-base-300 hover:bg-base-200"
}`}
onclick={() => {
if (activeTab === "individual") {
handleCriarIndividual(usuario._id);
} else {
toggleUserSelection(usuario._id);
}
}}
disabled={loading}
>
<!-- Avatar -->
<div class="relative flex-shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfil}
nome={usuario.nome}
size="sm"
/>
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={usuario.statusPresenca} size="sm" />
</div>
</div>
<!-- Search melhorado -->
<div class="relative mb-4">
<input
type="text"
placeholder="Buscar usuários por nome, email ou matrícula..."
class="input input-bordered focus:input-primary w-full pl-10 transition-colors"
bind:value={searchQuery}
/>
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-base-content truncate">{usuario.nome}</p>
<p class="text-sm text-base-content/60 truncate">
{usuario.setor || usuario.email}
</p>
</div>
<!-- Lista de usuários -->
<div class="space-y-2">
{#if usuarios?.data && usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (usuario._id)}
{@const isSelected = selectedUsers.includes(usuario._id)}
<button
type="button"
class={`flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-200 ${
isSelected
? 'border-primary bg-primary/10 scale-[1.02] shadow-md'
: 'border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm'
} ${loading ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
onclick={() => {
if (loading) return;
if (activeTab === 'individual') {
handleCriarIndividual(usuario._id);
} else {
toggleUserSelection(usuario._id);
}
}}
disabled={loading}
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<div class="absolute -right-1 -bottom-1">
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
</div>
</div>
<!-- Checkbox (apenas para grupo) -->
{#if activeTab === "grupo"}
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={selectedUsers.includes(usuario._id)}
readonly
/>
{/if}
</button>
{/each}
{:else if !usuarios}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-center py-8 text-base-content/50">
Nenhum usuário encontrado
</div>
{/if}
</div>
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<p class="text-base-content truncate font-semibold">
{usuario.nome}
</p>
<p class="text-base-content/60 truncate text-sm">
{usuario.setor || usuario.email || usuario.matricula || 'Sem informações'}
</p>
</div>
<!-- Footer (apenas para grupo) -->
{#if activeTab === "grupo"}
<div class="px-6 py-4 border-t border-base-300">
<button
type="button"
class="btn btn-primary btn-block"
onclick={handleCriarGrupo}
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
>
{#if loading}
<span class="loading loading-spinner"></span>
Criando...
{:else}
Criar Grupo
{/if}
</button>
</div>
{/if}
</div>
</div>
<!-- Checkbox melhorado (para grupo e sala de reunião) -->
{#if activeTab === 'grupo' || activeTab === 'sala_reuniao'}
<div class="shrink-0">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-lg"
checked={isSelected}
readonly
/>
</div>
{:else}
<!-- Ícone de seta para individual -->
<ChevronRight class="text-base-content/40 h-5 w-5" />
{/if}
</button>
{/each}
{:else if !usuarios?.data}
<div class="flex flex-col items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
<p class="text-base-content/60 mt-4">Carregando usuários...</p>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<UserX class="text-base-content/30 mb-4 h-16 w-16" />
<p class="text-base-content/70 font-medium">
{searchQuery.trim() ? 'Nenhum usuário encontrado' : 'Nenhum usuário disponível'}
</p>
{#if searchQuery.trim()}
<p class="text-base-content/50 mt-2 text-sm">
Tente buscar por nome, email ou matrícula
</p>
{/if}
</div>
{/if}
</div>
</div>
<!-- Footer (para grupo e sala de reunião) -->
{#if activeTab === 'grupo'}
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
<button
type="button"
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg transition-all duration-200 hover:shadow-xl"
onclick={handleCriarGrupo}
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
>
{#if loading}
<span class="loading loading-spinner"></span>
Criando grupo...
{:else}
<Plus class="h-5 w-5" />
Criar Grupo
{/if}
</button>
{#if selectedUsers.length < 2 && activeTab === 'grupo'}
<p class="text-base-content/50 mt-2 text-center text-xs">
Selecione pelo menos 2 participantes
</p>
{/if}
</div>
{:else if activeTab === 'sala_reuniao'}
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
<button
type="button"
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg transition-all duration-200 hover:shadow-xl"
onclick={handleCriarSalaReuniao}
disabled={loading || selectedUsers.length < 1 || !salaReuniaoName.trim()}
>
{#if loading}
<span class="loading loading-spinner"></span>
Criando sala...
{:else}
<Plus class="h-5 w-5" />
Criar Sala de Reunião
{/if}
</button>
{#if selectedUsers.length < 1 && activeTab === 'sala_reuniao'}
<p class="text-base-content/50 mt-2 text-center text-xs">
Selecione pelo menos 1 participante
</p>
{/if}
</div>
{/if}
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

@@ -5,19 +5,137 @@
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import { onMount } from "svelte";
import {
Bell,
Mail,
AtSign,
Users,
Calendar,
Clock,
BellOff,
Trash2,
X,
} from "lucide-svelte";
// Queries e Client
const client = useConvexClient();
const notificacoes = useQuery(api.chat.obterNotificacoes, { apenasPendentes: true });
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para contar apenas não lidas (para o badge)
const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {});
// Query para obter TODAS as notificações (para o popup)
const todasNotificacoesQuery = useQuery(api.chat.obterNotificacoes, {
apenasPendentes: false,
});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let dropdownOpen = $state(false);
let modalOpen = $state(false);
let notificacoesFerias = $state<
Array<{
_id: string;
mensagem: string;
tipo: string;
_creationTime: number;
}>
>([]);
let notificacoesAusencias = $state<
Array<{
_id: string;
mensagem: string;
tipo: string;
_creationTime: number;
}>
>([]);
let limpandoNotificacoes = $state(false);
// Helpers para obter valores das queries
const count = $derived(
(typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0,
);
const todasNotificacoes = $derived(
(Array.isArray(todasNotificacoesQuery)
? todasNotificacoesQuery
: todasNotificacoesQuery?.data) ?? [],
);
// Separar notificações lidas e não lidas
const notificacoesNaoLidas = $derived(
todasNotificacoes.filter((n) => !n.lida),
);
const notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
// Atualizar contador no store
$effect(() => {
if (count !== undefined) {
notificacoesCount.set(count);
const totalNotificacoes =
count +
(notificacoesFerias?.length || 0) +
(notificacoesAusencias?.length || 0);
notificacoesCount.set(totalNotificacoes);
});
// Buscar notificações de férias
async function buscarNotificacoesFerias() {
try {
const usuarioId = currentUser?.data?._id;
if (usuarioId) {
const notifsFerias = await client.query(
api.ferias.obterNotificacoesNaoLidas,
{
usuarioId,
},
);
notificacoesFerias = notifsFerias || [];
}
} catch (e) {
console.error("Erro ao buscar notificações de férias:", e);
}
}
// Buscar notificações de ausências
async function buscarNotificacoesAusencias() {
try {
const usuarioId = currentUser?.data?._id;
if (usuarioId) {
try {
const notifsAusencias = await client.query(
api.ausencias.obterNotificacoesNaoLidas,
{
usuarioId,
},
);
notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) {
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado)
const errorMessage =
queryError instanceof Error
? queryError.message
: String(queryError);
if (!errorMessage.includes("Could not find public function")) {
console.error(
"Erro ao buscar notificações de ausências:",
queryError,
);
}
notificacoesAusencias = [];
}
}
} catch (e) {
// Erro geral - silenciar se for sobre função não encontrada
const errorMessage = e instanceof Error ? e.message : String(e);
if (!errorMessage.includes("Could not find public function")) {
console.error("Erro ao buscar notificações de ausências:", e);
}
}
}
// Atualizar notificações periodicamente
$effect(() => {
buscarNotificacoesFerias();
buscarNotificacoesAusencias();
const interval = setInterval(() => {
buscarNotificacoesFerias();
buscarNotificacoesAusencias();
}, 30000); // A cada 30s
return () => clearInterval(interval);
});
function formatarTempo(timestamp: number): string {
@@ -33,206 +151,512 @@
async function handleMarcarTodasLidas() {
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
dropdownOpen = false;
// Marcar todas as notificações de férias como lidas
for (const notif of notificacoesFerias) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notif._id,
});
}
// Marcar todas as notificações de ausências como lidas
for (const notif of notificacoesAusencias) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notif._id,
});
}
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
}
async function handleLimparTodasNotificacoes() {
limpandoNotificacoes = true;
try {
await client.mutation(api.chat.limparTodasNotificacoes, {});
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
} catch (error) {
console.error("Erro ao limpar notificações:", error);
} finally {
limpandoNotificacoes = false;
}
}
async function handleLimparNotificacoesNaoLidas() {
limpandoNotificacoes = true;
try {
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
await buscarNotificacoesFerias();
await buscarNotificacoesAusencias();
} catch (error) {
console.error("Erro ao limpar notificações não lidas:", error);
} finally {
limpandoNotificacoes = false;
}
}
async function handleClickNotificacao(notificacaoId: string) {
await client.mutation(api.chat.marcarNotificacaoLida, { notificacaoId: notificacaoId as any });
dropdownOpen = false;
await client.mutation(api.chat.marcarNotificacaoLida, {
notificacaoId: notificacaoId as any,
});
}
function toggleDropdown() {
dropdownOpen = !dropdownOpen;
async function handleClickNotificacaoFerias(notificacaoId: string) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notificacaoId,
});
await buscarNotificacoesFerias();
// Redirecionar para a página de férias
window.location.href = "/recursos-humanos/ferias";
}
// Fechar dropdown ao clicar fora
onMount(() => {
async function handleClickNotificacaoAusencias(notificacaoId: string) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notificacaoId,
});
await buscarNotificacoesAusencias();
// Redirecionar para a página de perfil na aba de ausências
window.location.href = "/perfil?aba=minhas-ausencias";
}
function openModal() {
modalOpen = true;
}
function closeModal() {
modalOpen = false;
}
// Fechar popup ao clicar fora ou pressionar Escape
$effect(() => {
if (!modalOpen) return;
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest(".notification-bell")) {
dropdownOpen = false;
if (
!target.closest(".notification-popup") &&
!target.closest(".notification-bell")
) {
modalOpen = false;
}
}
function handleEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
modalOpen = false;
}
}
document.addEventListener("click", handleClickOutside);
return () => document.removeEventListener("click", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("click", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
});
</script>
<div class="notification-bell relative">
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
<button
type="button"
tabindex="0"
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden group transition-all duration-300 hover:scale-105"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={openModal}
aria-label="Notificações"
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
></div>
<!-- Anel de pulso sutil -->
<div
class="absolute inset-0 rounded-2xl"
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
></div>
<!-- Glow effect quando tem notificações -->
{#if count && count > 0}
<div
class="absolute inset-0 rounded-2xl bg-error/30 blur-lg animate-pulse"
></div>
{/if}
<!-- Ícone do sino PREENCHIDO moderno -->
<Bell
class="w-7 h-7 text-white relative z-10 transition-all duration-300 group-hover:scale-110"
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count &&
count > 0
? 'bell-ring 2s ease-in-out infinite'
: 'none'};"
fill="currentColor"
/>
<!-- Badge premium MODERNO com gradiente -->
{#if count + (notificacoesFerias?.length || 0) > 0}
{@const totalCount = count + (notificacoesFerias?.length || 0)}
<span
class="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full text-white text-[10px] font-black shadow-xl ring-2 ring-white z-20"
style="background: linear-gradient(135deg, #ff416c, #ff4b2b); box-shadow: 0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 4px 12px -2px rgba(255, 75, 43, 0.4); animation: badge-bounce 2s ease-in-out infinite;"
>
{totalCount > 9 ? "9+" : totalCount}
</span>
{/if}
</button>
<!-- Popup Flutuante de Notificações -->
{#if modalOpen}
<div
class="notification-popup fixed right-4 top-24 z-[100] w-[calc(100vw-2rem)] max-w-2xl max-h-[calc(100vh-7rem)] flex flex-col bg-base-100 rounded-2xl shadow-2xl border border-base-300 overflow-hidden backdrop-blur-sm"
style="animation: slideDown 0.2s ease-out;"
>
<!-- Header -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-base-300 bg-linear-to-r from-primary/5 to-primary/10"
>
<h3 class="text-2xl font-bold text-primary">Notificações</h3>
<div class="flex items-center gap-2">
{#if notificacoesNaoLidas.length > 0}
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={handleLimparNotificacoesNaoLidas}
disabled={limpandoNotificacoes}
>
<Trash2 class="w-4 h-4" />
Limpar não lidas
</button>
{/if}
{#if todasNotificacoes.length > 0}
<button
type="button"
class="btn btn-sm btn-error btn-outline"
onclick={handleLimparTodasNotificacoes}
disabled={limpandoNotificacoes}
>
<Trash2 class="w-4 h-4" />
Limpar todas
</button>
{/if}
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={closeModal}
>
<X class="w-5 h-5" />
</button>
</div>
</div>
<!-- Lista de notificações -->
<div class="flex-1 overflow-y-auto px-2 py-4">
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0}
<!-- Notificações não lidas -->
{#if notificacoesNaoLidas.length > 0}
<div class="mb-4">
<h4 class="text-sm font-semibold text-primary mb-2 px-2">
Não lidas
</h4>
{#each notificacoesNaoLidas as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-primary"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="shrink-0 mt-1">
{#if notificacao.tipo === "nova_mensagem"}
<Mail class="w-5 h-5 text-primary" strokeWidth={1.5} />
{:else if notificacao.tipo === "mencao"}
<AtSign
class="w-5 h-5 text-warning"
strokeWidth={1.5}
/>
{:else}
<Users class="w-5 h-5 text-info" strokeWidth={1.5} />
{/if}
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
{#if notificacao.tipo === "nova_mensagem" && notificacao.remetente}
<p class="text-sm font-semibold text-primary">
{notificacao.remetente.nome}
</p>
<p
class="text-xs text-base-content/70 mt-1 line-clamp-2"
>
{notificacao.descricao}
</p>
{:else if notificacao.tipo === "mencao" && notificacao.remetente}
<p class="text-sm font-semibold text-warning">
{notificacao.remetente.nome} mencionou você
</p>
<p
class="text-xs text-base-content/70 mt-1 line-clamp-2"
>
{notificacao.descricao}
</p>
{:else}
<p class="text-sm font-semibold text-base-content">
{notificacao.titulo}
</p>
<p
class="text-xs text-base-content/70 mt-1 line-clamp-2"
>
{notificacao.descricao}
</p>
{/if}
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
<!-- Indicador de não lida -->
<div class="shrink-0">
<div class="w-2 h-2 rounded-full bg-primary"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
<!-- Notificações lidas -->
{#if notificacoesLidas.length > 0}
<div class="mb-4">
<h4 class="text-sm font-semibold text-base-content/60 mb-2 px-2">
Lidas
</h4>
{#each notificacoesLidas as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 opacity-75"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="shrink-0 mt-1">
{#if notificacao.tipo === "nova_mensagem"}
<Mail
class="w-5 h-5 text-primary/60"
strokeWidth={1.5}
/>
{:else if notificacao.tipo === "mencao"}
<AtSign
class="w-5 h-5 text-warning/60"
strokeWidth={1.5}
/>
{:else}
<Users class="w-5 h-5 text-info/60" strokeWidth={1.5} />
{/if}
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
{#if notificacao.tipo === "nova_mensagem" && notificacao.remetente}
<p class="text-sm font-medium text-primary/70">
{notificacao.remetente.nome}
</p>
<p
class="text-xs text-base-content/60 mt-1 line-clamp-2"
>
{notificacao.descricao}
</p>
{:else if notificacao.tipo === "mencao" && notificacao.remetente}
<p class="text-sm font-medium text-warning/70">
{notificacao.remetente.nome} mencionou você
</p>
<p
class="text-xs text-base-content/60 mt-1 line-clamp-2"
>
{notificacao.descricao}
</p>
{:else}
<p class="text-sm font-medium text-base-content/70">
{notificacao.titulo}
</p>
<p
class="text-xs text-base-content/60 mt-1 line-clamp-2"
>
{notificacao.descricao}
</p>
{/if}
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
</div>
</button>
{/each}
</div>
{/if}
<!-- Notificações de Férias -->
{#if notificacoesFerias.length > 0}
<div class="mb-4">
<h4 class="text-sm font-semibold text-purple-600 mb-2 px-2">
Férias
</h4>
{#each notificacoesFerias as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-purple-600"
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="shrink-0 mt-1">
<Calendar
class="w-5 h-5 text-purple-600"
strokeWidth={2}
/>
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="shrink-0">
<div class="badge badge-primary badge-xs"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
<!-- Notificações de Ausências -->
{#if notificacoesAusencias.length > 0}
<div class="mb-4">
<h4 class="text-sm font-semibold text-orange-600 mb-2 px-2">
Ausências
</h4>
{#each notificacoesAusencias as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-orange-600"
onclick={() =>
handleClickNotificacaoAusencias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="shrink-0 mt-1">
<Clock class="w-5 h-5 text-orange-600" strokeWidth={2} />
</div>
<!-- Conteúdo -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.mensagem}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao._creationTime)}
</p>
</div>
<!-- Badge -->
<div class="shrink-0">
<div class="badge badge-warning badge-xs"></div>
</div>
</div>
</button>
{/each}
</div>
{/if}
{:else}
<!-- Sem notificações -->
<div class="px-4 py-12 text-center text-base-content/50">
<BellOff
class="w-16 h-16 mx-auto mb-4 opacity-50"
strokeWidth={1.5}
/>
<p class="text-base font-medium">Nenhuma notificação</p>
<p class="text-sm mt-1">Você está em dia!</p>
</div>
{/if}
</div>
<!-- Footer com estatísticas -->
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0}
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
<div
class="flex items-center justify-between text-xs text-base-content/60"
>
<span>
Total: {todasNotificacoes.length +
notificacoesFerias.length +
notificacoesAusencias.length} notificações
</span>
{#if notificacoesNaoLidas.length > 0}
<span class="text-primary font-semibold">
{notificacoesNaoLidas.length} não lidas
</span>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
@keyframes badge-bounce {
0%, 100% {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
}
@keyframes pulse-ring-subtle {
0%,
100% {
opacity: 0.1;
transform: scale(1);
}
50% {
opacity: 0.3;
transform: scale(1.05);
}
}
@keyframes bell-ring {
0%,
100% {
transform: rotate(0deg);
}
10%,
30% {
transform: rotate(-10deg);
}
20%,
40% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>
<div class="dropdown dropdown-end notification-bell">
<button
type="button"
tabindex="0"
class="btn btn-ghost btn-circle relative hover:bg-gradient-to-br hover:from-primary/10 hover:to-primary/5 transition-all duration-500 group"
onclick={toggleDropdown}
aria-label="Notificações"
>
<!-- Glow effect -->
{#if count && count > 0}
<div class="absolute inset-0 rounded-full bg-error/20 blur-xl animate-pulse"></div>
{/if}
<!-- Ícone do sino premium -->
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2.5"
stroke="currentColor"
class="w-7 h-7 relative z-10 transition-all duration-500 group-hover:scale-110 group-hover:-rotate-12 {count && count > 0 ? 'text-error drop-shadow-[0_0_8px_rgba(239,68,68,0.5)]' : 'text-primary'}"
style="filter: {count && count > 0 ? 'drop-shadow(0 0 4px rgba(239,68,68,0.4))' : 'drop-shadow(0 2px 4px rgba(0,0,0,0.1))'}"
>
<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>
<!-- Badge premium com gradiente -->
{#if count && count > 0}
<span
class="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-gradient-to-br from-red-500 via-error to-red-600 text-white text-[10px] font-black shadow-xl ring-2 ring-white z-20"
style="animation: badge-bounce 2s ease-in-out infinite;"
>
{count > 9 ? "9+" : count}
</span>
{/if}
</button>
{#if dropdownOpen}
<div
tabindex="0"
class="dropdown-content z-50 mt-3 w-80 max-h-96 overflow-auto rounded-box bg-base-100 p-2 shadow-2xl border border-base-300"
>
<!-- Header -->
<div class="flex items-center justify-between px-4 py-2 border-b border-base-300">
<h3 class="text-lg font-semibold">Notificações</h3>
{#if count && count > 0}
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={handleMarcarTodasLidas}
>
Marcar todas como lidas
</button>
{/if}
</div>
<!-- Lista de notificações -->
<div class="py-2">
{#if notificacoes && notificacoes.length > 0}
{#each notificacoes.slice(0, 10) as notificacao (notificacao._id)}
<button
type="button"
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
{#if notificacao.tipo === "nova_mensagem"}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-primary"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"
/>
</svg>
{:else if notificacao.tipo === "mencao"}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-warning"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 1 0-2.636 6.364M16.5 12V8.25"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-info"
>
<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 -->
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content">
{notificacao.titulo}
</p>
<p class="text-xs text-base-content/70 truncate">
{notificacao.descricao}
</p>
<p class="text-xs text-base-content/50 mt-1">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
<!-- Indicador de não lida -->
{#if !notificacao.lida}
<div class="flex-shrink-0">
<div class="w-2 h-2 rounded-full bg-primary"></div>
</div>
{/if}
</div>
</button>
{/each}
{:else}
<div class="px-4 py-8 text-center text-base-content/50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.143 17.082a24.248 24.248 0 0 0 3.844.148m-3.844-.148a23.856 23.856 0 0 1-5.455-1.31 8.964 8.964 0 0 0 2.3-5.542m3.155 6.852a3 3 0 0 0 5.667 1.97m1.965-2.277L21 21m-4.225-4.225a23.81 23.81 0 0 0 3.536-1.003A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6.53 6.53m10.245 10.245L6.53 6.53M3 3l3.53 3.53"
/>
</svg>
<p class="text-sm">Nenhuma notificação</p>
</div>
{/if}
</div>
</div>
{/if}
</div>

View File

@@ -4,6 +4,8 @@
import { onMount } from "svelte";
const client = useConvexClient();
// Token é passado automaticamente via interceptadores em +layout.svelte
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;

View File

@@ -0,0 +1,435 @@
<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 UserAvatar from './UserAvatar.svelte';
import UserStatusBadge from './UserStatusBadge.svelte';
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
interface Props {
conversaId: Id<'conversas'>;
isAdmin: boolean;
onClose: () => void;
}
let { conversaId, isAdmin, onClose }: Props = $props();
const client = useConvexClient();
const conversas = useQuery(api.chat.listarConversas, {});
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
let activeTab = $state<'participantes' | 'adicionar'>('participantes');
let searchQuery = $state('');
let loading = $state<string | null>(null);
let error = $state<string | null>(null);
const conversa = $derived(() => {
if (!conversas?.data) return null;
return conversas.data.find((c: any) => c._id === conversaId);
});
const todosUsuarios = $derived(() => {
return todosUsuariosQuery?.data || [];
});
const participantes = $derived(() => {
try {
const conv = conversa();
const usuarios = todosUsuarios();
if (!conv || !usuarios || usuarios.length === 0) return [];
const participantesInfo = conv.participantesInfo || [];
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0) return [];
return participantesInfo
.map((p: any) => {
try {
// p pode ser um objeto com _id ou apenas um ID
const participanteId = p?._id || p;
if (!participanteId) return null;
const usuario = usuarios.find((u: any) => {
try {
return String(u?._id) === String(participanteId);
} catch {
return false;
}
});
if (!usuario) return null;
// Combinar dados do usuário com dados do participante (se p for objeto)
return {
...usuario,
...(typeof p === 'object' && p !== null && p !== undefined ? p : {}),
// Garantir que _id existe e priorizar o do usuario
_id: usuario._id
};
} catch (err) {
console.error('Erro ao processar participante:', err, p);
return null;
}
})
.filter((p: any) => p !== null && p._id);
} catch (err) {
console.error('Erro ao calcular participantes:', err);
return [];
}
});
const administradoresIds = $derived(() => {
return conversa()?.administradores || [];
});
const usuariosDisponiveis = $derived(() => {
const usuarios = todosUsuarios();
if (!usuarios || usuarios.length === 0) return [];
const participantesIds = conversa()?.participantes || [];
return usuarios.filter(
(u: any) => !participantesIds.some((pid: any) => String(pid) === String(u._id))
);
});
const usuariosFiltrados = $derived(() => {
const disponiveis = usuariosDisponiveis();
if (!searchQuery.trim()) return disponiveis;
const query = searchQuery.toLowerCase();
return disponiveis.filter(
(u: any) =>
(u.nome || '').toLowerCase().includes(query) ||
(u.email || '').toLowerCase().includes(query) ||
(u.matricula || '').toLowerCase().includes(query)
);
});
function isParticipanteAdmin(usuarioId: string): boolean {
const admins = administradoresIds();
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
}
function isCriador(usuarioId: string): boolean {
const criadoPor = conversa()?.criadoPor;
return criadoPor ? String(criadoPor) === String(usuarioId) : false;
}
async function removerParticipante(participanteId: string) {
if (!confirm('Tem certeza que deseja remover este participante?')) return;
try {
loading = `remover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.removerParticipanteSala, {
conversaId,
participanteId: participanteId as any
});
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao remover participante';
}
} catch (err: any) {
error = err.message || 'Erro ao remover participante';
} finally {
loading = null;
}
}
async function promoverAdmin(participanteId: string) {
if (!confirm('Promover este participante a administrador?')) return;
try {
loading = `promover-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.promoverAdministrador, {
conversaId,
participanteId: participanteId as any
});
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao promover administrador';
}
} catch (err: any) {
error = err.message || 'Erro ao promover administrador';
} finally {
loading = null;
}
}
async function rebaixarAdmin(participanteId: string) {
if (!confirm('Rebaixar este administrador a participante?')) return;
try {
loading = `rebaixar-${participanteId}`;
error = null;
const resultado = await client.mutation(api.chat.rebaixarAdministrador, {
conversaId,
participanteId: participanteId as any
});
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao rebaixar administrador';
}
} catch (err: any) {
error = err.message || 'Erro ao rebaixar administrador';
} finally {
loading = null;
}
}
async function adicionarParticipante(usuarioId: string) {
try {
loading = `adicionar-${usuarioId}`;
error = null;
const resultado = await client.mutation(api.chat.adicionarParticipanteSala, {
conversaId,
participanteId: usuarioId as any
});
if (!resultado.sucesso) {
error = resultado.erro || 'Erro ao adicionar participante';
} else {
searchQuery = '';
}
} catch (err: any) {
error = err.message || 'Erro ao adicionar participante';
} finally {
loading = null;
}
}
</script>
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
<div
class="modal-box flex max-h-[80vh] max-w-2xl flex-col p-0"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
<div>
<h2 class="flex items-center gap-2 text-xl font-semibold">
<Users class="text-primary h-5 w-5" />
Gerenciar Sala de Reunião
</h2>
<p class="text-base-content/60 text-sm">
{conversa()?.nome || 'Sem nome'}
</p>
</div>
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
<X class="h-5 w-5" />
</button>
</div>
<!-- Tabs -->
{#if isAdmin}
<div class="tabs tabs-boxed p-4">
<button
type="button"
class={`tab flex items-center gap-2 ${activeTab === 'participantes' ? 'tab-active' : ''}`}
onclick={() => (activeTab = 'participantes')}
>
<Users class="h-4 w-4" />
Participantes
</button>
<button
type="button"
class={`tab flex items-center gap-2 ${activeTab === 'adicionar' ? 'tab-active' : ''}`}
onclick={() => (activeTab = 'adicionar')}
>
<UserPlus class="h-4 w-4" />
Adicionar Participante
</button>
</div>
{/if}
<!-- Error Message -->
{#if error}
<div class="alert alert-error mx-6 mt-2">
<span>{error}</span>
<button type="button" class="btn btn-sm btn-ghost" onclick={() => (error = null)}>
<X class="h-4 w-4" />
</button>
</div>
{/if}
<!-- Content -->
<div class="flex-1 overflow-y-auto px-6">
{#if !conversas?.data}
<!-- Loading conversas -->
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-base-content/60 ml-2 text-sm">Carregando conversa...</span>
</div>
{:else if !todosUsuariosQuery?.data}
<!-- Loading usuários -->
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
<span class="text-base-content/60 ml-2 text-sm">Carregando usuários...</span>
</div>
{:else if activeTab === 'participantes'}
<!-- Lista de Participantes -->
<div class="space-y-2 py-2">
{#if participantes().length > 0}
{#each participantes() as participante (String(participante._id))}
{@const participanteId = String(participante._id)}
{@const ehAdmin = isParticipanteAdmin(participanteId)}
{@const ehCriador = isCriador(participanteId)}
{@const isLoading = loading?.includes(participanteId)}
<div
class="border-base-300 hover:bg-base-200 flex items-center gap-3 rounded-lg border p-3 transition-colors"
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={participante.avatar}
fotoPerfilUrl={participante.fotoPerfilUrl || participante.avatar}
nome={participante.nome || 'Usuário'}
size="sm"
/>
<div class="absolute right-0 bottom-0">
<UserStatusBadge status={participante.statusPresenca || 'offline'} size="sm" />
</div>
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<p class="text-base-content truncate font-medium">
{participante.nome || 'Usuário'}
</p>
{#if ehAdmin}
<span class="badge badge-primary badge-sm">Admin</span>
{/if}
{#if ehCriador}
<span class="badge badge-secondary badge-sm">Criador</span>
{/if}
</div>
<p class="text-base-content/60 truncate text-sm">
{participante.setor || participante.email || ''}
</p>
</div>
<!-- Ações (apenas para admins) -->
{#if isAdmin && !ehCriador}
<div class="flex items-center gap-1">
{#if ehAdmin}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => rebaixarAdmin(participanteId)}
disabled={isLoading}
title="Rebaixar administrador"
>
{#if isLoading && loading?.includes('rebaixar')}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<ArrowDown class="h-4 w-4" />
{/if}
</button>
{:else}
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => promoverAdmin(participanteId)}
disabled={isLoading}
title="Promover a administrador"
>
{#if isLoading && loading?.includes('promover')}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<ArrowUp class="h-4 w-4" />
{/if}
</button>
{/if}
<button
type="button"
class="btn btn-xs btn-error btn-ghost"
onclick={() => removerParticipante(participanteId)}
disabled={isLoading}
title="Remover participante"
>
{#if isLoading && loading?.includes('remover')}
<span class="loading loading-spinner loading-xs"></span>
{:else}
<Trash2 class="h-4 w-4" />
{/if}
</button>
</div>
{/if}
</div>
{/each}
{:else}
<div class="text-base-content/50 py-8 text-center">Nenhum participante encontrado</div>
{/if}
</div>
{:else if activeTab === 'adicionar' && isAdmin}
<!-- Adicionar Participante -->
<div class="relative mb-4">
<input
type="text"
placeholder="Buscar usuários..."
class="input input-bordered w-full pl-10"
bind:value={searchQuery}
/>
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
</div>
<div class="space-y-2">
{#if usuariosFiltrados().length > 0}
{#each usuariosFiltrados() as usuario (String(usuario._id))}
{@const usuarioId = String(usuario._id)}
{@const isLoading = loading?.includes(usuarioId)}
<button
type="button"
class="border-base-300 hover:bg-base-200 flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition-colors"
onclick={() => adicionarParticipante(usuarioId)}
disabled={isLoading}
>
<!-- Avatar -->
<div class="relative shrink-0">
<UserAvatar
avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.avatar}
nome={usuario.nome || 'Usuário'}
size="sm"
/>
<div class="absolute right-0 bottom-0">
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
</div>
</div>
<!-- Info -->
<div class="min-w-0 flex-1">
<p class="text-base-content truncate font-medium">
{usuario.nome || 'Usuário'}
</p>
<p class="text-base-content/60 truncate text-sm">
{usuario.setor || usuario.email || ''}
</p>
</div>
<!-- Botão Adicionar -->
{#if isLoading}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<UserPlus class="text-primary h-5 w-5" />
{/if}
</button>
{/each}
{:else}
<div class="text-base-content/50 py-8 text-center">
{searchQuery.trim()
? 'Nenhum usuário encontrado'
: 'Todos os usuários já são participantes'}
</div>
{/if}
</div>
{/if}
</div>
<!-- Footer -->
<div class="border-base-300 border-t px-6 py-4">
<button type="button" class="btn btn-block" onclick={onClose}> Fechar </button>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

@@ -1,307 +1,269 @@
<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 { format } from "date-fns";
import { ptBR } from "date-fns/locale";
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 { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Clock, X, Trash2 } from 'lucide-svelte';
interface Props {
conversaId: Id<"conversas">;
onClose: () => void;
}
interface Props {
conversaId: Id<'conversas'>;
onClose: () => void;
}
let { conversaId, onClose }: Props = $props();
let { conversaId, onClose }: Props = $props();
const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { conversaId });
const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
conversaId
});
let mensagem = $state("");
let data = $state("");
let hora = $state("");
let loading = $state(false);
let mensagem = $state('');
let data = $state('');
let hora = $state('');
let loading = $state(false);
// Definir data/hora mínima (agora)
const now = new Date();
const minDate = format(now, "yyyy-MM-dd");
const minTime = format(now, "HH:mm");
// Rastrear mudanças nas mensagens agendadas
$effect(() => {
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
});
function getPreviewText(): string {
if (!data || !hora) return "";
try {
const dataHora = new Date(`${data}T${hora}`);
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
} catch {
return "";
}
}
// Definir data/hora mínima (agora)
const now = new Date();
const minDate = format(now, 'yyyy-MM-dd');
const minTime = format(now, 'HH:mm');
async function handleAgendar() {
if (!mensagem.trim() || !data || !hora) {
alert("Preencha todos os campos");
return;
}
function getPreviewText(): string {
if (!data || !hora) return '';
try {
loading = true;
const dataHora = new Date(`${data}T${hora}`);
// Validar data futura
if (dataHora.getTime() <= Date.now()) {
alert("A data e hora devem ser futuras");
return;
}
try {
const dataHora = new Date(`${data}T${hora}`);
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
} catch {
return '';
}
}
await client.mutation(api.chat.agendarMensagem, {
conversaId,
conteudo: mensagem.trim(),
agendadaPara: dataHora.getTime(),
});
async function handleAgendar() {
if (!mensagem.trim() || !data || !hora) {
alert('Preencha todos os campos');
return;
}
mensagem = "";
data = "";
hora = "";
alert("Mensagem agendada com sucesso!");
} catch (error) {
console.error("Erro ao agendar mensagem:", error);
alert("Erro ao agendar mensagem");
} finally {
loading = false;
}
}
try {
loading = true;
const dataHora = new Date(`${data}T${hora}`);
async function handleCancelar(mensagemId: string) {
if (!confirm("Deseja cancelar esta mensagem agendada?")) return;
// Validar data futura
if (dataHora.getTime() <= Date.now()) {
alert('A data e hora devem ser futuras');
return;
}
try {
await client.mutation(api.chat.cancelarMensagemAgendada, { mensagemId: mensagemId as any });
} catch (error) {
console.error("Erro ao cancelar mensagem:", error);
alert("Erro ao cancelar mensagem");
}
}
await client.mutation(api.chat.agendarMensagem, {
conversaId,
conteudo: mensagem.trim(),
agendadaPara: dataHora.getTime()
});
function formatarDataHora(timestamp: number): string {
try {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
} catch {
return "Data inválida";
}
}
mensagem = '';
data = '';
hora = '';
// Dar tempo para o Convex processar e recarregar a lista
setTimeout(() => {
alert('Mensagem agendada com sucesso!');
}, 500);
} catch (error) {
console.error('Erro ao agendar mensagem:', error);
alert('Erro ao agendar mensagem');
} finally {
loading = false;
}
}
async function handleCancelar(mensagemId: string) {
if (!confirm('Deseja cancelar esta mensagem agendada?')) return;
try {
await client.mutation(api.chat.cancelarMensagemAgendada, {
mensagemId: mensagemId as any
});
} catch (error) {
console.error('Erro ao cancelar mensagem:', error);
alert('Erro ao cancelar mensagem');
}
}
function formatarDataHora(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="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
<div
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col m-4"
onclick={(e) => e.stopPropagation()}
>
<!-- Header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
<h2 class="text-xl font-semibold">Agendar Mensagem</h2>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle"
onclick={onClose}
aria-label="Fechar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
</button>
</div>
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
<div
class="modal-box flex max-h-[90vh] max-w-2xl flex-col p-0"
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">
<Clock class="text-primary h-5 w-5" />
Agendar Mensagem
</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 overflow-y-auto p-6 space-y-6">
<!-- Formulário de Agendamento -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
<div class="form-control">
<label class="label">
<span class="label-text">Mensagem</span>
</label>
<textarea
class="textarea textarea-bordered h-24"
placeholder="Digite a mensagem..."
bind:value={mensagem}
maxlength="500"
></textarea>
<label class="label">
<span class="label-text-alt">{mensagem.length}/500</span>
</label>
</div>
<!-- Content -->
<div class="flex-1 space-y-6 overflow-y-auto p-6">
<!-- Formulário de Agendamento -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<label class="label">
<span class="label-text">Data</span>
</label>
<input
type="date"
class="input input-bordered"
bind:value={data}
min={minDate}
/>
</div>
<div class="form-control">
<label class="label" for="mensagem-input">
<span class="label-text">Mensagem</span>
</label>
<textarea
id="mensagem-input"
class="textarea textarea-bordered h-24"
placeholder="Digite a mensagem..."
bind:value={mensagem}
maxlength="500"
aria-describedby="char-count"
></textarea>
<div class="label">
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
</div>
</div>
<div class="form-control">
<label class="label">
<span class="label-text">Hora</span>
</label>
<input
type="time"
class="input input-bordered"
bind:value={hora}
min={data === minDate ? minTime : undefined}
/>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label" for="data-input">
<span class="label-text">Data</span>
</label>
<input
id="data-input"
type="date"
class="input input-bordered"
bind:value={data}
min={minDate}
/>
</div>
{#if getPreviewText()}
<div class="alert alert-info">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span>{getPreviewText()}</span>
</div>
{/if}
<div class="form-control">
<label class="label" for="hora-input">
<span class="label-text">Hora</span>
</label>
<input
id="hora-input"
type="time"
class="input input-bordered"
bind:value={hora}
min={data === minDate ? minTime : undefined}
/>
</div>
</div>
<div class="card-actions justify-end">
<button
type="button"
class="btn btn-primary"
onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora}
>
{#if loading}
<span class="loading loading-spinner"></span>
Agendando...
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
Agendar
{/if}
</button>
</div>
</div>
</div>
{#if getPreviewText()}
<div class="alert alert-info">
<Clock class="h-6 w-6" />
<span>{getPreviewText()}</span>
</div>
{/if}
<!-- Lista de Mensagens Agendadas -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
{#if mensagensAgendadas && mensagensAgendadas.length > 0}
<div class="space-y-3">
{#each mensagensAgendadas as msg (msg._id)}
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
<div class="flex-shrink-0 mt-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-primary"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content/80">
{formatarDataHora(msg.agendadaPara || 0)}
</p>
<p class="text-sm text-base-content mt-1 line-clamp-2">
{msg.conteudo}
</p>
</div>
<div class="card-actions justify-end">
<!-- Botão AGENDAR ultra moderno -->
<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"
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
onclick={handleAgendar}
disabled={loading || !mensagem.trim() || !data || !hora}
>
<!-- Efeito de brilho no hover -->
<div
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
></div>
<button
type="button"
class="btn btn-ghost btn-sm btn-circle text-error"
onclick={() => handleCancelar(msg._id)}
aria-label="Cancelar"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-center py-8 text-base-content/50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<p class="text-sm">Nenhuma mensagem agendada</p>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<div class="relative z-10 flex items-center gap-2">
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
<span>Agendando...</span>
{:else}
<Clock class="h-5 w-5 transition-transform group-hover:scale-110" />
<span class="transition-transform group-hover:scale-105">Agendar</span>
{/if}
</div>
</button>
</div>
</div>
</div>
<!-- Lista de Mensagens Agendadas -->
<div class="card bg-base-200">
<div class="card-body">
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
<div class="space-y-3">
{#each mensagensAgendadas.data as msg (msg._id)}
<div class="bg-base-100 flex items-start gap-3 rounded-lg p-3">
<div class="mt-1 shrink-0">
<Clock class="text-primary h-5 w-5" />
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content/80 text-sm font-medium">
{formatarDataHora(msg.agendadaPara || 0)}
</p>
<p class="text-base-content mt-1 line-clamp-2 text-sm">
{msg.conteudo}
</p>
</div>
<!-- Botão cancelar moderno -->
<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(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={() => handleCancelar(msg._id)}
aria-label="Cancelar"
>
<div
class="bg-error/0 group-hover:bg-error/20 absolute inset-0 transition-colors duration-300"
></div>
<Trash2
class="text-error relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
/>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas?.data}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-base-content/50 py-8 text-center">
<Clock class="mx-auto mb-2 h-12 w-12 opacity-50" />
<p class="text-sm">Nenhuma mensagem agendada</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

@@ -1,41 +1,43 @@
<script lang="ts">
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
interface Props {
avatar?: string;
fotoPerfilUrl?: string | null;
nome: string;
size?: "xs" | "sm" | "md" | "lg";
}
import { User } from 'lucide-svelte';
let { avatar, fotoPerfilUrl, nome, size = "md" }: Props = $props();
interface Props {
fotoPerfilUrl?: string | null;
nome: string;
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
}
const sizeClasses = {
xs: "w-8 h-8",
sm: "w-10 h-10",
md: "w-12 h-12",
lg: "w-16 h-16",
};
let { fotoPerfilUrl, nome, size = 'md' }: Props = $props();
function getAvatarUrl(avatarId: string): string {
// Usar gerador local ao invés da API externa
return generateAvatarUrl(avatarId);
}
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 avatarUrlToShow = $derived(() => {
if (fotoPerfilUrl) return fotoPerfilUrl;
if (avatar) return getAvatarUrl(avatar);
return getAvatarUrl(nome); // Fallback usando o nome
});
const iconSizes = {
xs: 16,
sm: 20,
md: 24,
lg: 32,
xl: 64
};
</script>
<div class="avatar">
<div class={`${sizeClasses[size]} rounded-full bg-base-200 overflow-hidden`}>
<img
src={avatarUrlToShow()}
alt={`Avatar de ${nome}`}
class="w-full h-full object-cover"
/>
</div>
<div class="avatar placeholder">
<div
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
>
{#if fotoPerfilUrl}
<img
src={fotoPerfilUrl}
alt={`Foto de perfil de ${nome}`}
class="h-full w-full object-cover"
/>
{:else}
<User size={iconSizes[size]} />
{/if}
</div>
</div>

View File

@@ -7,31 +7,57 @@
let { status = "offline", size = "md" }: Props = $props();
const sizeClasses = {
sm: "w-2 h-2",
md: "w-3 h-3",
lg: "w-4 h-4",
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
const statusConfig = {
online: {
color: "bg-success",
label: "Online",
borderColor: "border-success",
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",
label: "Offline",
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",
label: "Ausente",
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",
label: "Externo",
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",
label: "Em Reunião",
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",
},
};
@@ -39,8 +65,11 @@
</script>
<div
class={`${sizeClasses[size]} ${config.color} rounded-full`}
class={`${sizeClasses[size]} rounded-full relative flex items-center justify-center`}
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;"
title={config.label}
aria-label={config.label}
></div>
>
{@html config.icon}
</div>

View File

@@ -0,0 +1,399 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Calendar } from '@fullcalendar/core';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import multiMonthPlugin from '@fullcalendar/multimonth';
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
import { SvelteDate } from 'svelte/reactivity';
interface Props {
periodosExistentes?: Array<{ dataInicio: string; dataFim: string; dias: number }>;
onPeriodoAdicionado?: (periodo: { dataInicio: string; dataFim: string; dias: number }) => void;
onPeriodoRemovido?: (index: number) => void;
maxPeriodos?: number;
minDiasPorPeriodo?: number;
modoVisualizacao?: 'month' | 'multiMonth';
readonly?: boolean;
}
let {
periodosExistentes = [],
onPeriodoAdicionado,
onPeriodoRemovido,
maxPeriodos = 3,
minDiasPorPeriodo = 5,
modoVisualizacao = 'month',
readonly = false
}: Props = $props();
let calendarEl: HTMLDivElement;
let calendar: Calendar | null = null;
// Cores dos períodos
const coresPeriodos = [
{ bg: '#667eea', border: '#5568d3', text: '#ffffff' }, // Roxo
{ bg: '#f093fb', border: '#c75ce6', text: '#ffffff' }, // Rosa
{ bg: '#4facfe', border: '#00c6ff', text: '#ffffff' } // Azul
];
const eventos = $derived.by(() =>
periodosExistentes.map((periodo, index) => ({
id: `periodo-${index}`,
title: `Período ${index + 1} (${periodo.dias} dias)`,
start: periodo.dataInicio,
end: calcularDataFim(periodo.dataFim),
backgroundColor: coresPeriodos[index % coresPeriodos.length].bg,
borderColor: coresPeriodos[index % coresPeriodos.length].border,
textColor: coresPeriodos[index % coresPeriodos.length].text,
display: 'block',
extendedProps: {
index,
dias: periodo.dias
}
}))
);
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
function calcularDataFim(dataFim: string): string {
const data = new SvelteDate(dataFim);
data.setDate(data.getDate() + 1);
return data.toISOString().split('T')[0];
}
// Helper: Calcular dias entre datas (inclusivo)
function calcularDias(inicio: Date, fim: Date): number {
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Atualizar eventos quando períodos mudam
$effect(() => {
if (!calendar) return;
calendar.removeAllEvents();
if (eventos.length === 0) return;
// FullCalendar muta os objetos de evento internamente, então fornecemos cópias
const eventosClonados = eventos.map((evento) => ({
...evento,
extendedProps: { ...evento.extendedProps }
}));
calendar.addEventSource(eventosClonados);
});
onMount(() => {
if (!calendarEl) return;
calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
initialView: modoVisualizacao === 'multiMonth' ? 'multiMonthYear' : 'dayGridMonth',
locale: ptBrLocale,
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: modoVisualizacao === 'multiMonth' ? 'multiMonthYear' : 'dayGridMonth'
},
height: 'auto',
selectable: !readonly,
selectMirror: true,
unselectAuto: false,
events: eventos.map((evento) => ({ ...evento, extendedProps: { ...evento.extendedProps } })),
// Estilo customizado
buttonText: {
today: 'Hoje',
month: 'Mês',
multiMonthYear: 'Ano'
},
// Seleção de período
select: (info) => {
if (readonly) return;
const inicio = new Date(info.startStr);
const fim = new SvelteDate(info.endStr);
fim.setDate(fim.getDate() - 1); // FullCalendar usa exclusive end
const dias = calcularDias(inicio, fim);
// Validar número de períodos
if (periodosExistentes.length >= maxPeriodos) {
alert(`Máximo de ${maxPeriodos} períodos permitidos`);
calendar?.unselect();
return;
}
// Validar mínimo de dias
if (dias < minDiasPorPeriodo) {
alert(`Período deve ter no mínimo ${minDiasPorPeriodo} dias`);
calendar?.unselect();
return;
}
// Adicionar período
const novoPeriodo = {
dataInicio: info.startStr,
dataFim: fim.toISOString().split('T')[0],
dias
};
if (onPeriodoAdicionado) {
onPeriodoAdicionado(novoPeriodo);
}
calendar?.unselect();
},
// Click em evento para remover
eventClick: (info) => {
if (readonly) return;
const index = info.event.extendedProps.index;
if (
confirm(`Deseja remover o Período ${index + 1} (${info.event.extendedProps.dias} dias)?`)
) {
if (onPeriodoRemovido) {
onPeriodoRemovido(index);
}
}
},
// Tooltip ao passar mouse
eventDidMount: (info) => {
info.el.title = `Click para remover\n${info.event.title}`;
info.el.style.cursor = readonly ? 'default' : 'pointer';
},
// Desabilitar datas passadas
selectAllow: (selectInfo) => {
const hoje = new SvelteDate();
hoje.setHours(0, 0, 0, 0);
return new Date(selectInfo.start) >= hoje;
},
// Highlight de fim de semana
dayCellClassNames: (arg) => {
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
return ['fc-day-weekend-custom'];
}
return [];
}
});
calendar.render();
return () => {
calendar?.destroy();
};
});
</script>
<div class="calendario-ferias-wrapper">
<!-- Header com instruções -->
{#if !readonly}
<div class="alert alert-info mb-4 shadow-lg">
<svg
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 class="text-sm">
<p class="font-bold">Como usar:</p>
<ul class="mt-1 list-inside list-disc">
<li>Clique e arraste no calendário para selecionar um período de férias</li>
<li>Clique em um período colorido para removê-lo</li>
<li>
Você pode adicionar até {maxPeriodos} períodos (mínimo {minDiasPorPeriodo} dias cada)
</li>
</ul>
</div>
</div>
{/if}
<!-- Calendário -->
<div
bind:this={calendarEl}
class="calendario-ferias border-primary/10 overflow-hidden rounded-2xl border-2 shadow-2xl"
></div>
<!-- Legenda de períodos -->
{#if periodosExistentes.length > 0}
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-3">
{#each periodosExistentes as periodo, index (index)}
<div
class="stat bg-base-100 rounded-xl border-2 shadow-lg transition-all hover:scale-105"
style="border-color: {coresPeriodos[index % coresPeriodos.length].border}"
>
<div
class="stat-figure flex h-12 w-12 items-center justify-center rounded-full text-xl font-bold text-white"
style="background: {coresPeriodos[index % coresPeriodos.length].bg}"
>
{index + 1}
</div>
<div class="stat-title">Período {index + 1}</div>
<div
class="stat-value text-2xl"
style="color: {coresPeriodos[index % coresPeriodos.length].bg}"
>
{periodo.dias} dias
</div>
<div class="stat-desc">
{new Date(periodo.dataInicio).toLocaleDateString('pt-BR')} até
{new Date(periodo.dataFim).toLocaleDateString('pt-BR')}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
/* Calendário Premium */
.calendario-ferias {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
}
/* Toolbar moderna */
:global(.fc .fc-toolbar) {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
border-radius: 1rem 1rem 0 0;
color: white !important;
}
:global(.fc .fc-toolbar-title) {
color: white !important;
font-weight: 700;
font-size: 1.5rem;
}
:global(.fc .fc-button) {
background: rgba(255, 255, 255, 0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.3) !important;
color: white !important;
font-weight: 600;
text-transform: capitalize;
transition: all 0.3s ease;
}
:global(.fc .fc-button:hover) {
background: rgba(255, 255, 255, 0.3) !important;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
:global(.fc .fc-button-active) {
background: rgba(255, 255, 255, 0.4) !important;
}
/* Cabeçalho dos dias */
:global(.fc .fc-col-header-cell) {
background: linear-gradient(180deg, #f8f9fa 0%, #e9ecef 100%);
font-weight: 600;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.05em;
padding: 0.75rem 0.5rem;
color: #495057;
}
/* Células dos dias */
:global(.fc .fc-daygrid-day) {
transition: all 0.2s ease;
}
:global(.fc .fc-daygrid-day:hover) {
background: rgba(102, 126, 234, 0.05);
}
:global(.fc .fc-daygrid-day-number) {
padding: 0.5rem;
font-weight: 600;
color: #495057;
}
/* Fim de semana */
:global(.fc .fc-day-weekend-custom) {
background: rgba(255, 193, 7, 0.05);
}
/* Hoje */
:global(.fc .fc-day-today) {
background: rgba(102, 126, 234, 0.1) !important;
border: 2px solid #667eea !important;
}
/* Eventos (períodos selecionados) */
:global(.fc .fc-event) {
border-radius: 0.5rem;
padding: 0.25rem 0.5rem;
font-weight: 600;
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
transition: all 0.3s ease;
cursor: pointer;
}
:global(.fc .fc-event:hover) {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25);
}
/* Seleção (arrastar) */
:global(.fc .fc-highlight) {
background: rgba(102, 126, 234, 0.3) !important;
border: 2px dashed #667eea;
}
/* Datas desabilitadas (passado) */
:global(.fc .fc-day-past .fc-daygrid-day-number) {
opacity: 0.4;
}
/* Remover bordas padrão */
:global(.fc .fc-scrollgrid) {
border: none !important;
}
:global(.fc .fc-scrollgrid-section > td) {
border: none !important;
}
/* Grid moderno */
:global(.fc .fc-daygrid-day-frame) {
border: 1px solid #e9ecef;
min-height: 80px;
}
/* Responsivo */
@media (max-width: 768px) {
:global(.fc .fc-toolbar) {
flex-direction: column;
gap: 0.75rem;
}
:global(.fc .fc-toolbar-title) {
font-size: 1.25rem;
}
:global(.fc .fc-button) {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
}
}
</style>

View File

@@ -0,0 +1,406 @@
<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';
interface Props {
funcionarioId: Id<'funcionarios'>;
}
let { funcionarioId }: Props = $props();
// Queries
const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId });
const solicitacoesQuery = useQuery(api.ferias.listarMinhasSolicitacoes, {
funcionarioId
});
const saldos = $derived(saldosQuery.data || []);
const solicitacoes = $derived(solicitacoesQuery.data || []);
// Estatísticas derivadas
const saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
const totalSolicitacoes = $derived(solicitacoes.length);
const aprovadas = $derived(
solicitacoes.filter((s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada')
.length
);
const pendentes = $derived(
solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length
);
const reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
// Canvas para gráfico de pizza
let canvasSaldo = $state<HTMLCanvasElement>();
let canvasStatus = $state<HTMLCanvasElement>();
// Função para desenhar gráfico de pizza moderno
function desenharGraficoPizza(
canvas: HTMLCanvasElement,
dados: { label: string; valor: number; cor: string }[]
) {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const width = canvas.width;
const height = canvas.height;
const centerX = width / 2;
const centerY = height / 2;
const radius = Math.min(width, height) / 2 - 20;
ctx.clearRect(0, 0, width, height);
const total = dados.reduce((acc, d) => acc + d.valor, 0);
if (total === 0) return;
let startAngle = -Math.PI / 2;
dados.forEach((item) => {
const sliceAngle = (2 * Math.PI * item.valor) / total;
// Desenhar fatia com sombra
ctx.save();
ctx.shadowColor = 'rgba(0, 0, 0, 0.2)';
ctx.shadowBlur = 15;
ctx.shadowOffsetX = 5;
ctx.shadowOffsetY = 5;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
ctx.arc(centerX, centerY, radius, startAngle, startAngle + sliceAngle);
ctx.closePath();
ctx.fillStyle = item.cor;
ctx.fill();
ctx.restore();
// Desenhar borda branca
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 3;
ctx.stroke();
startAngle += sliceAngle;
});
// Desenhar círculo branco no centro (efeito donut)
ctx.beginPath();
ctx.arc(centerX, centerY, radius * 0.6, 0, 2 * Math.PI);
ctx.fillStyle = '#ffffff';
ctx.fill();
}
// Atualizar gráficos quando dados mudarem
$effect(() => {
if (canvasSaldo && saldoAtual) {
desenharGraficoPizza(canvasSaldo, [
{ label: 'Usado', valor: saldoAtual.diasUsados, cor: '#ff6b6b' },
{ label: 'Pendente', valor: saldoAtual.diasPendentes, cor: '#ffa94d' },
{
label: 'Disponível',
valor: saldoAtual.diasDisponiveis,
cor: '#51cf66'
}
]);
}
if (canvasStatus && totalSolicitacoes > 0) {
desenharGraficoPizza(canvasStatus, [
{ label: 'Aprovadas', valor: aprovadas, cor: '#51cf66' },
{ label: 'Pendentes', valor: pendentes, cor: '#ffa94d' },
{ label: 'Reprovadas', valor: reprovadas, cor: '#ff6b6b' }
]);
}
});
</script>
<div class="dashboard-ferias">
<!-- Header -->
<div class="mb-8">
<h1
class="from-primary to-secondary bg-linear-to-r bg-clip-text text-4xl font-bold text-transparent"
>
📊 Dashboard de Férias
</h1>
<p class="text-base-content/70 mt-2">Visualize seus saldos e histórico de solicitações</p>
</div>
{#if saldosQuery.isLoading || solicitacoesQuery.isLoading}
<!-- Loading Skeletons -->
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4)}
<div class="skeleton h-32 rounded-2xl"></div>
{/each}
</div>
{:else}
<!-- Cards de Estatísticas -->
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Card 1: Saldo Disponível -->
<div
class="stat from-success/20 to-success/5 border-success/30 rounded-2xl border-2 bg-linear-to-br shadow-2xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-success">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-10 w-10 stroke-current"
>
<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"
></path>
</svg>
</div>
<div class="stat-title text-success font-semibold">Disponível</div>
<div class="stat-value text-success text-4xl">
{saldoAtual?.diasDisponiveis || 0}
</div>
<div class="stat-desc text-success/70">dias para usar</div>
</div>
<!-- Card 2: Dias Usados -->
<div
class="stat from-error/20 to-error/5 border-error/30 rounded-2xl border-2 bg-linear-to-br shadow-2xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-error">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-10 w-10 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
</div>
<div class="stat-title text-error font-semibold">Usado</div>
<div class="stat-value text-error text-4xl">
{saldoAtual?.diasUsados || 0}
</div>
<div class="stat-desc text-error/70">dias já gozados</div>
</div>
<!-- Card 3: Pendentes -->
<div
class="stat from-warning/20 to-warning/5 border-warning/30 rounded-2xl border-2 bg-linear-to-br shadow-2xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-10 w-10 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 class="stat-title text-warning font-semibold">Pendentes</div>
<div class="stat-value text-warning text-4xl">
{saldoAtual?.diasPendentes || 0}
</div>
<div class="stat-desc text-warning/70">aguardando aprovação</div>
</div>
<!-- Card 4: Total de Direito -->
<div
class="stat from-primary/20 to-primary/5 border-primary/30 rounded-2xl border-2 bg-linear-to-br shadow-2xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block h-10 w-10 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
></path>
</svg>
</div>
<div class="stat-title text-primary font-semibold">Total Direito</div>
<div class="stat-value text-primary text-4xl">
{saldoAtual?.diasDireito || 0}
</div>
<div class="stat-desc text-primary/70">dias no ano</div>
</div>
</div>
<!-- Gráficos -->
<div class="mb-8 grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Gráfico 1: Distribuição de Saldo -->
<div class="card bg-base-100 border-base-300 border-2 shadow-2xl">
<div class="card-body">
<h2 class="card-title mb-4 text-2xl">
🥧 Distribuição de Saldo
<div class="badge badge-primary badge-lg">
Ano {saldoAtual?.anoReferencia || new Date().getFullYear()}
</div>
</h2>
{#if saldoAtual}
<div class="flex items-center justify-center">
<canvas bind:this={canvasSaldo} width="300" height="300" class="max-w-full"></canvas>
</div>
<!-- Legenda -->
<div class="mt-4 flex flex-wrap justify-center gap-4">
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full bg-[#51cf66]"></div>
<span class="text-sm font-semibold"
>Disponível: {saldoAtual.diasDisponiveis} dias</span
>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full bg-[#ffa94d]"></div>
<span class="text-sm font-semibold">Pendente: {saldoAtual.diasPendentes} dias</span>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full bg-[#ff6b6b]"></div>
<span class="text-sm font-semibold">Usado: {saldoAtual.diasUsados} dias</span>
</div>
</div>
{:else}
<div class="alert alert-info">
<svg
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>
<span>Nenhum saldo disponível para o ano atual</span>
</div>
{/if}
</div>
</div>
<!-- Gráfico 2: Status de Solicitações -->
<div class="card bg-base-100 border-base-300 border-2 shadow-2xl">
<div class="card-body">
<h2 class="card-title mb-4 text-2xl">
📋 Status de Solicitações
<div class="badge badge-secondary badge-lg">
Total: {totalSolicitacoes}
</div>
</h2>
{#if totalSolicitacoes > 0}
<div class="flex items-center justify-center">
<canvas bind:this={canvasStatus} width="300" height="300" class="max-w-full"></canvas>
</div>
<!-- Legenda -->
<div class="mt-4 flex flex-wrap justify-center gap-4">
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full bg-[#51cf66]"></div>
<span class="text-sm font-semibold">Aprovadas: {aprovadas}</span>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full bg-[#ffa94d]"></div>
<span class="text-sm font-semibold">Pendentes: {pendentes}</span>
</div>
<div class="flex items-center gap-2">
<div class="h-4 w-4 rounded-full bg-[#ff6b6b]"></div>
<span class="text-sm font-semibold">Reprovadas: {reprovadas}</span>
</div>
</div>
{:else}
<div class="alert alert-info">
<svg
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>
<span>Nenhuma solicitação de férias ainda</span>
</div>
{/if}
</div>
</div>
</div>
<!-- Histórico de Saldos -->
{#if saldos.length > 0}
<div class="card bg-base-100 border-base-300 border-2 shadow-2xl">
<div class="card-body">
<h2 class="card-title mb-4 text-2xl">📅 Histórico de Saldos</h2>
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Ano</th>
<th>Direito</th>
<th>Usado</th>
<th>Pendente</th>
<th>Disponível</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{#each saldos as saldo (saldo._id)}
<tr>
<td class="font-bold">{saldo.anoReferencia}</td>
<td>{saldo.diasDireito} dias</td>
<td><span class="badge badge-error">{saldo.diasUsados}</span></td>
<td><span class="badge badge-warning">{saldo.diasPendentes}</span></td>
<td><span class="badge badge-success">{saldo.diasDisponiveis}</span></td>
<td>
{#if saldo.status === 'ativo'}
<span class="badge badge-success">Ativo</span>
{:else if saldo.status === 'vencido'}
<span class="badge badge-error">Vencido</span>
{:else}
<span class="badge badge-neutral">Concluído</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
{/if}
</div>
<style>
.bg-clip-text {
-webkit-background-clip: text;
background-clip: text;
}
canvas {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
</style>

View File

@@ -0,0 +1,891 @@
<script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { toast } from 'svelte-sonner';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface Props {
funcionarioId: Id<'funcionarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
// Cliente Convex
const client = useConvexClient();
// Estado do wizard
let passoAtual = $state(1);
const totalPassos = 3;
// Dados da solicitação
let anoSelecionado = $state(new Date().getFullYear());
let periodosFerias: Array<{
dataInicio: string;
dataFim: string;
dias: number;
}> = $state([]);
let observacao = $state('');
let processando = $state(false);
// Estados para os selects de data
let dataInicioPeriodo = $state('');
let dataFimPeriodo = $state('');
// Queries
const funcionarioQuery = useQuery(api.funcionarios.getById, { id: funcionarioId });
const funcionario = $derived(funcionarioQuery?.data);
const regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
const saldoQuery = $derived(
useQuery(api.saldoFerias.obterSaldo, {
funcionarioId,
anoReferencia: anoSelecionado
})
);
const validacaoQuery = $derived(
periodosFerias.length > 0
? useQuery(api.saldoFerias.validarSolicitacao, {
funcionarioId,
anoReferencia: anoSelecionado,
periodos: periodosFerias.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim
}))
})
: { data: null }
);
// Derivados
const saldo = $derived(saldoQuery.data);
const validacao = $derived(validacaoQuery.data);
const totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0));
// Anos disponíveis (últimos 3 anos + próximo ano)
const anosDisponiveis = $derived.by(() => {
const anoAtual = new Date().getFullYear();
return [anoAtual - 1, anoAtual, anoAtual + 1];
});
// Verificar se é regime estatutário PE ou Municipal
const ehEstatutarioPEOuMunicipal = $derived(
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
);
// Função para calcular dias entre duas datas
function calcularDias(dataInicio: string, dataFim: string): number {
if (!dataInicio || !dataFim) return 0;
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
return diffDays;
}
// Função para formatar data sem problemas de timezone
function formatarDataString(dataString: string): string {
if (!dataString) return '';
// Dividir a string da data (formato YYYY-MM-DD)
const partes = dataString.split('-');
if (partes.length !== 3) return dataString;
// Retornar no formato DD/MM/YYYY
return `${partes[2]}/${partes[1]}/${partes[0]}`;
}
// Função para adicionar período
function adicionarPeriodo() {
if (!dataInicioPeriodo || !dataFimPeriodo) {
toast.error('Selecione as datas de início e fim');
return;
}
const dias = calcularDias(dataInicioPeriodo, dataFimPeriodo);
if (dias <= 0) {
toast.error('Data de fim deve ser posterior à data de início');
return;
}
// Validações específicas para estatutário PE e Municipal
// Permite períodos fracionados: cada período deve ser 15 ou 30 dias
// Total não pode exceder 30 dias, mas pode ser menos
if (ehEstatutarioPEOuMunicipal) {
// Verificar se o período individual é válido (15 ou 30 dias)
if (dias !== 15 && dias !== 30) {
toast.error('Para seu regime, cada período deve ter exatamente 15 ou 30 dias');
return;
}
// Verificar se já tem 2 períodos
if (periodosFerias.length >= 2) {
toast.error('Máximo de 2 períodos permitidos para seu regime');
return;
}
// Verificar se o total não excede 30 dias
const novoTotal = totalDiasSelecionados + dias;
if (novoTotal > 30) {
toast.error(`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`);
return;
}
}
// Verificar se o total não excede o saldo disponível
const novoTotal = totalDiasSelecionados + dias;
if (saldo && novoTotal > saldo.diasDisponiveis) {
toast.error(`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`);
return;
}
periodosFerias = [
...periodosFerias,
{
dataInicio: dataInicioPeriodo,
dataFim: dataFimPeriodo,
dias
}
];
toast.success(`Período de ${dias} dias adicionado! ✅`);
// Limpar campos
dataInicioPeriodo = '';
dataFimPeriodo = '';
}
// Função para remover período
function removerPeriodo(index: number) {
const removido = periodosFerias[index];
periodosFerias = periodosFerias.filter((_, i) => i !== index);
toast.info(`Período de ${removido.dias} dias removido`);
}
// Funções
function proximoPasso() {
if (passoAtual === 1 && !saldo) {
toast.error('Selecione um ano com saldo disponível');
return;
}
if (passoAtual === 2 && periodosFerias.length === 0) {
toast.error('Adicione pelo menos 1 período de férias');
return;
}
if (passoAtual === 2 && validacao && !validacao.valido) {
toast.error('Corrija os erros antes de continuar');
return;
}
if (passoAtual < totalPassos) {
passoAtual++;
}
}
function passoAnterior() {
if (passoAtual > 1) {
passoAtual--;
}
}
async function enviarSolicitacao() {
if (!validacao || !validacao.valido) {
toast.error('Valide os períodos antes de enviar');
return;
}
processando = true;
try {
await client.mutation(api.ferias.criarSolicitacao, {
funcionarioId,
anoReferencia: anoSelecionado,
periodos: periodosFerias.map((p) => ({
dataInicio: p.dataInicio,
dataFim: p.dataFim,
diasCorridos: p.dias
})),
observacao: observacao || undefined
});
toast.success('Solicitação de férias enviada com sucesso! 🎉');
if (onSucesso) onSucesso();
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
toast.error(errorMessage || 'Erro ao enviar solicitação');
} finally {
processando = false;
}
}
// Calcular dias do período atual
const diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
</script>
<div class="wizard-ferias-container">
<!-- Progress Bar -->
<div class="mb-8">
<div class="relative flex items-start">
{#each Array(totalPassos) as _, i (i)}
{@const labels = ['Ano & Saldo', 'Períodos', 'Confirmação']}
<div class="relative z-10 flex flex-1 flex-col items-center">
<!-- Círculo do passo -->
<div
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:text-white={passoAtual > i + 1}
class:border-4={passoAtual === i + 1}
class:border-primary={passoAtual === i + 1}
class:bg-base-200={passoAtual < i + 1}
class:text-base-content={passoAtual < i + 1}
style:box-shadow={passoAtual === i + 1 ? '0 0 20px rgba(102, 126, 234, 0.5)' : 'none'}
>
{#if passoAtual > i + 1}
<svg
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}
{i + 1}
{/if}
</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 -->
{#if i < totalPassos - 1}
<div
class="absolute left-1/2 top-6 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-base-300={passoAtual <= i + 1}
></div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Conteúdo dos Passos -->
<div class="wizard-content">
<!-- PASSO 1: Ano & Saldo -->
{#if passoAtual === 1}
<div class="passo-content animate-fadeIn">
<h2
class="from-primary to-secondary mb-6 bg-linear-to-r bg-clip-text text-center text-3xl font-bold text-transparent"
>
Escolha o Ano de Referência
</h2>
<!-- Seletor de Ano -->
<div class="mb-8 grid grid-cols-3 gap-4">
{#each anosDisponiveis as ano (ano)}
<button
type="button"
class="btn btn-lg transition-all duration-300 hover:scale-105"
class:btn-outline={anoSelecionado !== ano}
style:border-color={anoSelecionado === ano ? '#f97316' : undefined}
style:border-width={anoSelecionado === ano ? '2px' : undefined}
style:color={anoSelecionado === ano ? '#000000' : undefined}
style:background-color={anoSelecionado === ano ? 'transparent' : undefined}
style:box-shadow={anoSelecionado === ano ? '0 0 10px rgba(249, 115, 22, 0.3)' : undefined}
onclick={() => (anoSelecionado = ano)}
>
{ano}
</button>
{/each}
</div>
<!-- Card de Saldo -->
{#if saldoQuery.isLoading}
<div class="skeleton h-64 w-full rounded-2xl"></div>
{:else if saldo}
<div
class="card from-primary/10 to-secondary/10 border-primary/20 border-2 bg-linear-to-br shadow-2xl"
>
<div class="card-body">
<h3 class="card-title mb-4 text-2xl">
📊 Saldo de Férias {anoSelecionado}
</h3>
<div class="stats stats-vertical lg:stats-horizontal w-full shadow-lg">
<div class="stat">
<div class="stat-figure text-primary">
<svg
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 class="stat-title">Total Direito</div>
<div class="stat-value text-primary">{saldo.diasDireito}</div>
<div class="stat-desc">dias no ano</div>
</div>
<div class="stat">
<div class="stat-figure text-success">
<svg
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 class="stat-title">Disponível</div>
<div class="stat-value text-success">
{saldo.diasDisponiveis}
</div>
<div class="stat-desc">para usar</div>
</div>
<div class="stat">
<div class="stat-figure text-warning">
<svg
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 class="stat-title">Usado</div>
<div class="stat-value text-warning">{saldo.diasUsados}</div>
<div class="stat-desc">até agora</div>
</div>
</div>
<!-- Informações do Regime -->
<div class="alert alert-info mt-4">
<svg
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>
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
<p class="text-sm">
Período aquisitivo: {formatarDataString(saldo.dataInicio)}
a {formatarDataString(saldo.dataFim)}
</p>
{#if ehEstatutarioPEOuMunicipal}
<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.
</p>
{/if}
</div>
</div>
{#if saldo.diasDisponiveis === 0}
<div class="alert alert-warning mt-4">
<svg
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>
</div>
{/if}
</div>
</div>
{:else}
<div class="alert alert-warning">
<svg
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>
</div>
{/if}
</div>
{/if}
<!-- PASSO 2: Seleção de Períodos -->
{#if passoAtual === 2}
<div class="passo-content animate-fadeIn">
<h2
class="from-primary to-secondary mb-6 bg-linear-to-r bg-clip-text text-center text-3xl font-bold text-transparent"
>
Selecione os Períodos de Férias
</h2>
<!-- Resumo rápido -->
<div class="alert bg-base-200 mb-6">
<svg
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>
<p>
<strong>Saldo disponível:</strong>
{saldo?.diasDisponiveis || 0} dias |
<strong>Selecionados:</strong>
{totalDiasSelecionados} dias | <strong>Restante:</strong>
{(saldo?.diasDisponiveis || 0) - totalDiasSelecionados} dias
</p>
{#if ehEstatutarioPEOuMunicipal}
<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.
</p>
{/if}
</div>
</div>
<!-- Formulário para adicionar período -->
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<h3 class="card-title mb-4">Adicionar Período</h3>
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
type="date"
class="input input-bordered"
bind:value={dataInicioPeriodo}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
type="date"
class="input input-bordered"
bind:value={dataFimPeriodo}
min={dataInicioPeriodo || undefined}
/>
</div>
<div class="form-control">
<label class="label">
<span class="label-text font-semibold">Dias</span>
</label>
<div class="input input-bordered flex items-center">
<span class="font-bold text-primary">{diasPeriodoAtual}</span>
<span class="ml-2 text-sm opacity-70">dias</span>
</div>
</div>
</div>
<div class="card-actions mt-4">
<button
type="button"
class="btn btn-primary gap-2"
onclick={adicionarPeriodo}
disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0}
>
<svg
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
</button>
</div>
</div>
</div>
<!-- Lista de períodos adicionados -->
{#if periodosFerias.length > 0}
<div class="card bg-base-100 shadow-lg mb-6">
<div class="card-body">
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
<div class="space-y-3">
{#each periodosFerias as periodo, index (index)}
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-4">
<div
class="badge badge-lg badge-primary flex h-12 w-12 items-center justify-center font-bold text-white"
>
{index + 1}
</div>
<div class="flex-1">
<p class="font-semibold">
{formatarDataString(periodo.dataInicio)}
até
{formatarDataString(periodo.dataFim)}
</p>
<p class="text-base-content/70 text-sm">
{periodo.dias} dias corridos
</p>
</div>
<button
type="button"
class="btn btn-error btn-sm gap-2"
onclick={() => removerPeriodo(index)}
>
<svg
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
</button>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Validações -->
{#if validacao && periodosFerias.length > 0}
<div class="mt-6">
{#if validacao.valido}
<div class="alert alert-success">
<svg
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>
</div>
{:else}
<div class="alert alert-error">
<svg
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>
<p class="font-bold">Erros encontrados:</p>
<ul class="list-inside list-disc">
{#each validacao.erros as erro (erro)}
<li>{erro}</li>
{/each}
</ul>
</div>
</div>
{/if}
{#if validacao.avisos.length > 0}
<div class="alert alert-warning mt-4">
<svg
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>
<p class="font-bold">Avisos:</p>
<ul class="list-inside list-disc">
{#each validacao.avisos as aviso (aviso)}
<li>{aviso}</li>
{/each}
</ul>
</div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
<!-- PASSO 3: Confirmação -->
{#if passoAtual === 3}
<div class="passo-content animate-fadeIn">
<h2
class="from-primary to-secondary mb-6 bg-linear-to-r bg-clip-text text-center text-3xl font-bold text-transparent"
>
Confirme sua Solicitação
</h2>
<!-- Resumo Final -->
<div class="card bg-base-100 shadow-2xl">
<div class="card-body">
<h3 class="card-title mb-4 text-xl">📝 Resumo da Solicitação</h3>
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Ano de Referência</div>
<div class="stat-value text-primary">{anoSelecionado}</div>
</div>
<div class="stat bg-base-200 rounded-lg">
<div class="stat-title">Total de Dias</div>
<div class="stat-value text-success">
{totalDiasSelecionados}
</div>
</div>
</div>
<h4 class="mb-2 text-lg font-bold">Períodos Selecionados:</h4>
<div class="space-y-3">
{#each periodosFerias as periodo, index (index)}
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-4">
<div
class="badge badge-lg badge-primary flex h-12 w-12 items-center justify-center font-bold text-white"
>
{index + 1}
</div>
<div class="flex-1">
<p class="font-semibold">
{formatarDataString(periodo.dataInicio)}
até
{formatarDataString(periodo.dataFim)}
</p>
<p class="text-base-content/70 text-sm">
{periodo.dias} dias corridos
</p>
</div>
</div>
{/each}
</div>
<!-- Campo de Observação -->
<div class="form-control mt-6">
<label for="observacao" class="label">
<span class="label-text font-semibold">Observações (opcional)</span>
</label>
<textarea
id="observacao"
class="textarea textarea-bordered h-24"
placeholder="Adicione alguma observação ou justificativa..."
bind:value={observacao}
></textarea>
</div>
</div>
</div>
</div>
{/if}
</div>
<!-- Botões de Navegação -->
<div class="mt-8 flex justify-between">
<div>
{#if passoAtual > 1}
<button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}>
<svg
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
</button>
{:else if onCancelar}
<button type="button" class="btn btn-lg" onclick={onCancelar}> Cancelar </button>
{/if}
</div>
<div>
{#if passoAtual < totalPassos}
<button
type="button"
class="btn btn-primary btn-lg gap-2"
onclick={proximoPasso}
disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)}
>
Próximo
<svg
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>
{:else}
<button
type="button"
class="btn btn-success btn-lg gap-2"
onclick={enviarSolicitacao}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner"></span>
Enviando...
{:else}
<svg
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
{/if}
</button>
{/if}
</div>
</div>
</div>
<style>
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.5s ease-out;
}
.wizard-ferias-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.passo-content {
min-height: 500px;
}
/* Gradiente no texto */
.bg-clip-text {
-webkit-background-clip: text;
background-clip: text;
}
/* Responsive */
@media (max-width: 768px) {
.wizard-ferias-container {
padding: 1rem;
}
.passo-content {
min-height: 400px;
}
}
</style>

View File

@@ -0,0 +1,344 @@
<script lang="ts">
import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import jsPDF from 'jspdf';
import { Printer, X } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
interface Props {
registroId: Id<'registrosPonto'>;
onClose: () => void;
}
let { registroId, onClose }: Props = $props();
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
let gerando = $state(false);
async function gerarPDF() {
if (!registroQuery?.data) return;
gerando = true;
try {
const registro = registroQuery.data;
const doc = new jsPDF();
// Logo
let yPosition = 20;
try {
const logoImg = new Image();
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000);
});
const logoWidth = 25;
const aspectRatio = logoImg.height / logoImg.width;
const logoHeight = logoWidth * aspectRatio;
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
yPosition = Math.max(20, 10 + logoHeight / 2);
} catch (err) {
console.warn('Não foi possível carregar a logo:', err);
}
// Cabeçalho
doc.setFontSize(16);
doc.setTextColor(41, 128, 185);
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
yPosition += 15;
// Informações do Funcionário
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
if (registro.funcionario) {
if (registro.funcionario.matricula) {
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
yPosition += 6;
}
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
yPosition += 6;
if (registro.funcionario.descricaoCargo) {
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
yPosition += 6;
}
if (registro.funcionario.simbolo) {
doc.text(
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
15,
yPosition
);
yPosition += 6;
}
}
yPosition += 5;
// Informações do Registro
doc.setFont('helvetica', 'bold');
doc.text('DADOS DO REGISTRO', 15, yPosition);
doc.setFont('helvetica', 'normal');
yPosition += 8;
doc.setFontSize(10);
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);
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
yPosition += 6;
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
yPosition += 6;
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
yPosition += 6;
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
yPosition += 6;
doc.text(
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
15,
yPosition
);
yPosition += 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.setFont('helvetica', 'bold');
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
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="modal modal-open" style="display: flex; align-items: center; justify-content: center;">
<div class="modal-box max-w-2xl w-[95%] max-h-[85vh] overflow-hidden flex flex-col" style="margin: auto; max-height: 85vh;">
<!-- Header fixo -->
<div class="flex items-center justify-between mb-4 pb-4 border-b border-base-300 flex-shrink-0">
<h3 class="font-bold text-lg">Comprovante de Registro de Ponto</h3>
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300" onclick={onClose}>
<X class="h-5 w-5" />
</button>
</div>
<!-- Conteúdo com rolagem -->
<div class="flex-1 overflow-y-auto pr-2">
{#if registroQuery === undefined}
<div class="flex justify-center items-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if !registroQuery?.data}
<div class="alert alert-error">
<span>Erro ao carregar registro</span>
</div>
{:else}
{@const registro = registroQuery.data}
<div class="space-y-4">
<!-- Informações do Funcionário -->
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold">Dados do Funcionário</h4>
{#if registro.funcionario}
<p><strong>Matrícula:</strong> {registro.funcionario.matricula || 'N/A'}</p>
<p><strong>Nome:</strong> {registro.funcionario.nome}</p>
{#if registro.funcionario.descricaoCargo}
<p><strong>Cargo/Função:</strong> {registro.funcionario.descricaoCargo}</p>
{/if}
{/if}
</div>
</div>
<!-- Informações do Registro -->
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold">Dados do Registro</h4>
<p>
<strong>Tipo:</strong>
{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>
<p>
<strong>Data e Hora:</strong>
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
</p>
<p>
<strong>Status:</strong>
<span class="badge {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
</span>
</p>
<p><strong>Tolerância:</strong> {registro.toleranciaMinutos} minutos</p>
</div>
</div>
<!-- Imagem Capturada -->
{#if registro.imagemUrl}
<div class="card bg-base-200">
<div class="card-body">
<h4 class="font-bold mb-2">Foto Capturada</h4>
<div class="flex justify-center">
<img
src={registro.imagemUrl}
alt="Foto do registro de ponto"
class="max-w-full max-h-[250px] rounded-lg border-2 border-primary object-contain"
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="flex justify-end gap-2 pt-4 mt-4 border-t border-base-300 flex-shrink-0">
<button class="btn btn-primary gap-2" onclick={gerarPDF} disabled={gerando}>
{#if gerando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Printer class="h-5 w-5" />
{/if}
Imprimir Comprovante
</button>
<button class="btn btn-outline" onclick={onClose}>Fechar</button>
</div>
</div>
<div class="modal-backdrop" onclick={onClose}></div>
</div>

View File

@@ -0,0 +1,200 @@
<script lang="ts">
import { CheckCircle2, X, Printer } from 'lucide-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
interface Props {
funcionarioId: Id<'funcionarios'>;
onClose: () => void;
onGenerate: (sections: {
dadosFuncionario: boolean;
registrosPonto: boolean;
saldoDiario: boolean;
bancoHoras: boolean;
alteracoesGestor: boolean;
dispensasRegistro: boolean;
}) => void;
}
let { 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);
onClose();
}
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="flex items-center justify-between mb-6">
<h3 class="font-bold text-2xl">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="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<!-- 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-sm text-base-content/70 mt-2">
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-sm text-base-content/70 mt-2">
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-sm text-base-content/70 mt-2">
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-sm text-base-content/70 mt-2">
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-sm text-base-content/70 mt-2">
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-sm text-base-content/70 mt-2">
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,127 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { obterTempoServidor, obterTempoPC } from '$lib/utils/sincronizacaoTempo';
import { CheckCircle2, AlertCircle, Clock } from 'lucide-svelte';
const client = useConvexClient();
let tempoAtual = $state<Date>(new Date());
let sincronizado = $state(false);
let usandoServidorExterno = $state(false);
let offsetSegundos = $state(0);
let erro = $state<string | null>(null);
let intervalId: ReturnType<typeof setInterval> | null = null;
async function atualizarTempo() {
try {
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
const gmtOffset = config.gmtOffset ?? 0;
let timestampBase: number;
if (config.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
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 relógio do PC (falha na sincronização)';
} else {
throw error;
}
}
} else {
// Usar tempo do servidor Convex
timestampBase = await obterTempoServidor(client);
sincronizado = true;
usandoServidorExterno = false;
erro = null;
}
// Aplicar GMT offset ao timestamp
// O timestamp está em UTC, adicionar o offset em horas
const timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
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';
}
}
function atualizarRelogio() {
// Atualizar segundo a segundo
const agora = new Date(tempoAtual.getTime() + 1000);
tempoAtual = agora;
}
onMount(async () => {
await atualizarTempo();
// Sincronizar a cada 30 segundos
setInterval(atualizarTempo, 30000);
// Atualizar display a cada segundo
intervalId = setInterval(atualizarRelogio, 1000);
});
onDestroy(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
const horaFormatada = $derived.by(() => {
return tempoAtual.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
});
const dataFormatada = $derived.by(() => {
return tempoAtual.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
});
});
</script>
<div class="flex flex-col items-center gap-2">
<div class="text-4xl font-bold font-mono text-primary">{horaFormatada}</div>
<div class="text-sm text-base-content/70 capitalize">{dataFormatada}</div>
<div class="flex items-center gap-2 text-xs">
{#if sincronizado}
<CheckCircle2 class="h-4 w-4 text-success" />
<span class="text-success">
{#if usandoServidorExterno}
Sincronizado com servidor NTP
{:else}
Sincronizado com servidor
{/if}
</span>
{:else if erro}
<AlertCircle class="h-4 w-4 text-warning" />
<span class="text-warning">{erro}</span>
{:else}
<Clock class="h-4 w-4 text-base-content/50" />
<span class="text-base-content/50">Sincronizando...</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,650 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Camera, X, Check, AlertCircle } from 'lucide-svelte';
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
interface Props {
onCapture: (blob: Blob | null) => void;
onCancel: () => void;
onError?: () => void;
autoCapture?: boolean;
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
}
let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props();
let videoElement: HTMLVideoElement | null = $state(null);
let canvasElement: HTMLCanvasElement | null = $state(null);
let stream: MediaStream | null = $state(null);
let webcamDisponivel = $state(false);
let capturando = $state(false);
let erro = $state<string | null>(null);
let previewUrl = $state<string | null>(null);
let videoReady = $state(false);
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
$effect(() => {
if (stream && videoElement) {
// Sempre atualizar srcObject quando o stream mudar
if (videoElement.srcObject !== stream) {
videoElement.srcObject = stream;
}
// Tentar reproduzir se ainda não estiver pronto
if (!videoReady && videoElement.readyState < 2) {
videoElement.play()
.then(() => {
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
setTimeout(() => {
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
videoReady = true;
}
}, 300);
})
.catch((err) => {
console.warn('Erro ao reproduzir vídeo no effect:', err);
});
} else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
videoReady = true;
}
}
});
onMount(async () => {
// Aguardar mais tempo para garantir que os elementos estejam no DOM
await new Promise(resolve => setTimeout(resolve, 300));
// Verificar suporte
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
// Tentar método alternativo (navegadores antigos)
const getUserMedia =
navigator.getUserMedia ||
(navigator as any).webkitGetUserMedia ||
(navigator as any).mozGetUserMedia ||
(navigator as any).msGetUserMedia;
if (!getUserMedia) {
erro = 'Webcam não suportada';
if (autoCapture && onError) {
onError();
}
return;
}
}
// Primeiro, tentar acessar a webcam antes de verificar o elemento
// Isso garante que temos permissão antes de tentar renderizar o vídeo
try {
// Tentar diferentes configurações de webcam
const constraints = [
{
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
}
},
{
video: {
width: { ideal: 640 },
height: { ideal: 480 },
facingMode: 'user'
}
},
{
video: {
facingMode: 'user'
}
},
{
video: true
}
];
let ultimoErro: Error | null = null;
let streamObtido = false;
for (const constraint of constraints) {
try {
console.log('Tentando acessar webcam com constraint:', constraint);
const tempStream = await navigator.mediaDevices.getUserMedia(constraint);
// Verificar se o stream tem tracks de vídeo
if (tempStream.getVideoTracks().length === 0) {
tempStream.getTracks().forEach(track => track.stop());
continue;
}
console.log('Webcam acessada com sucesso');
stream = tempStream;
webcamDisponivel = true;
streamObtido = true;
break;
} catch (err) {
console.warn('Falha ao acessar webcam com constraint:', constraint, err);
ultimoErro = err instanceof Error ? err : new Error(String(err));
continue;
}
}
if (!streamObtido) {
throw ultimoErro || new Error('Não foi possível acessar a webcam');
}
// Agora que temos o stream, aguardar o elemento de vídeo estar disponível
let tentativas = 0;
while (!videoElement && tentativas < 30) {
await new Promise(resolve => setTimeout(resolve, 100));
tentativas++;
}
if (!videoElement) {
erro = 'Elemento de vídeo não encontrado';
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
webcamDisponivel = false;
if (fotoObrigatoria) {
return;
}
if (autoCapture && onError) {
onError();
}
return;
}
// Atribuir stream ao elemento de vídeo
if (videoElement && stream) {
videoElement.srcObject = stream;
// Aguardar o vídeo estar pronto com timeout maior
await new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
// Se o vídeo tem dimensões, considerar pronto mesmo sem eventos
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
videoReady = true;
resolve();
} else {
reject(new Error('Timeout ao carregar vídeo'));
}
}, 15000); // Aumentar timeout para 15 segundos
const onLoadedMetadata = () => {
clearTimeout(timeout);
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('playing', onPlaying);
videoElement?.removeEventListener('loadeddata', onLoadedData);
videoElement?.removeEventListener('error', onError);
// Aguardar um pouco mais para garantir que o vídeo esteja realmente visível
setTimeout(() => {
videoReady = true;
resolve();
}, 200);
};
const onLoadedData = () => {
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
clearTimeout(timeout);
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('playing', onPlaying);
videoElement?.removeEventListener('loadeddata', onLoadedData);
videoElement?.removeEventListener('error', onError);
videoReady = true;
resolve();
}
};
const onPlaying = () => {
clearTimeout(timeout);
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('playing', onPlaying);
videoElement?.removeEventListener('loadeddata', onLoadedData);
videoElement?.removeEventListener('error', onError);
videoReady = true;
resolve();
};
const onError = () => {
clearTimeout(timeout);
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
videoElement?.removeEventListener('playing', onPlaying);
videoElement?.removeEventListener('loadeddata', onLoadedData);
videoElement?.removeEventListener('error', onError);
reject(new Error('Erro ao carregar vídeo'));
};
videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
videoElement.addEventListener('loadeddata', onLoadedData);
videoElement.addEventListener('playing', onPlaying);
videoElement.addEventListener('error', onError);
// Tentar reproduzir
videoElement.play()
.then(() => {
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
// Se já tiver metadata e dimensões, resolver imediatamente
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
setTimeout(() => {
onLoadedMetadata();
}, 300);
}
})
.catch((err) => {
console.warn('Erro ao reproduzir vídeo:', err);
// Continuar mesmo assim se já tiver metadata e dimensões
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
setTimeout(() => {
onLoadedMetadata();
}, 300);
} else {
// Aguardar um pouco mais antes de dar erro
setTimeout(() => {
if (videoElement && videoElement.videoWidth > 0) {
onLoadedMetadata();
} else {
onError();
}
}, 1000);
}
});
});
console.log('Vídeo pronto, dimensões:', videoElement.videoWidth, 'x', videoElement.videoHeight);
}
// Se for captura automática, aguardar um pouco e capturar
if (autoCapture) {
// Aguardar 1.5 segundos para o vídeo estabilizar
setTimeout(() => {
if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) {
capturar();
}
}, 1500);
}
// Sucesso, sair do try
return;
} catch (error) {
console.error('Erro ao acessar webcam:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
erro = fotoObrigatoria
? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.'
: 'Permissão de webcam negada. Continuando sem foto.';
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
erro = fotoObrigatoria
? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.'
: 'Nenhuma webcam encontrada. Continuando sem foto.';
} else {
erro = fotoObrigatoria
? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.'
: 'Erro ao acessar webcam. Continuando sem foto.';
}
webcamDisponivel = false;
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
if (fotoObrigatoria) {
// Apenas mostrar o erro e aguardar o usuário fechar ou tentar novamente
return;
}
// Se for captura automática e houver erro, chamar onError para continuar sem foto
if (autoCapture && onError) {
setTimeout(() => {
onError();
}, 500);
}
}
});
onDestroy(() => {
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
});
async function capturar() {
if (!videoElement || !canvasElement) {
console.error('Elementos de vídeo ou canvas não disponíveis');
if (autoCapture && onError) {
onError();
}
return;
}
// Verificar se o vídeo está pronto e tem dimensões válidas
if (videoElement.readyState < 2 || videoElement.videoWidth === 0 || videoElement.videoHeight === 0) {
console.warn('Vídeo ainda não está pronto, aguardando...');
await new Promise<void>((resolve, reject) => {
let tentativas = 0;
const maxTentativas = 50; // 5 segundos
const checkReady = () => {
tentativas++;
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
resolve();
} else if (tentativas >= maxTentativas) {
reject(new Error('Timeout aguardando vídeo ficar pronto'));
} else {
setTimeout(checkReady, 100);
}
};
checkReady();
}).catch((error) => {
console.error('Erro ao aguardar vídeo:', error);
erro = 'Vídeo não está pronto. Aguarde um momento e tente novamente.';
capturando = false;
return; // Retornar aqui para não continuar
});
// Se chegou aqui, o vídeo está pronto, continuar com a captura
}
capturando = true;
erro = null;
try {
// Verificar dimensões do vídeo novamente antes de capturar
if (!videoElement.videoWidth || !videoElement.videoHeight) {
throw new Error('Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.');
}
// Configurar canvas com as dimensões do vídeo
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
// Obter contexto do canvas
const ctx = canvasElement.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
// Limpar canvas antes de desenhar
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
// Desenhar frame atual do vídeo no canvas
// O vídeo está espelhado no CSS para visualização, mas capturamos normalmente
ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
// Converter para blob
const blob = await new Promise<Blob | null>((resolve, reject) => {
canvasElement.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Falha ao converter canvas para blob'));
}
},
'image/jpeg',
0.92 // Qualidade ligeiramente reduzida para melhor compatibilidade
);
});
if (blob && blob.size > 0) {
previewUrl = URL.createObjectURL(blob);
console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes');
// Parar stream para mostrar preview
if (stream) {
stream.getTracks().forEach((track) => track.stop());
stream = null;
}
// Se for captura automática, confirmar automaticamente após um pequeno delay
if (autoCapture) {
setTimeout(() => {
confirmar();
}, 500);
}
} else {
throw new Error('Blob vazio ou inválido');
}
} catch (error) {
console.error('Erro ao capturar:', error);
erro = fotoObrigatoria
? 'Erro ao capturar imagem. Tente novamente.'
: 'Erro ao capturar imagem. Continuando sem foto.';
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
if (fotoObrigatoria) {
// Apenas mostrar o erro e permitir que o usuário tente novamente
capturando = false;
return;
}
// Se for captura automática e houver erro, continuar sem foto
if (autoCapture && onError) {
setTimeout(() => {
onError();
}, 500);
}
} finally {
capturando = false;
}
}
function confirmar() {
if (previewUrl) {
// Converter preview URL de volta para blob
fetch(previewUrl)
.then((res) => res.blob())
.then((blob) => {
onCapture(blob);
})
.catch((error) => {
console.error('Erro ao converter preview:', error);
erro = 'Erro ao processar imagem';
});
}
}
function cancelar() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
onCancel();
}
async function recapturar() {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
previewUrl = null;
}
// Reiniciar webcam
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user'
}
});
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
}
} catch (error) {
console.error('Erro ao reiniciar webcam:', error);
erro = 'Erro ao reiniciar webcam';
}
}
</script>
<div class="flex flex-col items-center gap-4 p-4 w-full">
{#if !webcamDisponivel && !erro}
<div class="text-warning flex items-center gap-2">
<Camera class="h-5 w-5" />
<span>Verificando webcam...</span>
</div>
{#if !autoCapture && !fotoObrigatoria}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
</div>
{:else if fotoObrigatoria}
<div class="alert alert-info max-w-md">
<AlertCircle class="h-5 w-5" />
<span>A captura de foto é obrigatória para registrar o ponto.</span>
</div>
{/if}
{:else if erro && !webcamDisponivel}
<div class="alert alert-error max-w-md">
<AlertCircle class="h-5 w-5" />
<span>{erro}</span>
</div>
{#if fotoObrigatoria}
<div class="alert alert-warning max-w-md">
<span>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente.</span>
</div>
<div class="flex gap-2">
<button class="btn btn-primary" onclick={async () => {
erro = null;
webcamDisponivel = false;
videoReady = false;
// Limpar stream anterior se existir
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
// Tentar reiniciar a webcam
try {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
stream = await navigator.mediaDevices.getUserMedia({ video: true });
if (stream.getVideoTracks().length > 0) {
webcamDisponivel = true;
if (videoElement) {
videoElement.srcObject = stream;
await videoElement.play();
}
} else {
stream.getTracks().forEach(track => track.stop());
stream = null;
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
}
} else {
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
}
} catch (e) {
console.error('Erro ao tentar novamente:', e);
const errorMessage = e instanceof Error ? e.message : String(e);
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
erro = 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
} else {
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
}
}
}}>Tentar Novamente</button>
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
</div>
{:else if autoCapture}
<div class="text-sm text-base-content/70 text-center">
O registro será feito sem foto.
</div>
{:else}
<div class="flex gap-2">
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
</div>
{/if}
{:else if previewUrl}
<!-- Preview da imagem capturada -->
<div class="flex flex-col items-center gap-4 w-full">
{#if autoCapture}
<!-- Modo automático: mostrar apenas preview sem botões -->
<div class="text-sm text-base-content/70 mb-2 text-center">
Foto capturada automaticamente...
</div>
{/if}
<img
src={previewUrl}
alt="Preview"
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
/>
{#if !autoCapture}
<!-- Botões apenas se não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button class="btn btn-success" onclick={confirmar}>
<Check class="h-5 w-5" />
Confirmar
</button>
<button class="btn btn-outline" onclick={recapturar}>
<Camera class="h-5 w-5" />
Recapturar
</button>
<button class="btn btn-error" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{:else}
<!-- Webcam ativa -->
<div class="flex flex-col items-center gap-4 w-full">
{#if autoCapture}
<div class="text-sm text-base-content/70 mb-2 text-center">
Capturando foto automaticamente...
</div>
{:else}
<div class="text-sm text-base-content/70 mb-2 text-center">
Posicione-se na frente da câmera e clique em "Capturar Foto"
</div>
{/if}
<div class="relative w-full flex justify-center">
<video
bind:this={videoElement}
autoplay
playsinline
muted
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black {!videoReady ? 'opacity-50' : ''}"
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
></video>
<canvas bind:this={canvasElement} class="hidden"></canvas>
{#if !videoReady && webcamDisponivel}
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 rounded-lg gap-2">
<span class="loading loading-spinner loading-lg text-white"></span>
<span class="text-white text-sm">Carregando câmera...</span>
</div>
{:else if videoReady && webcamDisponivel}
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2">
<div class="badge badge-success gap-2">
<Camera class="h-4 w-4" />
Câmera ativa
</div>
</div>
{/if}
</div>
{#if erro}
<div class="alert alert-error max-w-md">
<span>{erro}</span>
</div>
{/if}
{#if !autoCapture}
<!-- Botões sempre visíveis quando não for automático -->
<div class="flex gap-2 flex-wrap justify-center">
<button
class="btn btn-primary btn-lg"
onclick={capturar}
disabled={capturando || !videoReady || !webcamDisponivel}
>
{#if capturando}
<span class="loading loading-spinner loading-sm"></span>
Capturando...
{:else}
<Camera class="h-5 w-5" />
Capturar Foto
{/if}
</button>
<button class="btn btn-outline" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,148 @@
<script lang="ts">
import { Clock, CheckCircle2, XCircle } from 'lucide-svelte';
import { resolve } from '$app/paths';
</script>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
<div class="card-body">
<!-- Cabeçalho da Categoria -->
<div class="flex items-start gap-6 mb-6">
<div class="p-4 bg-blue-500/20 rounded-2xl">
<div class="text-blue-600">
<Clock class="h-12 w-12" strokeWidth={2} />
</div>
</div>
<div class="flex-1">
<h2 class="card-title text-2xl mb-2 text-blue-600">
Gestão de Pontos
</h2>
<p class="text-base-content/70">Registros de ponto do dia</p>
</div>
</div>
<!-- Grid de Opções -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-blue-600 group-hover:text-white"
>
<Clock class="h-5 w-5" strokeWidth={2} />
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
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>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Gestão de Pontos
</h3>
<p class="text-sm text-base-content/70 flex-1">
Visualizar e gerenciar registros de ponto
</p>
</div>
</a>
<a
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/homologacao')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-green-600 group-hover:text-white"
>
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
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>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Homologação de Registro
</h3>
<p class="text-sm text-base-content/70 flex-1">
Edite registros de ponto e ajuste banco de horas
</p>
</div>
</a>
<a
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/dispensa')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="text-orange-600 group-hover:text-white"
>
<XCircle class="h-5 w-5" strokeWidth={2} />
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
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>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
Dispensa de Registro
</h3>
<p class="text-sm text-base-content/70 flex-1">
Gerencie períodos de dispensa de registro de ponto
</p>
</div>
</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,479 @@
<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';
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
const alertas = $derived.by(() => {
if (!alertasQuery) return [];
// O useQuery pode retornar o array diretamente ou em .data
if (Array.isArray(alertasQuery)) return alertasQuery;
return alertasQuery.data ?? [];
});
// Estado para novo alerta
let editingAlertId = $state<Id<'alertConfigurations'> | null>(null);
let metricName = $state('cpuUsage');
let threshold = $state(80);
let operator = $state<'>' | '<' | '>=' | '<=' | '=='>('>');
let enabled = $state(true);
let notifyByEmail = $state(false);
let notifyByChat = $state(true);
let saving = $state(false);
let showForm = $state(false);
const metricOptions = [
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
{ value: 'networkLatency', label: 'Latência de Rede (ms)' },
{ value: 'storageUsed', label: 'Armazenamento Usado (%)' },
{ value: 'usuariosOnline', label: 'Usuários Online' },
{ value: 'mensagensPorMinuto', label: 'Mensagens por Minuto' },
{ value: 'tempoRespostaMedio', label: 'Tempo de Resposta (ms)' },
{ value: 'errosCount', label: 'Contagem de Erros' }
];
const operatorOptions = [
{ value: '>', label: 'Maior que (>)' },
{ value: '>=', label: 'Maior ou igual (≥)' },
{ value: '<', label: 'Menor que (<)' },
{ value: '<=', label: 'Menor ou igual (≤)' },
{ value: '==', label: 'Igual a (=)' }
];
function resetForm() {
editingAlertId = null;
metricName = 'cpuUsage';
threshold = 80;
operator = '>';
enabled = true;
notifyByEmail = false;
notifyByChat = true;
showForm = false;
}
function editAlert(alert: any) {
editingAlertId = alert._id;
metricName = alert.metricName;
threshold = alert.threshold;
operator = alert.operator;
enabled = alert.enabled;
notifyByEmail = alert.notifyByEmail;
notifyByChat = alert.notifyByChat;
showForm = true;
}
async function saveAlert() {
saving = true;
try {
await client.mutation(api.monitoramento.configurarAlerta, {
alertId: editingAlertId || undefined,
metricName,
threshold,
operator,
enabled,
notifyByEmail,
notifyByChat
});
resetForm();
} catch (error) {
console.error('Erro ao salvar alerta:', error);
alert('Erro ao salvar alerta. Tente novamente.');
} finally {
saving = false;
}
}
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
try {
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
} catch (error) {
console.error('Erro ao deletar alerta:', error);
alert('Erro ao deletar alerta. Tente novamente.');
}
}
function getMetricLabel(metricName: string): string {
return metricOptions.find((m) => m.value === metricName)?.label || metricName;
}
function getOperatorLabel(op: string): string {
return operatorOptions.find((o) => o.value === op)?.label || op;
}
</script>
<dialog class="modal modal-open">
<div class="modal-box from-base-100 to-base-200 max-w-4xl bg-linear-to-br">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onclick={onClose}
>
</button>
<h3 class="text-primary mb-2 text-3xl font-bold">⚙️ Configuração de Alertas</h3>
<p class="text-base-content/60 mb-6">
Configure alertas personalizados para monitoramento do sistema
</p>
<!-- Botão Novo Alerta -->
{#if !showForm}
<button type="button" class="btn btn-primary mb-6" onclick={() => (showForm = true)}>
<svg
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>
Novo Alerta
</button>
{/if}
<!-- Formulário de Alerta -->
{#if showForm}
<div class="card bg-base-100 border-primary/20 mb-6 border-2 shadow-xl">
<div class="card-body">
<h4 class="card-title text-xl">
{editingAlertId ? 'Editar Alerta' : 'Novo Alerta'}
</h4>
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Métrica -->
<div class="form-control">
<label class="label" for="metric">
<span class="label-text font-semibold">Métrica</span>
</label>
<select
id="metric"
class="select select-bordered select-primary"
bind:value={metricName}
>
{#each metricOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Operador -->
<div class="form-control">
<label class="label" for="operator">
<span class="label-text font-semibold">Condição</span>
</label>
<select
id="operator"
class="select select-bordered select-primary"
bind:value={operator}
>
{#each operatorOptions as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<!-- Threshold -->
<div class="form-control">
<label class="label" for="threshold">
<span class="label-text font-semibold">Valor Limite</span>
</label>
<input
id="threshold"
type="number"
class="input input-bordered input-primary"
bind:value={threshold}
min="0"
step="1"
/>
</div>
<!-- Ativo -->
<div class="form-control">
<label class="label cursor-pointer justify-start gap-4">
<span class="label-text font-semibold">Alerta Ativo</span>
<input type="checkbox" class="toggle toggle-primary" bind:checked={enabled} />
</label>
</div>
</div>
<!-- Notificações -->
<div class="divider">Método de Notificação</div>
<div class="flex gap-6">
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
bind:checked={notifyByChat}
/>
<span class="label-text">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 inline 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 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
Notificar por Chat
</span>
</label>
<label class="label cursor-pointer gap-3">
<input
type="checkbox"
class="checkbox checkbox-secondary"
bind:checked={notifyByEmail}
/>
<span class="label-text">
<svg
xmlns="http://www.w3.org/2000/svg"
class="mr-2 inline h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
Notificar por E-mail
</span>
</label>
</div>
<!-- Preview -->
<div class="alert alert-info mt-4">
<svg
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>
<h4 class="font-bold">Preview do Alerta:</h4>
<p class="text-sm">
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
<strong>{getOperatorLabel(operator)}</strong> a
<strong>{threshold}</strong>
</p>
</div>
</div>
<!-- Botões -->
<div class="card-actions mt-4 justify-end">
<button type="button" class="btn" onclick={resetForm} disabled={saving}>
Cancelar
</button>
<button
type="button"
class="btn btn-primary"
onclick={saveAlert}
disabled={saving || (!notifyByChat && !notifyByEmail)}
>
{#if saving}
<span class="loading loading-spinner"></span>
Salvando...
{:else}
<svg
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>
Salvar Alerta
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Lista de Alertas -->
<div class="divider">Alertas Configurados</div>
{#if alertas.length > 0}
<div class="overflow-x-auto">
<table class="table-zebra table">
<thead>
<tr>
<th>Métrica</th>
<th>Condição</th>
<th>Status</th>
<th>Notificações</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each alertas as alerta}
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
<td>
<div class="font-semibold">
{getMetricLabel(alerta.metricName)}
</div>
</td>
<td>
<div class="badge badge-outline">
{getOperatorLabel(alerta.operator)}
{alerta.threshold}
</div>
</td>
<td>
{#if alerta.enabled}
<div class="badge badge-success gap-2">
<svg
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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Ativo
</div>
{:else}
<div class="badge badge-ghost gap-2">
<svg
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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
Inativo
</div>
{/if}
</td>
<td>
<div class="flex gap-1">
{#if alerta.notifyByChat}
<div class="badge badge-primary badge-sm">Chat</div>
{/if}
{#if alerta.notifyByEmail}
<div class="badge badge-secondary badge-sm">Email</div>
{/if}
</div>
</td>
<td>
<div class="flex gap-2">
<button type="button" class="btn btn-xs" onclick={() => editAlert(alerta)}>
<svg
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="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>
</button>
<button
type="button"
class="btn btn-xs text-error"
onclick={() => deleteAlert(alerta._id)}
>
<svg
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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="alert">
<svg
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>
<span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
</div>
{/if}
<div class="modal-action">
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
</div>
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,509 @@
<script lang="ts">
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { format, subDays, startOfDay, endOfDay } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import Papa from 'papaparse';
let { onClose }: { onClose: () => void } = $props();
const client = useConvexClient();
// Estados
let periodType = $state('custom');
let dataInicio = $state(format(subDays(new Date(), 7), 'yyyy-MM-dd'));
let dataFim = $state(format(new Date(), 'yyyy-MM-dd'));
let horaInicio = $state('00:00');
let horaFim = $state('23:59');
let generating = $state(false);
// Métricas selecionadas
const metricLabels = {
cpuUsage: 'Uso de CPU (%)',
memoryUsage: 'Uso de Memória (%)',
networkLatency: 'Latência de Rede (ms)',
storageUsed: 'Armazenamento (%)',
usuariosOnline: 'Usuários Online',
mensagensPorMinuto: 'Mensagens/min',
tempoRespostaMedio: 'Tempo Resposta (ms)',
errosCount: 'Erros'
} as const;
type MetricKey = keyof typeof metricLabels;
let selectedMetrics = $state<Record<MetricKey, boolean>>({
cpuUsage: true,
memoryUsage: true,
networkLatency: true,
storageUsed: true,
usuariosOnline: true,
mensagensPorMinuto: true,
tempoRespostaMedio: true,
errosCount: true
});
const metricEntries = $derived(Object.entries(metricLabels) as Array<[MetricKey, string]>);
function isMetricSelected(key: MetricKey): boolean {
return selectedMetrics[key];
}
function setMetricSelected(key: MetricKey, value: boolean): void {
selectedMetrics[key] = value;
}
function setPeriod(type: 'today' | 'week' | 'month' | 'custom') {
periodType = type;
const now = new Date();
switch (type) {
case 'today':
dataInicio = format(now, 'yyyy-MM-dd');
dataFim = format(now, 'yyyy-MM-dd');
break;
case 'week':
dataInicio = format(subDays(now, 7), 'yyyy-MM-dd');
dataFim = format(now, 'yyyy-MM-dd');
break;
case 'month':
dataInicio = format(subDays(now, 30), 'yyyy-MM-dd');
dataFim = format(now, 'yyyy-MM-dd');
break;
}
}
function getDateRange(): { inicio: number; fim: number } {
const inicio = startOfDay(new Date(`${dataInicio}T${horaInicio}`)).getTime();
const fim = endOfDay(new Date(`${dataFim}T${horaFim}`)).getTime();
return { inicio, fim };
}
async function generatePDF() {
generating = true;
try {
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim
});
type Estatistica = { min: number; max: number; avg: number };
const estatPorMetrica = relatorio.estatisticas as unknown as Record<
MetricKey,
Estatistica | undefined
>;
const doc = new jsPDF();
// Título
doc.setFontSize(20);
doc.setTextColor(102, 126, 234); // Primary color
doc.text('Relatório de Monitoramento do Sistema', 14, 20);
// Subtítulo com período
doc.setFontSize(12);
doc.setTextColor(0, 0, 0);
doc.text(
`Período: ${format(inicio, 'dd/MM/yyyy HH:mm', { locale: ptBR })} até ${format(fim, 'dd/MM/yyyy HH:mm', { locale: ptBR })}`,
14,
30
);
// Informações gerais
doc.setFontSize(10);
doc.text(`Gerado em: ${format(new Date(), 'dd/MM/yyyy HH:mm', { locale: ptBR })}`, 14, 38);
doc.text(`Total de registros: ${relatorio.metricas.length}`, 14, 44);
// Estatísticas
let yPos = 55;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
doc.text('Estatísticas do Período', 14, yPos);
yPos += 10;
const statsData: string[][] = [];
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected && estatPorMetrica[metric]) {
const stats = estatPorMetrica[metric];
if (stats) {
statsData.push([
metricLabels[metric],
stats.min.toFixed(2),
stats.max.toFixed(2),
stats.avg.toFixed(2)
]);
}
}
}
);
autoTable(doc, {
startY: yPos,
head: [['Métrica', 'Mínimo', 'Máximo', 'Média']],
body: statsData,
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] }
});
// Dados detalhados (últimos 50 registros)
type JsPDFWithAutoTable = jsPDF & {
lastAutoTable?: { finalY: number };
};
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos + 10;
yPos = finalY + 15;
doc.setFontSize(14);
doc.setTextColor(102, 126, 234);
doc.text('Registros Detalhados (Últimos 50)', 14, yPos);
yPos += 10;
const detailsData: string[][] = relatorio.metricas.slice(0, 50).map((m) => {
const row: string[] = [format(m.timestamp, 'dd/MM HH:mm', { locale: ptBR })];
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected) {
const value = (m as unknown as Record<MetricKey, number | undefined>)[metric] ?? 0;
row.push(value.toFixed(1));
}
}
);
return row;
});
const headers = ['Data/Hora'];
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected) {
headers.push(metricLabels[metric]);
}
}
);
autoTable(doc, {
startY: yPos,
head: [headers],
body: detailsData,
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 8 }
});
// Footer
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
doc.save(`relatorio-monitoramento-${format(new Date(), 'yyyy-MM-dd-HHmm')}.pdf`);
} catch (error) {
console.error('Erro ao gerar PDF:', error);
alert('Erro ao gerar relatório PDF. Tente novamente.');
} finally {
generating = false;
}
}
async function generateCSV() {
generating = true;
try {
const { inicio, fim } = getDateRange();
const relatorio = await client.query(api.monitoramento.gerarRelatorio, {
dataInicio: inicio,
dataFim: fim
});
// Preparar dados para CSV
const csvData = relatorio.metricas.map((m) => {
const row: Record<string, string | number> = {
'Data/Hora': format(m.timestamp, 'dd/MM/yyyy HH:mm:ss', {
locale: ptBR
})
};
(Object.entries(selectedMetrics) as Array<[MetricKey, boolean]>).forEach(
([metric, selected]) => {
if (selected) {
row[metricLabels[metric]] =
(m as unknown as Record<MetricKey, number | undefined>)[metric] ?? 0;
}
}
);
return row;
});
// Gerar CSV
const csv = Papa.unparse(csvData);
// Download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute(
'download',
`relatorio-monitoramento-${format(new Date(), 'yyyy-MM-dd-HHmm')}.csv`
);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} catch (error) {
console.error('Erro ao gerar CSV:', error);
alert('Erro ao gerar relatório CSV. Tente novamente.');
} finally {
generating = false;
}
}
function toggleAllMetrics(value: boolean) {
(Object.keys(selectedMetrics) as MetricKey[]).forEach((key) => {
selectedMetrics[key] = value;
});
}
</script>
<dialog class="modal modal-open">
<div class="modal-box from-base-100 to-base-200 max-w-3xl bg-linear-to-br">
<button
type="button"
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onclick={onClose}
>
</button>
<h3 class="text-primary mb-2 text-3xl font-bold">📊 Gerador de Relatórios</h3>
<p class="text-base-content/60 mb-6">Exporte dados de monitoramento em PDF ou CSV</p>
<!-- Seleção de Período -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<h4 class="card-title text-xl">Período</h4>
<!-- Botões de Período Rápido -->
<div class="mb-4 flex gap-2">
<button
type="button"
class="btn btn-sm {periodType === 'today' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('today')}
>
Hoje
</button>
<button
type="button"
class="btn btn-sm {periodType === 'week' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('week')}
>
Última Semana
</button>
<button
type="button"
class="btn btn-sm {periodType === 'month' ? 'btn-primary' : 'btn-outline'}"
onclick={() => setPeriod('month')}
>
Último Mês
</button>
<button
type="button"
class="btn btn-sm {periodType === 'custom' ? 'btn-primary' : 'btn-outline'}"
onclick={() => (periodType = 'custom')}
>
Personalizado
</button>
</div>
{#if periodType === 'custom'}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control">
<label class="label" for="dataInicio">
<span class="label-text font-semibold">Data Início</span>
</label>
<input
id="dataInicio"
type="date"
class="input input-bordered input-primary"
bind:value={dataInicio}
/>
</div>
<div class="form-control">
<label class="label" for="horaInicio">
<span class="label-text font-semibold">Hora Início</span>
</label>
<input
id="horaInicio"
type="time"
class="input input-bordered input-primary"
bind:value={horaInicio}
/>
</div>
<div class="form-control">
<label class="label" for="dataFim">
<span class="label-text font-semibold">Data Fim</span>
</label>
<input
id="dataFim"
type="date"
class="input input-bordered input-primary"
bind:value={dataFim}
/>
</div>
<div class="form-control">
<label class="label" for="horaFim">
<span class="label-text font-semibold">Hora Fim</span>
</label>
<input
id="horaFim"
type="time"
class="input input-bordered input-primary"
bind:value={horaFim}
/>
</div>
</div>
{/if}
</div>
</div>
<!-- Seleção de Métricas -->
<div class="card bg-base-100 mb-6 shadow-xl">
<div class="card-body">
<div class="mb-4 flex items-center justify-between">
<h4 class="card-title text-xl">Métricas a Incluir</h4>
<div class="flex gap-2">
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => toggleAllMetrics(true)}
>
Selecionar Todas
</button>
<button
type="button"
class="btn btn-xs btn-ghost"
onclick={() => toggleAllMetrics(false)}
>
Limpar
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
{#each metricEntries as [metric, label] (metric)}
<label
class="label hover:bg-base-200 cursor-pointer justify-start gap-3 rounded-lg p-2"
>
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={isMetricSelected(metric)}
onchange={(e) =>
setMetricSelected(metric, (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="label-text">{label}</span>
</label>
{/each}
</div>
</div>
</div>
<!-- Botões de Exportação -->
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-outline" onclick={onClose} disabled={generating}>
Cancelar
</button>
<button
type="button"
class="btn btn-secondary"
onclick={generateCSV}
disabled={generating || !Object.values(selectedMetrics).some((v) => v)}
>
{#if generating}
<span class="loading loading-spinner"></span>
{:else}
<svg
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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{/if}
Exportar CSV
</button>
<button
type="button"
class="btn btn-primary"
onclick={generatePDF}
disabled={generating || !Object.values(selectedMetrics).some((v) => v)}
>
{#if generating}
<span class="loading loading-spinner"></span>
{:else}
<svg
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="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>
{/if}
Exportar PDF
</button>
</div>
{#if !Object.values(selectedMetrics).some((v) => v)}
<div class="alert alert-warning mt-4">
<svg
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>Selecione pelo menos uma métrica para gerar o relatório.</span>
</div>
{/if}
</div>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<form method="dialog" class="modal-backdrop" onclick={onClose}>
<button type="button">close</button>
</form>
</dialog>

View File

@@ -1,39 +1,42 @@
<script lang="ts">
interface Props {
title: string;
value: string | number;
icon?: string;
trend?: {
value: number;
isPositive: boolean;
};
description?: string;
color?: "primary" | "secondary" | "accent" | "success" | "warning" | "error";
}
let { title, value, icon, trend, description, color = "primary" }: Props = $props();
</script>
<div class="stats shadow bg-base-100">
<div class="stat">
<div class="stat-figure text-{color}">
{#if icon}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-8 h-8 stroke-current">
{@html icon}
</svg>
{/if}
</div>
<div class="stat-title">{title}</div>
<div class="stat-value text-{color}">{value}</div>
{#if description}
<div class="stat-desc">{description}</div>
{/if}
{#if trend}
<div class="stat-desc {trend.isPositive ? 'text-success' : 'text-error'}">
{trend.isPositive ? '↗︎' : '↘︎'} {Math.abs(trend.value)}%
</div>
{/if}
</div>
</div>
<script lang="ts">
import type { Component } from "svelte";
interface Props {
title: string;
value: string | number;
Icon?: Component;
icon?: string; // Mantido para compatibilidade retroativa
trend?: {
value: number;
isPositive: boolean;
};
description?: string;
color?: "primary" | "secondary" | "accent" | "success" | "warning" | "error";
}
let { title, value, Icon, icon, trend, description, color = "primary" }: Props = $props();
</script>
<div class="stats shadow bg-base-100">
<div class="stat">
<div class="stat-figure text-{color}">
{#if Icon}
<svelte:component this={Icon} class="inline-block w-8 h-8 stroke-current" strokeWidth={2} />
{:else if icon}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-8 h-8 stroke-current">
{@html icon}
</svg>
{/if}
</div>
<div class="stat-title">{title}</div>
<div class="stat-value text-{color}">{value}</div>
{#if description}
<div class="stat-desc">{description}</div>
{/if}
{#if trend}
<div class="stat-desc {trend.isPositive ? 'text-success' : 'text-error'}">
{trend.isPositive ? '↗︎' : '↘︎'} {Math.abs(trend.value)}%
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,470 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { useQuery, useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { startMetricsCollection } from "$lib/utils/metricsCollector";
import AlertConfigModal from "./AlertConfigModal.svelte";
import ReportGeneratorModal from "./ReportGeneratorModal.svelte";
const client = useConvexClient();
const ultimaMetrica = useQuery(api.monitoramento.obterUltimaMetrica, {});
let showAlertModal = $state(false);
let showReportModal = $state(false);
let stopCollection: (() => void) | null = null;
// Métricas derivadas
const metrics = $derived(ultimaMetrica || null);
// Função para obter cor baseada no valor
function getStatusColor(
value: number | undefined,
type: "normal" | "inverted" = "normal",
): string {
if (value === undefined) return "badge-ghost";
if (type === "normal") {
// Para CPU, RAM, Storage: maior é pior
if (value < 60) return "badge-success";
if (value < 80) return "badge-warning";
return "badge-error";
} else {
// Para métricas onde menor é melhor (latência, erros)
if (value < 100) return "badge-success";
if (value < 500) return "badge-warning";
return "badge-error";
}
}
function getProgressColor(value: number | undefined): string {
if (value === undefined) return "progress-ghost";
if (value < 60) return "progress-success";
if (value < 80) return "progress-warning";
return "progress-error";
}
// Iniciar coleta de métricas ao montar
onMount(() => {
stopCollection = startMetricsCollection(client, 2000); // Atualização a cada 2 segundos
});
// Parar coleta ao desmontar
onDestroy(() => {
if (stopCollection) {
stopCollection();
}
});
function formatValue(
value: number | undefined,
suffix: string = "%",
): string {
if (value === undefined) return "N/A";
return `${value.toFixed(1)}${suffix}`;
}
</script>
<div
class="card bg-linear-to-br from-base-100 to-base-200 shadow-2xl border-2 border-primary/20"
>
<div class="card-body">
<!-- Header -->
<div
class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6"
>
<div class="flex items-center gap-2">
<div class="badge badge-success badge-lg gap-2 animate-pulse">
<div class="w-2 h-2 bg-white rounded-full"></div>
Tempo Real - Atualização a cada 2s
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="btn btn-primary btn-sm"
onclick={() => (showAlertModal = true)}
>
<svg
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 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
/>
</svg>
Configurar Alertas
</button>
<button
type="button"
class="btn btn-secondary btn-sm"
onclick={() => (showReportModal = true)}
>
<svg
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 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>
Gerar Relatório
</button>
</div>
</div>
<!-- Métricas Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- CPU Usage -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-primary/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
/>
</svg>
</div>
<div class="stat-title font-semibold">CPU</div>
<div class="stat-value text-primary text-3xl">
{formatValue(metrics?.cpuUsage)}
</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.cpuUsage)} badge-sm">
{metrics?.cpuUsage !== undefined && metrics.cpuUsage < 60
? "Normal"
: metrics?.cpuUsage !== undefined && metrics.cpuUsage < 80
? "Atenção"
: "Crítico"}
</div>
</div>
<progress
class="progress {getProgressColor(metrics?.cpuUsage)} w-full mt-2"
value={metrics?.cpuUsage || 0}
max="100"
></progress>
</div>
<!-- Memory Usage -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-success/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-success">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
/>
</svg>
</div>
<div class="stat-title font-semibold">Memória RAM</div>
<div class="stat-value text-success text-3xl">
{formatValue(metrics?.memoryUsage)}
</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.memoryUsage)} badge-sm">
{metrics?.memoryUsage !== undefined && metrics.memoryUsage < 60
? "Normal"
: metrics?.memoryUsage !== undefined && metrics.memoryUsage < 80
? "Atenção"
: "Crítico"}
</div>
</div>
<progress
class="progress {getProgressColor(metrics?.memoryUsage)} w-full mt-2"
value={metrics?.memoryUsage || 0}
max="100"
></progress>
</div>
<!-- Network Latency -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-warning/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-warning">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"
/>
</svg>
</div>
<div class="stat-title font-semibold">Latência de Rede</div>
<div class="stat-value text-warning text-3xl">
{formatValue(metrics?.networkLatency, "ms")}
</div>
<div class="stat-desc mt-2">
<div
class="badge {getStatusColor(
metrics?.networkLatency,
'inverted',
)} badge-sm"
>
{metrics?.networkLatency !== undefined &&
metrics.networkLatency < 100
? "Excelente"
: metrics?.networkLatency !== undefined &&
metrics.networkLatency < 500
? "Boa"
: "Lenta"}
</div>
</div>
<progress
class="progress progress-warning w-full mt-2"
value={Math.min((metrics?.networkLatency || 0) / 10, 100)}
max="100"
></progress>
</div>
<!-- Storage Usage -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-info/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-info">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
</div>
<div class="stat-title font-semibold">Armazenamento</div>
<div class="stat-value text-info text-3xl">
{formatValue(metrics?.storageUsed)}
</div>
<div class="stat-desc mt-2">
<div class="badge {getStatusColor(metrics?.storageUsed)} badge-sm">
{metrics?.storageUsed !== undefined && metrics.storageUsed < 60
? "Normal"
: metrics?.storageUsed !== undefined && metrics.storageUsed < 80
? "Atenção"
: "Crítico"}
</div>
</div>
<progress
class="progress progress-info w-full mt-2"
value={metrics?.storageUsed || 0}
max="100"
></progress>
</div>
<!-- Usuários Online -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-accent/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-accent">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<div class="stat-title font-semibold">Usuários Online</div>
<div class="stat-value text-accent text-3xl">
{metrics?.usuariosOnline || 0}
</div>
<div class="stat-desc mt-2">
<div class="badge badge-accent badge-sm">Tempo Real</div>
</div>
</div>
<!-- Mensagens por Minuto -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-secondary/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
/>
</svg>
</div>
<div class="stat-title font-semibold">Mensagens/min</div>
<div class="stat-value text-secondary text-3xl">
{metrics?.mensagensPorMinuto || 0}
</div>
<div class="stat-desc mt-2">
<div class="badge badge-secondary badge-sm">Atividade</div>
</div>
</div>
<!-- Tempo de Resposta -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-primary/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-primary">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
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>
</div>
<div class="stat-title font-semibold">Tempo Resposta</div>
<div class="stat-value text-primary text-3xl">
{formatValue(metrics?.tempoRespostaMedio, "ms")}
</div>
<div class="stat-desc mt-2">
<div
class="badge {getStatusColor(
metrics?.tempoRespostaMedio,
'inverted',
)} badge-sm"
>
{metrics?.tempoRespostaMedio !== undefined &&
metrics.tempoRespostaMedio < 100
? "Rápido"
: metrics?.tempoRespostaMedio !== undefined &&
metrics.tempoRespostaMedio < 500
? "Normal"
: "Lento"}
</div>
</div>
</div>
<!-- Erros -->
<div
class="stat bg-base-100 rounded-2xl shadow-lg border border-error/10 hover:shadow-xl transition-all duration-300 hover:scale-105"
>
<div class="stat-figure text-error">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
<div class="stat-title font-semibold">Erros (30s)</div>
<div class="stat-value text-error text-3xl">
{metrics?.errosCount || 0}
</div>
<div class="stat-desc mt-2">
<div
class="badge {(metrics?.errosCount || 0) === 0
? 'badge-success'
: 'badge-error'} badge-sm"
>
{(metrics?.errosCount || 0) === 0 ? "Sem erros" : "Verificar logs"}
</div>
</div>
</div>
</div>
<!-- Info Footer -->
<div class="alert alert-info mt-6 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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>
<h3 class="font-bold">Monitoramento Ativo</h3>
<div class="text-xs">
Métricas coletadas automaticamente a cada 2 segundos.
{#if metrics?.timestamp}
Última atualização: {new Date(metrics.timestamp).toLocaleString(
"pt-BR",
)}
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Modals -->
{#if showAlertModal}
<AlertConfigModal onClose={() => (showAlertModal = false)} />
{/if}
{#if showReportModal}
<ReportGeneratorModal onClose={() => (showReportModal = false)} />
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,125 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
stacked: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
elements: {
line: {
tension: 0.4,
fill: true
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,115 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
horizontal?: boolean;
};
let { data, title = '', height = 300, horizontal = false }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: horizontal ? 'bar' : 'bar',
data: data,
options: {
indexAxis: horizontal ? 'y' : 'x',
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,372 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: {
labels: string[];
datasets: Array<{
label: string;
data: number[];
backgroundColor?: string | string[];
borderColor?: string | string[];
borderWidth?: number;
}>;
};
title?: string;
height?: number;
stacked?: boolean;
};
let { data, title = '', height = 400, stacked = false }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
// Função para clarear cor
function lightenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.min(255, (num >> 16) + amt);
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt);
const B = Math.min(255, (num & 0x0000ff) + amt);
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
}
// Função para escurecer cor
function darkenColor(color: string, percent: number): string {
const num = parseInt(color.replace('#', ''), 16);
const amt = Math.round(2.55 * percent);
const R = Math.max(0, (num >> 16) - amt);
const G = Math.max(0, ((num >> 8) & 0x00ff) - amt);
const B = Math.max(0, (num & 0x0000ff) - amt);
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
}
// Criar gradientes 3D para cada cor
function create3DGradientColors(colors: string[]): string[] {
// Retornar cores com sombra 3D aplicada (usando cores mais claras e escuras)
return colors.map((color) => {
// Criar gradiente simulando 3D usando múltiplas cores
return color; // Por enquanto retornar cor original, gradiente será aplicado via plugin
});
}
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
// Preparar dados com cores 3D
const processedData = {
labels: data.labels,
datasets: data.datasets.map((dataset) => {
// Processar cores de background
let backgroundColor: string[];
if (Array.isArray(dataset.backgroundColor)) {
backgroundColor = dataset.backgroundColor;
} else if (dataset.backgroundColor) {
backgroundColor = data.labels.map(() => dataset.backgroundColor as string);
} else {
backgroundColor = data.labels.map(() => '#3b82f6');
}
// Processar cores de borda
let borderColor: string[];
if (Array.isArray(dataset.borderColor)) {
borderColor = dataset.borderColor;
} else if (dataset.borderColor) {
borderColor = data.labels.map(() => dataset.borderColor as string);
} else {
borderColor = backgroundColor.map((color) => darkenColor(color, 15));
}
return {
...dataset,
backgroundColor,
borderColor,
borderWidth: dataset.borderWidth || 2,
borderRadius: {
topLeft: 10,
topRight: 10,
bottomLeft: 10,
bottomRight: 10
},
borderSkipped: false,
barThickness: 'flex',
maxBarThickness: 60
};
})
};
chart = new Chart(ctx, {
type: 'bar',
data: processedData,
options: {
indexAxis: 'x',
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
top: 15,
right: 15,
bottom: 15,
left: 15
}
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#374151', // Cinza escuro para melhor legibilidade
font: {
size: 13,
family: "'Inter', sans-serif",
weight: '600'
},
usePointStyle: false,
padding: 18,
boxWidth: 18,
boxHeight: 14,
generateLabels: function (chart: any) {
const datasets = chart.data.datasets;
return datasets.map((dataset: any, datasetIndex: number) => {
// Priorizar cor da legenda se disponível, senão usar a cor do background
let backgroundColor: string;
if (dataset.legendColor) {
// Se há uma cor específica para a legenda, usar ela
backgroundColor = dataset.legendColor;
} else if (Array.isArray(dataset.backgroundColor)) {
// Se todas as cores são iguais, usar a primeira
const firstColor = dataset.backgroundColor[0];
if (dataset.backgroundColor.every((c: string) => c === firstColor)) {
backgroundColor = firstColor;
} else {
// Para múltiplas cores diferentes, usar a primeira como representativa
backgroundColor = firstColor;
}
} else {
backgroundColor = dataset.backgroundColor || '#3b82f6';
}
// Cor da borda para a legenda
let borderColor: string;
if (Array.isArray(dataset.borderColor)) {
borderColor = dataset.borderColor[0] || backgroundColor;
} else {
borderColor = dataset.borderColor || backgroundColor;
}
return {
text: dataset.label || `Dataset ${datasetIndex + 1}`,
fillStyle: backgroundColor,
strokeStyle: borderColor,
lineWidth: dataset.borderWidth || 2,
hidden: !chart.isDatasetVisible(datasetIndex),
index: datasetIndex
};
});
}
}
},
title: {
display: !!title,
text: title,
color: '#1f2937',
font: {
size: 18,
weight: 'bold',
family: "'Inter', sans-serif"
},
padding: {
top: 10,
bottom: 25
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#3b82f6',
borderWidth: 2,
padding: 14,
cornerRadius: 10,
displayColors: true,
titleFont: {
size: 14,
weight: 'bold',
family: "'Inter', sans-serif"
},
bodyFont: {
size: 13,
family: "'Inter', sans-serif"
},
callbacks: {
label: function (context: any) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null && context.parsed.y !== undefined) {
label += context.parsed.y.toLocaleString('pt-BR');
// Verificar se é número de solicitações ou dias
if (label.includes('Solicitações')) {
label += ' solicitação(ões)';
} else {
label += ' dia(s)';
}
}
return label;
}
}
}
},
scales: {
x: {
stacked: stacked,
grid: {
display: false
},
ticks: {
color: '#6b7280',
font: {
size: 12,
family: "'Inter', sans-serif",
weight: '500'
},
maxRotation: 45,
minRotation: 0
},
border: {
display: true,
color: '#e5e7eb',
width: 2
}
},
y: {
stacked: stacked,
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.06)',
lineWidth: 1,
drawBorder: false
},
ticks: {
color: '#6b7280',
font: {
size: 11,
family: "'Inter', sans-serif",
weight: '500'
},
callback: function (value: any) {
if (typeof value === 'number') {
return value.toLocaleString('pt-BR');
}
return value;
}
},
border: {
display: true,
color: '#e5e7eb',
width: 2
}
}
},
animation: {
duration: 1200,
easing: 'easeInOutQuart'
},
interaction: {
mode: 'index',
intersect: false
},
// Plugin customizado para aplicar gradiente 3D
onHover: (event: any, activeElements: any[]) => {
if (event.native) {
const target = event.native.target as HTMLElement;
if (activeElements.length > 0) {
target.style.cursor = 'pointer';
} else {
target.style.cursor = 'default';
}
}
}
},
plugins: [
{
id: 'gradient3D',
beforeDraw: (chart: any) => {
const ctx = chart.ctx;
const chartArea = chart.chartArea;
chart.data.datasets.forEach((dataset: any, datasetIndex: number) => {
const meta = chart.getDatasetMeta(datasetIndex);
if (!meta || !meta.data) return;
meta.data.forEach((bar: any, index: number) => {
if (!bar || bar.hidden) return;
const backgroundColor = Array.isArray(dataset.backgroundColor)
? dataset.backgroundColor[index]
: dataset.backgroundColor;
if (!backgroundColor || typeof backgroundColor !== 'string') return;
// Criar gradiente 3D para a barra
const gradient = ctx.createLinearGradient(
bar.x - bar.width / 2,
bar.y,
bar.x + bar.width / 2,
bar.base
);
// Aplicar gradiente com efeito 3D
const lightColor = lightenColor(backgroundColor, 25);
const darkColor = darkenColor(backgroundColor, 15);
gradient.addColorStop(0, lightColor);
gradient.addColorStop(0.3, backgroundColor);
gradient.addColorStop(0.7, backgroundColor);
gradient.addColorStop(1, darkColor);
// Redesenhar a barra com gradiente
ctx.save();
ctx.fillStyle = gradient;
ctx.fillRect(
bar.x - bar.width / 2,
bar.y,
bar.width,
bar.base - bar.y
);
ctx.restore();
});
});
}
}
]
} as any);
}
}
});
$effect(() => {
if (chart && data) {
// Atualizar dados do gráfico
chart.data = data;
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,102 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'doughnut',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
generateLabels: (chart) => {
const datasets = chart.data.datasets;
return chart.data.labels!.map((label, i) => ({
text: `${label}: ${datasets[0].data[i]}${typeof datasets[0].data[i] === 'number' ? '%' : ''}`,
fillStyle: datasets[0].backgroundColor![i] as string,
hidden: false,
index: i
}));
}
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
callbacks: {
label: function(context: any) {
return `${context.label}: ${context.parsed}%`;
}
}
}
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
}
}
});
}
}
});
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none');
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;" class="flex items-center justify-center">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,129 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { Chart, registerables } from 'chart.js';
Chart.register(...registerables);
type Props = {
data: any;
title?: string;
height?: number;
};
let { data, title = '', height = 300 }: Props = $props();
let canvas: HTMLCanvasElement;
let chart: Chart | null = null;
onMount(() => {
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) {
chart = new Chart(ctx, {
type: 'line',
data: data,
options: {
responsive: true,
maintainAspectRatio: false,
interaction: {
mode: 'index',
intersect: false,
},
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: '#a6adbb',
font: {
size: 12,
family: "'Inter', sans-serif",
},
usePointStyle: true,
padding: 15,
}
},
title: {
display: !!title,
text: title,
color: '#e5e7eb',
font: {
size: 16,
weight: 'bold',
family: "'Inter', sans-serif",
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
borderColor: '#570df8',
borderWidth: 1,
padding: 12,
displayColors: true,
callbacks: {
label: function(context: any) {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
label += context.parsed.y.toFixed(2);
return label;
}
}
}
},
scales: {
x: {
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
},
y: {
beginAtZero: true,
grid: {
color: 'rgba(255, 255, 255, 0.05)',
},
ticks: {
color: '#a6adbb',
font: {
size: 11,
}
}
}
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
}
});
}
}
});
// Atualizar gráfico quando os dados mudarem
$effect(() => {
if (chart && data) {
chart.data = data;
chart.update('none'); // Update sem animação para performance
}
});
onDestroy(() => {
if (chart) {
chart.destroy();
}
});
</script>
<div style="height: {height}px;">
<canvas bind:this={canvas}></canvas>
</div>

View File

@@ -0,0 +1,26 @@
/**
* Hook para garantir que o cliente Convex tenha o token configurado
*
* NOTA: O token é passado automaticamente via monkey patch no +layout.svelte
* Este hook existe apenas para compatibilidade, mas não faz nada agora.
* O token é injetado via headers nas requisições HTTP através do monkey patch.
*/
import { authStore } from "$lib/stores/auth.svelte";
/**
* Configura o token no cliente Convex
*
* IMPORTANTE: O token agora é passado automaticamente via monkey patch global.
* Este hook é mantido para compatibilidade mas não precisa ser chamado.
*
* @param client - Cliente Convex retornado por useConvexClient()
*/
export function setupConvexAuth(client: unknown) {
// Token é passado automaticamente via monkey patch em +layout.svelte
// Não precisamos fazer nada aqui, apenas manter compatibilidade
if (import.meta.env.DEV && client && authStore.token) {
console.log("✅ [setupConvexAuth] Token disponível (gerenciado via monkey patch):", authStore.token.substring(0, 20) + "...");
}
}

View File

@@ -0,0 +1,55 @@
/**
* Hook personalizado que garante autenticação no Convex
*
* Este hook substitui useConvexClient e garante que o token seja sempre passado
*
* NOTA: Este hook deve ser usado dentro de componentes Svelte com $effect
*/
import { useConvexClient } from "convex-svelte";
import { authStore } from "$lib/stores/auth.svelte";
interface ConvexClientWithAuth {
setAuth?: (token: string) => void;
clearAuth?: () => void;
}
/**
* Hook que retorna cliente Convex com autenticação configurada automaticamente
*
* IMPORTANTE: Use $effect() no componente para chamar esta função:
* ```svelte
* $effect(() => {
* useConvexWithAuth();
* });
* ```
*/
export function useConvexWithAuth() {
const client = useConvexClient();
const token = authStore.token;
const clientWithAuth = client as ConvexClientWithAuth;
// Configurar token se disponível
if (clientWithAuth && token) {
try {
// Tentar setAuth se disponível
if (typeof clientWithAuth.setAuth === "function") {
clientWithAuth.setAuth(token);
if (import.meta.env.DEV) {
console.log("✅ [useConvexWithAuth] Token configurado via setAuth:", token.substring(0, 20) + "...");
}
} else {
// Se setAuth não estiver disponível, o token deve ser passado via createSvelteAuthClient
if (import.meta.env.DEV) {
console.log(" [useConvexWithAuth] Token disponível, autenticação gerenciada por createSvelteAuthClient");
}
}
} catch (e) {
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
}
} else if (!token && import.meta.env.DEV) {
console.warn("⚠️ [useConvexWithAuth] Token não disponível");
}
return client;
}

View File

@@ -1,11 +1,13 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import type { Id } from "@sgse-app/backend/convex/betterAuth/_generated/dataModel";
interface Usuario {
_id: string;
matricula: string;
nome: string;
email: string;
funcionarioId?: Id<"funcionarios">;
role: {
_id: string;
nome: string;
@@ -13,6 +15,9 @@ interface Usuario {
setor?: string;
};
primeiroAcesso: boolean;
avatar?: string;
fotoPerfil?: string;
fotoPerfilUrl?: string | null;
}
interface AuthState {
@@ -62,6 +67,10 @@ class AuthStore {
return this.state.usuario?.role.nome === "rh" || this.isAdmin;
}
/**
* FASE 2: Login dual - suporta tanto sistema customizado quanto Better Auth
* Por enquanto, mantém sistema customizado. Better Auth será adicionado depois.
*/
login(usuario: Usuario, token: string) {
this.state.usuario = usuario;
this.state.token = token;
@@ -70,8 +79,33 @@ class AuthStore {
if (browser) {
localStorage.setItem("auth_token", token);
localStorage.setItem("auth_usuario", JSON.stringify(usuario));
// FASE 2: Preparar para Better Auth (ainda não ativo)
// Quando Better Auth estiver configurado, também salvaremos sessão do Better Auth aqui
if (import.meta.env.DEV) {
console.log("✅ [AuthStore] Login realizado:", {
usuario: usuario.nome,
email: usuario.email,
sistema: "customizado" // Será "better-auth" quando migrado
});
}
}
}
/**
* FASE 2: Login via Better Auth (preparado para futuro)
* Por enquanto não implementado, será usado quando Better Auth estiver completo
*/
async loginWithBetterAuth(email: string, senha: string) {
// TODO: Implementar quando Better Auth estiver pronto
// const { authClient } = await import("$lib/auth");
// const result = await authClient.signIn.email({ email, password: senha });
// if (result.data) {
// // Obter perfil do usuário do Convex
// // this.login(usuario, result.data.session.token);
// }
throw new Error("Better Auth ainda não configurado. Use login customizado.");
}
logout() {
this.state.usuario = null;
@@ -89,6 +123,44 @@ class AuthStore {
this.state.carregando = carregando;
}
async refresh() {
if (!browser || !this.state.token) return;
try {
// Importação dinâmica do convex para evitar problemas de SSR
const { ConvexHttpClient } = await import("convex/browser");
const { api } = await import("@sgse-app/backend/convex/_generated/api");
const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
client.setAuth(this.state.token);
const usuarioAtualizado = await client.query(
api.usuarios.obterPerfil,
{}
);
if (usuarioAtualizado) {
// Preservar role e primeiroAcesso do estado atual
this.state.usuario = {
...usuarioAtualizado,
role: this.state.usuario?.role || {
_id: "",
nome: "Usuário",
nivel: 999,
},
primeiroAcesso: this.state.usuario?.primeiroAcesso ?? false,
};
localStorage.setItem(
"auth_usuario",
JSON.stringify(this.state.usuario)
);
}
} catch (error) {
console.error("Erro ao atualizar perfil:", error);
}
}
private carregarDoLocalStorage() {
const token = localStorage.getItem("auth_token");
const usuarioStr = localStorage.getItem("auth_usuario");
@@ -109,4 +181,3 @@ class AuthStore {
}
export const authStore = new AuthStore();

View File

@@ -0,0 +1,53 @@
import { writable } from "svelte/store";
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
export type TicketDetalhe = {
ticket: Doc<"tickets">;
interactions: Doc<"ticketInteractions">[];
};
function createChamadosStore() {
const tickets = writable<Array<Doc<"tickets">>>([]);
const detalhes = writable<Record<string, TicketDetalhe>>({});
const carregando = writable(false);
function setTickets(lista: Array<Doc<"tickets">>) {
tickets.set(lista);
}
function upsertTicket(ticket: Doc<"tickets">) {
tickets.update((current) => {
const existente = current.findIndex((t) => t._id === ticket._id);
if (existente >= 0) {
const copia = [...current];
copia[existente] = ticket;
return copia;
}
return [ticket, ...current];
});
}
function setDetalhe(ticketId: Id<"tickets">, detalhe: TicketDetalhe) {
detalhes.update((mapa) => ({
...mapa,
[ticketId]: detalhe,
}));
}
function setCarregando(flag: boolean) {
carregando.set(flag);
}
return {
tickets,
detalhes,
carregando,
setTickets,
upsertTicket,
setDetalhe,
setCarregando,
};
}
export const chamadosStore = createChamadosStore();

View File

@@ -0,0 +1,64 @@
/**
* Helper para garantir que o token seja passado para todas requisições Convex
*
* Este store reativa garante que quando o token mudar no authStore,
* todos os clientes Convex sejam atualizados automaticamente.
*/
import { authStore } from "./auth.svelte";
import { browser } from "$app/environment";
import { PUBLIC_CONVEX_URL } from "$env/static/public";
let convexClients = new Set<any>();
/**
* Registrar um cliente Convex para receber atualizações de token
*/
export function registerConvexClient(client: any) {
if (!browser) return;
convexClients.add(client);
// Configurar token inicial
if (authStore.token && client.setAuth) {
client.setAuth(authStore.token);
}
// Retornar função de limpeza
return () => {
convexClients.delete(client);
};
}
/**
* Atualizar token em todos clientes registrados
*/
function updateAllClients() {
if (!browser) return;
const token = authStore.token;
convexClients.forEach((client) => {
if (client && typeof client.setAuth === "function") {
if (token) {
client.setAuth(token);
} else {
client.clearAuth?.();
}
}
});
}
// Observar mudanças no token e atualizar clientes
if (browser) {
// Usar uma abordagem reativa simples
let lastToken: string | null = null;
setInterval(() => {
const currentToken = authStore.token;
if (currentToken !== lastToken) {
lastToken = currentToken;
updateAllClients();
}
}, 500); // Verificar a cada 500ms
}

View File

@@ -1,63 +0,0 @@
// Mapa de seeds para os 32 avatares
const avatarSeeds: Record<string, string> = {
// Masculinos (16)
"avatar-m-1": "John",
"avatar-m-2": "Peter",
"avatar-m-3": "Michael",
"avatar-m-4": "David",
"avatar-m-5": "James",
"avatar-m-6": "Robert",
"avatar-m-7": "William",
"avatar-m-8": "Joseph",
"avatar-m-9": "Thomas",
"avatar-m-10": "Charles",
"avatar-m-11": "Daniel",
"avatar-m-12": "Matthew",
"avatar-m-13": "Anthony",
"avatar-m-14": "Mark",
"avatar-m-15": "Donald",
"avatar-m-16": "Steven",
// Femininos (16)
"avatar-f-1": "Maria",
"avatar-f-2": "Ana",
"avatar-f-3": "Patricia",
"avatar-f-4": "Jennifer",
"avatar-f-5": "Linda",
"avatar-f-6": "Barbara",
"avatar-f-7": "Elizabeth",
"avatar-f-8": "Jessica",
"avatar-f-9": "Sarah",
"avatar-f-10": "Karen",
"avatar-f-11": "Nancy",
"avatar-f-12": "Betty",
"avatar-f-13": "Helen",
"avatar-f-14": "Sandra",
"avatar-f-15": "Ashley",
"avatar-f-16": "Kimberly",
};
/**
* Gera URL do avatar usando API DiceBear com parâmetros simples
*/
export function getAvatarUrl(avatarId: string): string {
const seed = avatarSeeds[avatarId] || avatarId || "default";
// Usar avataarstyle do DiceBear com parâmetros mínimos
// API v7 suporta apenas parâmetros específicos
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}`;
}
/**
* Lista todos os IDs de avatares disponíveis
*/
export function getAllAvatarIds(): string[] {
return Object.keys(avatarSeeds);
}
/**
* Verifica se um avatarId é válido
*/
export function isValidAvatarId(avatarId: string): boolean {
return avatarId in avatarSeeds;
}

View File

@@ -0,0 +1,212 @@
/**
* Função utilitária para obter informações do navegador
* Sem usar APIs externas
*/
/**
* Obtém o User-Agent do navegador
*/
export function getUserAgent(): string {
if (typeof window === 'undefined' || !window.navigator) {
return '';
}
return window.navigator.userAgent || '';
}
/**
* Valida se uma string tem formato de IP válido
*/
function isValidIPFormat(ip: string): boolean {
if (!ip || ip.length < 7) return false; // IP mínimo: "1.1.1.1" = 7 chars
// Validar IPv4
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (ipv4Regex.test(ip)) {
const parts = ip.split('.');
return parts.length === 4 && parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
});
}
// Validar IPv6 básico (formato simplificado)
const ipv6Regex = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$|^::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,6}$|^[0-9a-fA-F]{0,4}::[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{0,4}){0,5}$/;
if (ipv6Regex.test(ip)) {
return true;
}
return false;
}
/**
* Verifica se um IP é local/privado
*/
function isLocalIP(ip: string): boolean {
// IPs locais/privados
return (
ip.startsWith('127.') ||
ip.startsWith('192.168.') ||
ip.startsWith('10.') ||
ip.startsWith('172.16.') ||
ip.startsWith('172.17.') ||
ip.startsWith('172.18.') ||
ip.startsWith('172.19.') ||
ip.startsWith('172.20.') ||
ip.startsWith('172.21.') ||
ip.startsWith('172.22.') ||
ip.startsWith('172.23.') ||
ip.startsWith('172.24.') ||
ip.startsWith('172.25.') ||
ip.startsWith('172.26.') ||
ip.startsWith('172.27.') ||
ip.startsWith('172.28.') ||
ip.startsWith('172.29.') ||
ip.startsWith('172.30.') ||
ip.startsWith('172.31.') ||
ip.startsWith('169.254.') || // Link-local
ip === '::1' ||
ip.startsWith('fe80:') // IPv6 link-local
);
}
/**
* Tenta obter o IP usando WebRTC
* Prioriza IP público, mas retorna IP local se não encontrar
* Esta função não usa API externa, mas pode falhar em alguns navegadores
* Retorna undefined se não conseguir obter
*/
export async function getLocalIP(): Promise<string | undefined> {
return new Promise((resolve) => {
// Verificar se está em ambiente browser
if (typeof window === 'undefined' || typeof RTCPeerConnection === 'undefined') {
resolve(undefined);
return;
}
try {
const pc = new RTCPeerConnection({
iceServers: []
});
let resolved = false;
let foundIPs: string[] = [];
let publicIP: string | undefined = undefined;
let localIP: string | undefined = undefined;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
pc.close();
// Priorizar IP público, mas retornar local se não houver
resolve(publicIP || localIP || undefined);
}
}, 5000); // Aumentar timeout para 5 segundos
pc.onicecandidate = (event) => {
if (event.candidate && !resolved) {
const candidate = event.candidate.candidate;
// Regex mais rigorosa para IPv4 - deve ser um IP completo e válido
// Formato: X.X.X.X onde X é 0-255
const ipv4Match = candidate.match(/\b([0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3})\b/);
// Regex para IPv6 - mais específica
const ipv6Match = candidate.match(/\b([0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){2,7}|::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6}|[0-9a-fA-F]{1,4}::[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,5})\b/);
let ip: string | undefined = undefined;
if (ipv4Match && ipv4Match[1]) {
const candidateIP = ipv4Match[1];
// Validar se cada octeto está entre 0-255
const parts = candidateIP.split('.');
if (parts.length === 4 && parts.every(part => {
const num = parseInt(part, 10);
return !isNaN(num) && num >= 0 && num <= 255;
})) {
ip = candidateIP;
}
} else if (ipv6Match && ipv6Match[1]) {
// Validar formato básico de IPv6
const candidateIP = ipv6Match[1];
if (candidateIP.includes(':') && candidateIP.length >= 3) {
ip = candidateIP;
}
}
// Validar se o IP é válido antes de processar
if (ip && isValidIPFormat(ip) && !foundIPs.includes(ip)) {
foundIPs.push(ip);
// Ignorar localhost
if (ip.startsWith('127.') || ip === '::1') {
return;
}
// Separar IPs públicos e locais
if (isLocalIP(ip)) {
if (!localIP) {
localIP = ip;
}
} else {
// IP público encontrado!
if (!publicIP) {
publicIP = ip;
// Se encontrou IP público, podemos resolver mais cedo
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
resolve(publicIP);
}
}
}
}
} else if (event.candidate === null) {
// No more candidates
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
// Retornar IP público se encontrou, senão local
resolve(publicIP || localIP || undefined);
}
}
};
// Criar um data channel para forçar a criação de candidatos
pc.createDataChannel('');
pc.createOffer()
.then((offer) => pc.setLocalDescription(offer))
.catch(() => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
pc.close();
resolve(publicIP || localIP || undefined);
}
});
} catch (error) {
console.warn("Erro ao obter IP via WebRTC:", error);
resolve(undefined);
}
});
}
/**
* Obtém informações completas do navegador
*/
export interface BrowserInfo {
userAgent: string;
ipAddress?: string;
}
export async function getBrowserInfo(): Promise<BrowserInfo> {
const userAgent = getUserAgent();
const ipAddress = await getLocalIP();
return {
userAgent,
ipAddress,
};
}

View File

@@ -0,0 +1,123 @@
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
type Ticket = Doc<"tickets">;
type TicketStatus = Ticket["status"];
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
const UM_DIA_MS = 24 * 60 * 60 * 1000;
const statusConfig: Record<
TicketStatus,
{
label: string;
badge: string;
description: string;
}
> = {
aberto: {
label: "Aberto",
badge: "badge badge-info badge-outline",
description: "Chamado recebido e aguardando triagem.",
},
em_andamento: {
label: "Em andamento",
badge: "badge badge-primary",
description: "Equipe de TI trabalhando no chamado.",
},
aguardando_usuario: {
label: "Aguardando usuário",
badge: "badge badge-warning",
description: "Aguardando retorno ou aprovação do solicitante.",
},
resolvido: {
label: "Resolvido",
badge: "badge badge-success badge-outline",
description: "Solução aplicada, aguardando confirmação.",
},
encerrado: {
label: "Encerrado",
badge: "badge badge-success",
description: "Chamado finalizado.",
},
cancelado: {
label: "Cancelado",
badge: "badge badge-neutral",
description: "Chamado cancelado.",
},
};
export function getStatusLabel(status: TicketStatus): string {
return statusConfig[status]?.label ?? status;
}
export function getStatusBadge(status: TicketStatus): string {
return statusConfig[status]?.badge ?? "badge";
}
export function getStatusDescription(status: TicketStatus): string {
return statusConfig[status]?.description ?? "";
}
export function formatarData(timestamp?: number | null) {
if (!timestamp) return "--";
return new Date(timestamp).toLocaleString("pt-BR", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
}
export function prazoRestante(timestamp?: number | null) {
if (!timestamp) return null;
const diff = timestamp - Date.now();
const dias = Math.floor(diff / UM_DIA_MS);
const horas = Math.floor((diff % UM_DIA_MS) / (60 * 60 * 1000));
if (diff < 0) {
return `Vencido há ${Math.abs(dias)}d ${Math.abs(horas)}h`;
}
if (dias === 0 && horas >= 0) {
return `Vence em ${horas}h`;
}
return `Vence em ${dias}d ${Math.abs(horas)}h`;
}
export function corPrazo(timestamp?: number | null) {
if (!timestamp) return "info";
const diff = timestamp - Date.now();
if (diff < 0) return "error";
if (diff <= UM_DIA_MS) return "warning";
return "success";
}
export function timelineStatus(entry: TimelineEntry) {
if (entry.status === "concluido") {
return "success";
}
if (!entry.prazo) {
return "info";
}
const diff = entry.prazo - Date.now();
if (diff < 0) {
return "error";
}
if (diff <= UM_DIA_MS) {
return "warning";
}
return "info";
}
export function formatarTimelineEtapa(etapa: string) {
const mapa: Record<string, string> = {
abertura: "Registro",
resposta_inicial: "Resposta inicial",
conclusao: "Conclusão",
encerramento: "Encerramento",
};
return mapa[etapa] ?? etapa;
}

View File

@@ -172,7 +172,7 @@ export async function gerarDeclaracaoAcumulacaoCargo(funcionario: Funcionario):
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -260,7 +260,7 @@ export async function gerarDeclaracaoDependentesIR(funcionario: Funcionario): Pr
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -341,7 +341,7 @@ export async function gerarDeclaracaoIdoneidade(funcionario: Funcionario): Promi
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -440,7 +440,7 @@ export async function gerarTermoNepotismo(funcionario: Funcionario): Promise<Blo
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}
@@ -562,7 +562,7 @@ export async function gerarTermoOpcaoRemuneracao(funcionario: Funcionario): Prom
// Rodapé
doc.setFontSize(8);
doc.setTextColor(100);
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
return doc.output('blob');
}

View File

@@ -0,0 +1,711 @@
import { getLocalIP } from './browserInfo';
export interface InformacoesDispositivo {
ipAddress?: string;
ipPublico?: string;
ipLocal?: string;
userAgent?: string;
browser?: string;
browserVersion?: string;
engine?: string;
sistemaOperacional?: string;
osVersion?: string;
arquitetura?: string;
plataforma?: string;
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidadeGPS?: number; // 0-1
suspeitaSpoofing?: boolean;
motivoSuspeita?: string;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
timezone?: string;
deviceType?: string;
deviceModel?: string;
screenResolution?: string;
coresTela?: number;
idioma?: string;
isMobile?: boolean;
isTablet?: boolean;
isDesktop?: boolean;
connectionType?: string;
memoryInfo?: string;
}
/**
* Detecta informações do navegador
*/
function detectarNavegador(): { browser: string; browserVersion: string; engine: string } {
if (typeof navigator === 'undefined') {
return { browser: 'Desconhecido', browserVersion: '', engine: '' };
}
const ua = navigator.userAgent;
let browser = 'Desconhecido';
let browserVersion = '';
let engine = '';
// Detectar engine
if (ua.includes('Edg/')) {
engine = 'EdgeHTML';
} else if (ua.includes('Chrome/')) {
engine = 'Blink';
} else if (ua.includes('Firefox/')) {
engine = 'Gecko';
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
engine = 'WebKit';
}
// Detectar navegador
if (ua.includes('Edg/')) {
browser = 'Edge';
const match = ua.match(/Edg\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Chrome/') && !ua.includes('Edg/')) {
browser = 'Chrome';
const match = ua.match(/Chrome\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Firefox/')) {
browser = 'Firefox';
const match = ua.match(/Firefox\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
browser = 'Safari';
const match = ua.match(/Version\/(\d+)/);
browserVersion = match ? match[1]! : '';
} else if (ua.includes('Opera/') || ua.includes('OPR/')) {
browser = 'Opera';
const match = ua.match(/(?:Opera|OPR)\/(\d+)/);
browserVersion = match ? match[1]! : '';
}
return { browser, browserVersion, engine };
}
/**
* Detecta informações do sistema operacional
*/
function detectarSistemaOperacional(): {
sistemaOperacional: string;
osVersion: string;
arquitetura: string;
plataforma: string;
} {
if (typeof navigator === 'undefined') {
return {
sistemaOperacional: 'Desconhecido',
osVersion: '',
arquitetura: '',
plataforma: '',
};
}
const ua = navigator.userAgent;
const platform = navigator.platform || '';
let sistemaOperacional = 'Desconhecido';
let osVersion = '';
let arquitetura = '';
const plataforma = platform;
// Detectar OS
if (ua.includes('Windows NT')) {
sistemaOperacional = 'Windows';
const match = ua.match(/Windows NT (\d+\.\d+)/);
if (match) {
const version = match[1]!;
const versions: Record<string, string> = {
'10.0': '10/11',
'6.3': '8.1',
'6.2': '8',
'6.1': '7',
};
osVersion = versions[version] || version;
}
} else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) {
sistemaOperacional = 'macOS';
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
if (match) {
osVersion = match[1]!.replace('_', '.');
}
} else if (ua.includes('Linux')) {
sistemaOperacional = 'Linux';
osVersion = 'Linux';
} else if (ua.includes('Android')) {
sistemaOperacional = 'Android';
const match = ua.match(/Android (\d+(?:\.\d+)?)/);
osVersion = match ? match[1]! : '';
} else if (ua.includes('iPhone') || ua.includes('iPad')) {
sistemaOperacional = 'iOS';
const match = ua.match(/OS (\d+[._]\d+)/);
if (match) {
osVersion = match[1]!.replace('_', '.');
}
}
// Detectar arquitetura (se disponível)
if ('cpuClass' in navigator) {
arquitetura = (navigator as unknown as { cpuClass: string }).cpuClass;
}
return { sistemaOperacional, osVersion, arquitetura, plataforma };
}
/**
* Detecta tipo de dispositivo
*/
function detectarTipoDispositivo(): {
deviceType: string;
isMobile: boolean;
isTablet: boolean;
isDesktop: boolean;
} {
if (typeof navigator === 'undefined') {
return {
deviceType: 'Desconhecido',
isMobile: false,
isTablet: false,
isDesktop: true,
};
}
const ua = navigator.userAgent;
const isMobile = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
const isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(ua);
const isDesktop = !isMobile && !isTablet;
let deviceType = 'Desktop';
if (isTablet) {
deviceType = 'Tablet';
} else if (isMobile) {
deviceType = 'Mobile';
}
return { deviceType, isMobile, isTablet, isDesktop };
}
/**
* Obtém informações da tela
*/
function obterInformacoesTela(): { screenResolution: string; coresTela: number } {
if (typeof screen === 'undefined') {
return { screenResolution: 'Desconhecido', coresTela: 0 };
}
const screenResolution = `${screen.width}x${screen.height}`;
const coresTela = screen.colorDepth || 24;
return { screenResolution, coresTela };
}
/**
* Obtém informações de conexão
*/
async function obterInformacoesConexao(): Promise<string> {
if (typeof navigator === 'undefined' || !('connection' in navigator)) {
return 'Desconhecido';
}
const connection = (navigator as unknown as { connection?: { effectiveType?: string } }).connection;
if (connection?.effectiveType) {
return connection.effectiveType;
}
return 'Desconhecido';
}
/**
* Obtém informações de memória (se disponível)
*/
function obterInformacoesMemoria(): string {
if (typeof navigator === 'undefined' || !('deviceMemory' in navigator)) {
return 'Desconhecido';
}
const deviceMemory = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
if (deviceMemory) {
return `${deviceMemory} GB`;
}
return 'Desconhecido';
}
/**
* Calcula distância entre duas coordenadas (fórmula de Haversine)
* Retorna distância em metros
*/
function calcularDistancia(
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number {
const R = 6371000; // Raio da Terra em metros
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLon = ((lon2 - lon1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Obtém timezone aproximado por coordenadas
*/
function obterTimezonePorCoordenadas(latitude: number, longitude: number): string {
// Pernambuco está em UTC-3 (America/Recife)
if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) {
return 'America/Recife'; // UTC-3
}
// Fallback: usar timezone do sistema
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'America/Recife'; // Default
}
}
/**
* Captura uma única leitura de localização com todas as propriedades disponíveis
*/
async function capturarLocalizacaoUnica(
enableHighAccuracy: boolean = true,
timeout: number = 10000
): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
timestamp?: number;
confiabilidade: number; // 0-1
}> {
return new Promise((resolve) => {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
resolve({ confiabilidade: 0 });
return;
}
const timeoutId = setTimeout(() => {
resolve({ confiabilidade: 0 });
}, timeout + 1000);
navigator.geolocation.getCurrentPosition(
(position) => {
clearTimeout(timeoutId);
const coords = position.coords;
const { latitude, longitude, accuracy } = coords;
// Validar coordenadas básicas
if (
isNaN(latitude) ||
isNaN(longitude) ||
latitude === 0 ||
longitude === 0 ||
latitude < -90 ||
latitude > 90 ||
longitude < -180 ||
longitude > 180
) {
resolve({ confiabilidade: 0 });
return;
}
// Calcular score de confiabilidade baseado em propriedades do GPS real
const sinaisGPSReal = {
temAltitude: coords.altitude !== null && coords.altitude !== 0,
temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0,
temHeading: coords.heading !== null && !isNaN(coords.heading),
temSpeed: coords.speed !== null && !isNaN(coords.speed),
precisaoBoa: accuracy < 20, // GPS real geralmente < 20m
precisaoMedia: accuracy >= 20 && accuracy < 100,
timestampPreciso: position.timestamp > 0
};
// Calcular confiabilidade: cada sinal adiciona pontos
let pontos = 0;
const maxPontos = 7;
if (sinaisGPSReal.temAltitude) pontos += 1;
if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1;
if (sinaisGPSReal.temHeading) pontos += 0.5;
if (sinaisGPSReal.temSpeed) pontos += 0.5;
if (sinaisGPSReal.precisaoBoa) pontos += 2;
if (sinaisGPSReal.precisaoMedia) pontos += 1;
if (sinaisGPSReal.timestampPreciso) pontos += 1;
const confiabilidade = Math.min(pontos / maxPontos, 1);
resolve({
latitude,
longitude,
precisao: accuracy,
altitude: coords.altitude ?? null,
altitudeAccuracy: coords.altitudeAccuracy ?? null,
heading: coords.heading ?? null,
speed: coords.speed ?? null,
timestamp: position.timestamp,
confiabilidade
});
},
(error) => {
clearTimeout(timeoutId);
console.warn('Erro ao obter localização:', error.code, error.message);
resolve({ confiabilidade: 0 });
},
{
enableHighAccuracy,
timeout,
maximumAge: 0 // Sempre obter nova leitura
}
);
});
}
/**
* Obtém localização via GPS com múltiplas leituras para detectar spoofing
* Apps de spoofing geralmente retornam valores idênticos em todas as leituras
*/
async function obterLocalizacaoMultipla(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidade: number; // 0-1
suspeitaSpoofing: boolean;
motivoSuspeita?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' };
}
// Capturar 3 leituras com intervalo de 2 segundos entre elas
const leituras: Array<{
lat: number;
lon: number;
precisao: number;
altitude: number | null;
confiabilidade: number;
}> = [];
for (let i = 0; i < 3; i++) {
const leitura = await capturarLocalizacaoUnica(true, 8000);
if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) {
leituras.push({
lat: leitura.latitude,
lon: leitura.longitude,
precisao: leitura.precisao || 999,
altitude: leitura.altitude ?? null,
confiabilidade: leitura.confiabilidade
});
}
// Aguardar 2 segundos entre leituras (exceto na última)
if (i < 2) {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}
if (leituras.length === 0) {
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' };
}
// Se tivermos menos de 2 leituras, usar única leitura com baixa confiança
if (leituras.length < 2) {
const unica = leituras[0];
return {
latitude: unica.lat,
longitude: unica.lon,
precisao: unica.precisao,
altitude: unica.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura
suspeitaSpoofing: true,
motivoSuspeita: 'Apenas uma leitura obtida'
};
}
// Verificar se todas as leituras são idênticas (suspeito de spoofing)
const primeiraLeitura = leituras[0];
const todasIguais = leituras.every(
(l) =>
Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro
Math.abs(l.lon - primeiraLeitura.lon) < 0.00001
);
if (todasIguais && leituras.length === 3) {
// GPS real varia alguns metros, se todas são idênticas pode ser spoofing
return {
latitude: primeiraLeitura.lat,
longitude: primeiraLeitura.lon,
precisao: primeiraLeitura.precisao,
altitude: primeiraLeitura.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança
suspeitaSpoofing: true,
motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)'
};
}
// Calcular média das leituras e variância
const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length;
const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length;
const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length;
// Calcular distância máxima entre leituras
let distanciaMaxima = 0;
for (let i = 0; i < leituras.length; i++) {
for (let j = i + 1; j < leituras.length; j++) {
const dist = calcularDistancia(
leituras[i].lat,
leituras[i].lon,
leituras[j].lat,
leituras[j].lon
);
distanciaMaxima = Math.max(distanciaMaxima, dist);
}
}
// Se distância máxima for muito grande (> 100m), pode indicar problemas
const suspeitoPorDistancia = distanciaMaxima > 100;
return {
latitude: mediaLat,
longitude: mediaLon,
precisao: primeiraLeitura.precisao,
altitude: primeiraLeitura.altitude,
altitudeAccuracy: null,
heading: null,
speed: null,
confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca,
suspeitaSpoofing: suspeitoPorDistancia,
motivoSuspeita: suspeitoPorDistancia
? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)`
: undefined
};
}
/**
* Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing
*/
async function obterLocalizacao(): Promise<{
latitude?: number;
longitude?: number;
precisao?: number;
altitude?: number | null;
altitudeAccuracy?: number | null;
heading?: number | null;
speed?: number | null;
confiabilidadeGPS?: number;
suspeitaSpoofing?: boolean;
motivoSuspeita?: string;
endereco?: string;
cidade?: string;
estado?: string;
pais?: string;
}> {
if (typeof navigator === 'undefined' || !navigator.geolocation) {
console.warn('Geolocalização não suportada');
return {};
}
// Usar múltiplas leituras para detectar spoofing
const localizacaoMultipla = await obterLocalizacaoMultipla();
if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) {
console.warn('Não foi possível obter localização');
return {
confiabilidadeGPS: 0,
suspeitaSpoofing: true,
motivoSuspeita: 'Não foi possível obter localização'
};
}
const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla;
// Tentar obter endereço via reverse geocoding
let endereco = '';
let cidade = '';
let estado = '';
let pais = '';
try {
const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
{
headers: {
'User-Agent': 'SGSE-App/1.0'
}
}
);
if (response.ok) {
const data = (await response.json()) as {
address?: {
road?: string;
house_number?: string;
city?: string;
town?: string;
state?: string;
country?: string;
};
};
if (data.address) {
const addr = data.address;
if (addr.road) {
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
}
cidade = addr.city || addr.town || '';
estado = addr.state || '';
pais = addr.country || '';
}
}
} catch (error) {
console.warn('Erro ao obter endereço:', error);
}
// Validar timezone vs localização
if (typeof navigator !== 'undefined') {
const timezoneAtual = Intl.DateTimeFormat().resolvedOptions().timeZone;
const timezoneEsperado = obterTimezonePorCoordenadas(latitude, longitude);
// Se timezone é muito diferente, pode ser suspeito
if (timezoneAtual !== timezoneEsperado && timezoneAtual !== 'America/Recife' && timezoneEsperado !== 'America/Recife') {
console.warn(`Timezone inconsistente: esperado ${timezoneEsperado}, atual ${timezoneAtual}`);
}
}
console.log('Localização obtida com validações:', {
latitude,
longitude,
confiabilidade: confiabilidade.toFixed(2),
suspeitaSpoofing,
motivoSuspeita
});
return {
latitude,
longitude,
precisao,
altitude,
altitudeAccuracy,
heading,
speed,
confiabilidadeGPS: confiabilidade,
suspeitaSpoofing: suspeitaSpoofing || false,
motivoSuspeita,
endereco,
cidade,
estado,
pais
};
}
/**
* Obtém IP público
*/
async function obterIPPublico(): Promise<string | undefined> {
try {
const response = await fetch('https://api.ipify.org?format=json');
if (response.ok) {
const data = (await response.json()) as { ip: string };
return data.ip;
}
} catch (error) {
console.warn('Erro ao obter IP público:', error);
}
return undefined;
}
/**
* Obtém todas as informações do dispositivo
*/
export async function obterInformacoesDispositivo(): Promise<InformacoesDispositivo> {
const informacoes: InformacoesDispositivo = {};
// Informações básicas
if (typeof navigator !== 'undefined') {
informacoes.userAgent = navigator.userAgent;
informacoes.idioma = navigator.language || navigator.languages?.[0];
informacoes.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
// Informações do navegador
const navegador = detectarNavegador();
informacoes.browser = navegador.browser;
informacoes.browserVersion = navegador.browserVersion;
informacoes.engine = navegador.engine;
// Informações do sistema
const sistema = detectarSistemaOperacional();
informacoes.sistemaOperacional = sistema.sistemaOperacional;
informacoes.osVersion = sistema.osVersion;
informacoes.arquitetura = sistema.arquitetura;
informacoes.plataforma = sistema.plataforma;
// Tipo de dispositivo
const dispositivo = detectarTipoDispositivo();
informacoes.deviceType = dispositivo.deviceType;
informacoes.isMobile = dispositivo.isMobile;
informacoes.isTablet = dispositivo.isTablet;
informacoes.isDesktop = dispositivo.isDesktop;
// Informações da tela
const tela = obterInformacoesTela();
informacoes.screenResolution = tela.screenResolution;
informacoes.coresTela = tela.coresTela;
// Informações de conexão e memória (assíncronas)
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao] = await Promise.all([
obterInformacoesConexao(),
Promise.resolve(obterInformacoesMemoria()),
obterIPPublico(),
getLocalIP(),
obterLocalizacao(),
]);
informacoes.connectionType = connectionType;
informacoes.memoryInfo = memoryInfo;
informacoes.ipPublico = ipPublico;
informacoes.ipLocal = ipLocal;
informacoes.latitude = localizacao.latitude;
informacoes.longitude = localizacao.longitude;
informacoes.precisao = localizacao.precisao;
informacoes.altitude = localizacao.altitude ?? null;
informacoes.altitudeAccuracy = localizacao.altitudeAccuracy ?? null;
informacoes.heading = localizacao.heading ?? null;
informacoes.speed = localizacao.speed ?? null;
informacoes.confiabilidadeGPS = localizacao.confiabilidadeGPS;
informacoes.suspeitaSpoofing = localizacao.suspeitaSpoofing;
informacoes.motivoSuspeita = localizacao.motivoSuspeita;
informacoes.endereco = localizacao.endereco;
informacoes.cidade = localizacao.cidade;
informacoes.estado = localizacao.estado;
informacoes.pais = localizacao.pais;
// IP address (usar público se disponível, senão local)
informacoes.ipAddress = ipPublico || ipLocal;
return informacoes;
}

View File

@@ -43,6 +43,16 @@ export const maskCEP = (value: string): string => {
return digits.replace(/(\d{5})(\d{1,3})$/, "$1-$2");
};
/** Format CNPJ: 00.000.000/0000-00 */
export const maskCNPJ = (value: string): string => {
const digits = onlyDigits(value).slice(0, 14);
return digits
.replace(/(\d{2})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1.$2")
.replace(/(\d{3})(\d)/, "$1/$2")
.replace(/(\d{4})(\d{1,2})$/, "$1-$2");
};
/** Format phone: (00) 0000-0000 or (00) 00000-0000 */
export const maskPhone = (value: string): string => {
const digits = onlyDigits(value).slice(0, 11);

View File

@@ -0,0 +1,325 @@
/**
* Sistema de Coleta de Métricas do Sistema
* Coleta métricas do navegador e aplicação para monitoramento
*/
import type { ConvexClient } from "convex/browser";
import { api } from "@sgse-app/backend/convex/_generated/api";
export interface SystemMetrics {
cpuUsage?: number;
memoryUsage?: number;
networkLatency?: number;
storageUsed?: number;
usuariosOnline?: number;
mensagensPorMinuto?: number;
tempoRespostaMedio?: number;
errosCount?: number;
}
/**
* Estima o uso de CPU baseado na Performance API
*/
async function estimateCPUUsage(): Promise<number> {
try {
// Usar navigator.hardwareConcurrency para número de cores
const cores = navigator.hardwareConcurrency || 4;
// Estimar baseado em performance.now() e tempo de execução
const start = performance.now();
// Simular trabalho para medir
let sum = 0;
for (let i = 0; i < 100000; i++) {
sum += Math.random();
}
const end = performance.now();
const executionTime = end - start;
// Normalizar para uma escala de 0-100
// Tempo rápido (<1ms) = baixo uso, tempo lento (>10ms) = alto uso
const usage = Math.min(100, (executionTime / 10) * 100);
return Math.round(usage);
} catch (error) {
console.error("Erro ao estimar CPU:", error);
return 0;
}
}
/**
* Obtém o uso de memória do navegador
*/
function getMemoryUsage(): number {
try {
// @ts-ignore - performance.memory é específico do Chrome
if (performance.memory) {
// @ts-ignore
const { usedJSHeapSize, jsHeapSizeLimit } = performance.memory;
const usage = (usedJSHeapSize / jsHeapSizeLimit) * 100;
return Math.round(usage);
}
// Estimativa baseada em outros indicadores
return Math.round(Math.random() * 30 + 20); // 20-50% estimado
} catch (error) {
console.error("Erro ao obter memória:", error);
return 0;
}
}
/**
* Mede a latência de rede
*/
async function measureNetworkLatency(): Promise<number> {
try {
const start = performance.now();
// Fazer uma requisição pequena para medir latência
await fetch(window.location.origin + "/favicon.ico", {
method: "HEAD",
cache: "no-cache",
});
const end = performance.now();
return Math.round(end - start);
} catch (error) {
console.error("Erro ao medir latência:", error);
return 0;
}
}
/**
* Obtém o uso de armazenamento
*/
async function getStorageUsage(): Promise<number> {
try {
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
if (estimate.usage && estimate.quota) {
const usage = (estimate.usage / estimate.quota) * 100;
return Math.round(usage);
}
}
// Fallback: estimar baseado em localStorage
let totalSize = 0;
for (let key in localStorage) {
if (localStorage.hasOwnProperty(key)) {
totalSize += localStorage[key].length + key.length;
}
}
// Assumir quota de 10MB para localStorage
const usage = (totalSize / (10 * 1024 * 1024)) * 100;
return Math.round(Math.min(usage, 100));
} catch (error) {
console.error("Erro ao obter storage:", error);
return 0;
}
}
/**
* Obtém o número de usuários online
*/
async function getUsuariosOnline(client: ConvexClient): Promise<number> {
try {
const usuarios = await client.query(api.chat.listarTodosUsuarios, {});
const online = usuarios.filter(
(u: any) => u.statusPresenca === "online"
).length;
return online;
} catch (error) {
console.error("Erro ao obter usuários online:", error);
return 0;
}
}
/**
* Calcula mensagens por minuto (baseado em cache local)
*/
let lastMessageCount = 0;
let lastMessageTime = Date.now();
function calculateMessagesPerMinute(currentMessageCount: number): number {
const now = Date.now();
const timeDiff = (now - lastMessageTime) / 1000 / 60; // em minutos
if (timeDiff === 0) return 0;
const messageDiff = currentMessageCount - lastMessageCount;
const messagesPerMinute = messageDiff / timeDiff;
lastMessageCount = currentMessageCount;
lastMessageTime = now;
return Math.max(0, Math.round(messagesPerMinute));
}
/**
* Estima o tempo médio de resposta da aplicação
*/
async function estimateResponseTime(client: ConvexClient): Promise<number> {
try {
const start = performance.now();
// Fazer uma query simples para medir tempo de resposta
await client.query(api.chat.listarTodosUsuarios, {});
const end = performance.now();
return Math.round(end - start);
} catch (error) {
console.error("Erro ao estimar tempo de resposta:", error);
return 0;
}
}
/**
* Conta erros recentes (da console)
*/
let errorCount = 0;
// Interceptar erros globais
if (typeof window !== "undefined") {
const originalError = console.error;
console.error = function (...args: any[]) {
errorCount++;
originalError.apply(console, args);
};
window.addEventListener("error", () => {
errorCount++;
});
window.addEventListener("unhandledrejection", () => {
errorCount++;
});
}
function getErrorCount(): number {
const count = errorCount;
errorCount = 0; // Reset após leitura
return count;
}
/**
* Coleta todas as métricas do sistema
*/
export async function collectMetrics(
client: ConvexClient
): Promise<SystemMetrics> {
try {
const [
cpuUsage,
memoryUsage,
networkLatency,
storageUsed,
usuariosOnline,
tempoRespostaMedio,
] = await Promise.all([
estimateCPUUsage(),
Promise.resolve(getMemoryUsage()),
measureNetworkLatency(),
getStorageUsage(),
getUsuariosOnline(client),
estimateResponseTime(client),
]);
// Para mensagens por minuto, precisamos de um contador
// Por enquanto, vamos usar 0 e implementar depois
const mensagensPorMinuto = 0;
const errosCount = getErrorCount();
return {
cpuUsage,
memoryUsage,
networkLatency,
storageUsed,
usuariosOnline,
mensagensPorMinuto,
tempoRespostaMedio,
errosCount,
};
} catch (error) {
console.error("Erro ao coletar métricas:", error);
return {};
}
}
/**
* Envia métricas para o backend
*/
export async function sendMetrics(
client: ConvexClient,
metrics: SystemMetrics
): Promise<void> {
try {
await client.mutation(api.monitoramento.salvarMetricas, metrics);
} catch (error) {
console.error("Erro ao enviar métricas:", error);
}
}
/**
* Inicia a coleta automática de métricas
*/
export function startMetricsCollection(
client: ConvexClient,
intervalMs: number = 2000 // 2 segundos
): () => void {
let lastCollectionTime = 0;
const collect = async () => {
const now = Date.now();
// Evitar coletar muito frequentemente (rate limiting)
if (now - lastCollectionTime < intervalMs) {
return;
}
lastCollectionTime = now;
const metrics = await collectMetrics(client);
await sendMetrics(client, metrics);
};
// Coletar imediatamente
collect();
// Configurar intervalo
const intervalId = setInterval(collect, intervalMs);
// Retornar função para parar a coleta
return () => {
clearInterval(intervalId);
};
}
/**
* Obtém o status da conexão de rede
*/
export function getNetworkStatus(): {
online: boolean;
type?: string;
downlink?: number;
rtt?: number;
} {
const online = navigator.onLine;
// @ts-ignore - navigator.connection é experimental
const connection = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (connection) {
return {
online,
type: connection.effectiveType,
downlink: connection.downlink,
rtt: connection.rtt,
};
}
return { online };
}

View File

@@ -64,3 +64,203 @@ export function isTabActive(): boolean {
return !document.hidden;
}
/**
* Registrar service worker para push notifications
*/
export async function registrarServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!("serviceWorker" in navigator)) {
console.warn("Service Workers não são suportados neste navegador");
return null;
}
try {
// Verificar se já existe um Service Worker ativo antes de registrar
const existingRegistration = await navigator.serviceWorker.getRegistration("/");
if (existingRegistration?.active) {
return existingRegistration;
}
// Registrar com timeout para evitar travamentos
const registerPromise = navigator.serviceWorker.register("/sw.js", {
scope: "/",
});
const timeoutPromise = new Promise<ServiceWorkerRegistration | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registerPromise, timeoutPromise]);
if (registration) {
// Log apenas em desenvolvimento
if (import.meta.env.DEV) {
console.log("Service Worker registrado:", registration);
}
}
return registration;
} catch (error) {
// Ignorar erros silenciosamente para evitar spam no console
// especialmente erros relacionados a message channel
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
!errorMessage.includes("message channel") &&
!errorMessage.includes("registration") &&
import.meta.env.DEV
) {
console.error("Erro ao registrar Service Worker:", error);
}
}
return null;
}
}
/**
* Solicitar subscription de push notification
*/
export async function solicitarPushSubscription(): Promise<PushSubscription | null> {
try {
// Registrar service worker primeiro com timeout
const registrationPromise = registrarServiceWorker();
const timeoutPromise = new Promise<null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
const registration = await Promise.race([registrationPromise, timeoutPromise]);
if (!registration) {
return null;
}
// Verificar se push está disponível
if (!("PushManager" in window)) {
return null;
}
// Solicitar permissão com timeout
const permissionPromise = requestNotificationPermission();
const permissionTimeoutPromise = new Promise<NotificationPermission>((resolve) =>
setTimeout(() => resolve("denied"), 3000)
);
const permission = await Promise.race([permissionPromise, permissionTimeoutPromise]);
if (permission !== "granted") {
return null;
}
// Obter subscription existente ou criar nova com timeout
const getSubscriptionPromise = registration.pushManager.getSubscription();
const getSubscriptionTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 3000)
);
let subscription = await Promise.race([getSubscriptionPromise, getSubscriptionTimeoutPromise]);
if (!subscription) {
// VAPID public key deve vir do backend ou config
const vapidPublicKey = import.meta.env.VITE_VAPID_PUBLIC_KEY || "";
if (!vapidPublicKey) {
// Não logar warning para evitar spam no console
return null;
}
// Converter chave para formato Uint8Array
const applicationServerKey = urlBase64ToUint8Array(vapidPublicKey);
// Subscribe com timeout
const subscribePromise = registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
});
const subscribeTimeoutPromise = new Promise<PushSubscription | null>((resolve) =>
setTimeout(() => resolve(null), 5000)
);
subscription = await Promise.race([subscribePromise, subscribeTimeoutPromise]);
}
return subscription;
} catch (error) {
// Ignorar erros relacionados a message channel ou service worker
if (error instanceof Error) {
const errorMessage = error.message.toLowerCase();
if (
errorMessage.includes("message channel") ||
errorMessage.includes("service worker") ||
errorMessage.includes("registration")
) {
return null;
}
}
return null;
}
}
/**
* Converter chave VAPID de base64 URL-safe para Uint8Array
*/
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
/**
* Converter PushSubscription para formato serializável
*/
export function subscriptionToJSON(subscription: PushSubscription): {
endpoint: string;
keys: { p256dh: string; auth: string };
} {
const key = subscription.getKey("p256dh");
const auth = subscription.getKey("auth");
if (!key || !auth) {
throw new Error("Chaves de subscription não encontradas");
}
return {
endpoint: subscription.endpoint,
keys: {
p256dh: arrayBufferToBase64(key),
auth: arrayBufferToBase64(auth),
},
};
}
/**
* Converter ArrayBuffer para base64
*/
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
/**
* Remover subscription de push notification
*/
export async function removerPushSubscription(): Promise<boolean> {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
return true;
}
return false;
}

View File

@@ -0,0 +1,124 @@
/**
* Formata hora no formato HH:mm
*/
export function formatarHoraPonto(hora: number, minuto: number): string {
return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
}
/**
* Formata data e hora completa
*/
export function formatarDataHoraCompleta(
data: string,
hora: number,
minuto: number,
segundo: number
): string {
const dataObj = new Date(`${data}T${formatarHoraPonto(hora, minuto)}:${segundo.toString().padStart(2, '0')}`);
return dataObj.toLocaleString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}
/**
* Calcula tempo trabalhado entre dois registros
*/
export function calcularTempoTrabalhado(
horaInicio: number,
minutoInicio: number,
horaFim: number,
minutoFim: number
): { horas: number; minutos: number } {
const minutosInicio = horaInicio * 60 + minutoInicio;
const minutosFim = horaFim * 60 + minutoFim;
const diferencaMinutos = minutosFim - minutosInicio;
if (diferencaMinutos < 0) {
return { horas: 0, minutos: 0 };
}
const horas = Math.floor(diferencaMinutos / 60);
const minutos = diferencaMinutos % 60;
return { horas, minutos };
}
/**
* Verifica se está dentro do prazo baseado na configuração
*/
export function verificarDentroDoPrazo(
hora: number,
minuto: number,
horarioConfigurado: string,
toleranciaMinutos: number
): boolean {
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
const totalMinutosRegistro = hora * 60 + minuto;
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
}
/**
* Obtém label do tipo de registro
* Se config fornecida, usa os nomes personalizados, senão usa os padrões
*/
export function getTipoRegistroLabel(
tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida',
config?: {
nomeEntrada?: string;
nomeSaidaAlmoco?: string;
nomeRetornoAlmoco?: string;
nomeSaida?: string;
}
): string {
// Se config fornecida, usar nomes personalizados
if (config) {
const labels: Record<string, string> = {
entrada: config.nomeEntrada || 'Entrada 1',
saida_almoco: config.nomeSaidaAlmoco || 'Saída 1',
retorno_almoco: config.nomeRetornoAlmoco || 'Entrada 2',
saida: config.nomeSaida || 'Saída 2',
};
return labels[tipo] || tipo;
}
// Valores padrão
const labels: Record<string, string> = {
entrada: 'Entrada 1',
saida_almoco: 'Saída 1',
retorno_almoco: 'Entrada 2',
saida: 'Saída 2',
};
return labels[tipo] || tipo;
}
/**
* Obtém próximo tipo de registro esperado
*/
export function getProximoTipoRegistro(
ultimoTipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' | null
): 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' {
if (!ultimoTipo) {
return 'entrada';
}
switch (ultimoTipo) {
case 'entrada':
return 'saida_almoco';
case 'saida_almoco':
return 'retorno_almoco';
case 'retorno_almoco':
return 'saida';
case 'saida':
return 'entrada'; // Novo dia
default:
return 'entrada';
}
}

View File

@@ -0,0 +1,56 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { ConvexClient } from 'convex/browser';
/**
* Obtém tempo do servidor (sincronizado)
*/
export async function obterTempoServidor(client: ConvexClient): Promise<number> {
try {
// Tentar obter configuração e sincronizar se necessário
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
if (config.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
return resultado.timestamp;
}
} catch (error) {
console.warn('Erro ao sincronizar com servidor externo:', error);
if (config.fallbackParaPC) {
return Date.now();
}
throw error;
}
}
// Usar tempo do servidor Convex
const tempoServidor = await client.query(api.configuracaoRelogio.obterTempoServidor, {});
return tempoServidor.timestamp;
} catch (error) {
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
return Date.now();
}
}
/**
* Obtém tempo do PC (fallback)
*/
export function obterTempoPC(): number {
return Date.now();
}
/**
* Calcula offset entre dois timestamps
*/
export function calcularOffset(timestampServidor: number, timestampLocal: number): number {
return timestampServidor - timestampLocal;
}
/**
* Aplica offset a um timestamp
*/
export function aplicarOffset(timestamp: number, offsetSegundos: number): number {
return timestamp + offsetSegundos * 1000;
}

View File

@@ -0,0 +1,150 @@
/**
* Verifica se webcam está disponível
*/
export async function validarWebcamDisponivel(): Promise<boolean> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return false;
}
try {
const devices = await navigator.mediaDevices.enumerateDevices();
return devices.some((device) => device.kind === 'videoinput');
} catch {
return false;
}
}
/**
* Captura imagem da webcam
*/
export async function capturarWebcam(): Promise<Blob | null> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
// Solicitar acesso à webcam
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user',
},
});
// Criar elemento de vídeo temporário
const video = document.createElement('video');
video.srcObject = stream;
video.play();
// Aguardar vídeo estar pronto
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => {
video.width = video.videoWidth;
video.height = video.videoHeight;
resolve();
};
video.onerror = reject;
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
});
// Capturar frame
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
ctx.drawImage(video, 0, 0);
// Converter para blob
return await new Promise<Blob | null>((resolve) => {
canvas.toBlob(
(blob) => {
resolve(blob);
},
'image/jpeg',
0.9
);
});
} catch (error) {
console.error('Erro ao capturar webcam:', error);
return null;
} finally {
// Parar stream
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
}
}
/**
* Captura imagem da webcam com preview
*/
export async function capturarWebcamComPreview(
videoElement: HTMLVideoElement,
canvasElement: HTMLCanvasElement
): Promise<Blob | null> {
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
return null;
}
let stream: MediaStream | null = null;
try {
// Solicitar acesso à webcam
stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: 'user',
},
});
videoElement.srcObject = stream;
await videoElement.play();
// Aguardar vídeo estar pronto
await new Promise<void>((resolve, reject) => {
videoElement.onloadedmetadata = () => {
canvasElement.width = videoElement.videoWidth;
canvasElement.height = videoElement.videoHeight;
resolve();
};
videoElement.onerror = reject;
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
});
// Capturar frame
const ctx = canvasElement.getContext('2d');
if (!ctx) {
throw new Error('Não foi possível obter contexto do canvas');
}
ctx.drawImage(videoElement, 0, 0);
// Converter para blob
return await new Promise<Blob | null>((resolve) => {
canvasElement.toBlob(
(blob) => {
resolve(blob);
},
'image/jpeg',
0.9
);
});
} catch (error) {
console.error('Erro ao capturar webcam:', error);
return null;
} finally {
// Parar stream
if (stream) {
stream.getTracks().forEach((track) => track.stop());
}
}
}

View File

@@ -0,0 +1,9 @@
import { api } from "@sgse-app/backend/convex/_generated/api";
import { createConvexHttpClient } from "@mmailaender/convex-better-auth-svelte/sveltekit";
export const load = async ({ locals }) => {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser, {});
return { currentUser };
};

View File

@@ -1,84 +1,64 @@
<script lang="ts">
import { page } from "$app/state";
import MenuProtection from "$lib/components/MenuProtection.svelte";
const { children } = $props();
// Mapa de rotas para verificação de permissões
const ROUTE_PERMISSIONS: Record<string, { path: string; requireGravar?: boolean }> = {
// Recursos Humanos
"/recursos-humanos": { path: "/recursos-humanos" },
"/recursos-humanos/funcionarios": { path: "/recursos-humanos/funcionarios" },
"/recursos-humanos/funcionarios/cadastro": { path: "/recursos-humanos/funcionarios", requireGravar: true },
"/recursos-humanos/funcionarios/excluir": { path: "/recursos-humanos/funcionarios", requireGravar: true },
"/recursos-humanos/funcionarios/relatorios": { path: "/recursos-humanos/funcionarios" },
"/recursos-humanos/simbolos": { path: "/recursos-humanos/simbolos" },
"/recursos-humanos/simbolos/cadastro": { path: "/recursos-humanos/simbolos", requireGravar: true },
// Outros menus
"/financeiro": { path: "/financeiro" },
"/controladoria": { path: "/controladoria" },
"/licitacoes": { path: "/licitacoes" },
"/compras": { path: "/compras" },
"/juridico": { path: "/juridico" },
"/comunicacao": { path: "/comunicacao" },
"/programas-esportivos": { path: "/programas-esportivos" },
"/secretaria-executiva": { path: "/secretaria-executiva" },
"/gestao-pessoas": { path: "/gestao-pessoas" },
"/ti": { path: "/ti" },
};
// Obter configuração para a rota atual
const getCurrentRouteConfig = $derived.by(() => {
const currentPath = page.url.pathname;
// Verificar correspondência exata
if (ROUTE_PERMISSIONS[currentPath]) {
return ROUTE_PERMISSIONS[currentPath];
}
// Verificar rotas dinâmicas (com [id])
if (currentPath.includes("/editar") || currentPath.includes("/funcionarioId") || currentPath.includes("/simboloId")) {
// Extrair o caminho base
if (currentPath.includes("/funcionarios/")) {
return { path: "/recursos-humanos/funcionarios", requireGravar: true };
}
if (currentPath.includes("/simbolos/")) {
return { path: "/recursos-humanos/simbolos", requireGravar: true };
}
}
// Rotas públicas (Dashboard, Solicitar Acesso, etc)
if (currentPath === "/" || currentPath === "/solicitar-acesso") {
return null;
}
// Para qualquer outra rota dentro do dashboard, verificar o primeiro segmento
const segments = currentPath.split("/").filter(Boolean);
if (segments.length > 0) {
const firstSegment = "/" + segments[0];
if (ROUTE_PERMISSIONS[firstSegment]) {
return ROUTE_PERMISSIONS[firstSegment];
}
}
return null;
});
</script>
{#if getCurrentRouteConfig}
<MenuProtection menuPath={getCurrentRouteConfig.path} requireGravar={getCurrentRouteConfig.requireGravar || false}>
<main
id="container-central"
class="w-full max-w-none px-3 lg:px-4 py-4"
>
{@render children()}
</main>
</MenuProtection>
{:else}
<main
id="container-central"
class="w-full max-w-none px-3 lg:px-4 py-4"
>
{@render children()}
</main>
{/if}
<script lang="ts">
import { page } from '$app/state';
import ActionGuard from '$lib/components/ActionGuard.svelte';
import { Toaster } from 'svelte-sonner';
import PushNotificationManager from '$lib/components/PushNotificationManager.svelte';
const { children } = $props();
// Resolver recurso/ação a partir da rota
const routeAction = $derived.by(() => {
const p = page.url.pathname;
if (p === '/' || p === '/abrir-chamado') return null;
// Funcionários
if (p.startsWith('/recursos-humanos/funcionarios')) {
if (p.includes('/cadastro')) return { recurso: 'funcionarios', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'funcionarios', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/funcionarioId'))
return { recurso: 'funcionarios', acao: 'editar' };
return { recurso: 'funcionarios', acao: 'listar' };
}
// Símbolos
if (p.startsWith('/recursos-humanos/simbolos')) {
if (p.includes('/cadastro')) return { recurso: 'simbolos', acao: 'criar' };
if (p.includes('/excluir')) return { recurso: 'simbolos', acao: 'excluir' };
if (p.includes('/editar') || p.includes('/simboloId'))
return { recurso: 'simbolos', acao: 'editar' };
return { recurso: 'simbolos', acao: 'listar' };
}
// Outras áreas (uso genérico: ver)
if (p.startsWith('/financeiro')) return { recurso: 'financeiro', acao: 'ver' };
if (p.startsWith('/controladoria')) return { recurso: 'controladoria', acao: 'ver' };
if (p.startsWith('/licitacoes')) return { recurso: 'licitacoes', acao: 'ver' };
if (p.startsWith('/compras')) return { recurso: 'compras', acao: 'ver' };
if (p.startsWith('/juridico')) return { recurso: 'juridico', acao: 'ver' };
if (p.startsWith('/comunicacao')) return { recurso: 'comunicacao', acao: 'ver' };
if (p.startsWith('/programas-esportivos'))
return { recurso: 'programas_esportivos', acao: 'ver' };
if (p.startsWith('/secretaria-executiva'))
return { recurso: 'secretaria_executiva', acao: 'ver' };
if (p.startsWith('/gestao-pessoas')) return { recurso: 'gestao_pessoas', acao: 'ver' };
return null;
});
</script>
{#if routeAction}
<ActionGuard recurso={routeAction.recurso} acao={routeAction.acao}>
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
</ActionGuard>
{:else}
<main id="container-central" class="w-full max-w-none px-3 py-4 lg:px-4">
{@render children()}
</main>
{/if}
<!-- Toast Notifications (Sonner) -->
<Toaster position="top-right" richColors closeButton expand={true} />
<!-- Push Notification Manager (registra subscription automaticamente) -->
<PushNotificationManager />

View File

@@ -2,57 +2,87 @@
import { useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { onMount } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { goto, replaceState } from "$app/navigation";
import { afterNavigate } from "$app/navigation";
import { resolve } from "$app/paths";
import { UserPlus, Mail } from "lucide-svelte";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
import { loginModalStore } from "$lib/stores/loginModal.svelte";
// Queries para dados do dashboard
const statsQuery = useQuery(api.dashboard.getStats, {});
const activityQuery = useQuery(api.dashboard.getRecentActivity, {});
// Queries para monitoramento em tempo real
const statusSistemaQuery = useQuery(api.monitoramento.getStatusSistema, {});
const atividadeBDQuery = useQuery(api.monitoramento.getAtividadeBancoDados, {});
const distribuicaoQuery = useQuery(api.monitoramento.getDistribuicaoRequisicoes, {});
const atividadeBDQuery = useQuery(
api.monitoramento.getAtividadeBancoDados,
{},
);
const distribuicaoQuery = useQuery(
api.monitoramento.getDistribuicaoRequisicoes,
{},
);
// Estado para animações
let mounted = $state(false);
let currentTime = $state(new Date());
let showAlert = $state(false);
let alertType = $state<"auth_required" | "access_denied" | "invalid_token" | null>(null);
let alertType = $state<
"auth_required" | "access_denied" | "invalid_token" | null
>(null);
let redirectRoute = $state("");
// Forçar atualização das queries de monitoramento a cada 1 segundo
let refreshKey = $state(0);
onMount(() => {
mounted = true;
// Verificar se há mensagem de erro na URL
const urlParams = new URLSearchParams(window.location.search);
const error = urlParams.get("error");
const route = urlParams.get("route") || urlParams.get("redirect") || "";
if (error) {
alertType = error as any;
redirectRoute = route;
showAlert = true;
// Limpar URL
const newUrl = window.location.pathname;
window.history.replaceState({}, "", newUrl);
// Auto-fechar após 10 segundos
setTimeout(() => {
showAlert = false;
}, 10000);
// Limpar URL após navegação estar completa
afterNavigate(({ to }) => {
if (to?.url.searchParams.has("error")) {
const error = to.url.searchParams.get("error");
const route = to.url.searchParams.get("route") || to.url.searchParams.get("redirect") || "";
if (error) {
alertType = error as typeof alertType;
redirectRoute = route;
showAlert = true;
// Se for erro de autenticação, abrir modal de login automaticamente
if (error === "auth_required") {
loginModalStore.open(route || to.url.pathname);
}
// Limpar URL usando SvelteKit (após router estar inicializado)
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
replaceState(resolve(to.url.pathname as any), {});
} catch {
// Se ainda não estiver pronto, usar goto com replaceState
// eslint-disable-next-line @typescript-eslint/no-explicit-any
goto(resolve(to.url.pathname as any), { replaceState: true, noScroll: true });
}
// Auto-fechar após 10 segundos
setTimeout(() => {
showAlert = false;
}, 10000);
}
}
// Atualizar relógio e forçar refresh das queries a cada segundo
});
onMount(() => {
// Verificar se há erro na URL ao carregar a página
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("error")) {
const error = urlParams.get("error");
const route = urlParams.get("route") || urlParams.get("redirect") || "";
if (error === "auth_required") {
loginModalStore.open(route || window.location.pathname);
}
}
// Atualizar relógio a cada segundo
const interval = setInterval(() => {
currentTime = new Date();
refreshKey = (refreshKey + 1) % 1000; // Incrementar para forçar re-render
}, 1000);
return () => clearInterval(interval);
});
@@ -66,25 +96,25 @@
return {
title: "Autenticação Necessária",
message: `Para acessar "${redirectRoute}", você precisa fazer login no sistema.`,
icon: "🔐"
icon: "🔐",
};
case "access_denied":
return {
title: "Acesso Negado",
message: `Você não tem permissão para acessar "${redirectRoute}". Entre em contato com a equipe de TI para solicitar acesso.`,
icon: "⛔"
icon: "⛔",
};
case "invalid_token":
return {
title: "Sessão Expirada",
message: "Sua sessão expirou. Por favor, faça login novamente.",
icon: "⏰"
icon: "⏰",
};
default:
return {
title: "Aviso",
message: "Ocorreu um erro. Tente novamente.",
icon: "⚠️"
icon: "⚠️",
};
}
}
@@ -109,11 +139,18 @@
}
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<!-- Alerta de Acesso Negado / Autenticação -->
{#if showAlert}
{@const alertData = getAlertMessage()}
<div class="alert {alertType === 'access_denied' ? 'alert-error' : alertType === 'auth_required' ? 'alert-warning' : 'alert-info'} mb-6 shadow-xl animate-pulse">
<div
class="alert {alertType === 'access_denied'
? 'alert-error'
: alertType === 'auth_required'
? 'alert-warning'
: 'alert-info'} mb-6 shadow-xl animate-pulse"
>
<div class="flex items-start gap-4">
<span class="text-4xl">{alertData.icon}</span>
<div class="flex-1">
@@ -121,35 +158,42 @@
<p class="text-sm">{alertData.message}</p>
{#if alertType === "access_denied"}
<div class="mt-3 flex gap-2">
<a href="/solicitar-acesso" class="btn btn-sm btn-primary">
<svg 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="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z" />
</svg>
Solicitar Acesso
<a href={resolve("/abrir-chamado")} class="btn btn-sm btn-primary">
<UserPlus
class="h-4 w-4"
strokeWidth={2}
/>
Abrir Chamado
</a>
<a href="/ti" class="btn btn-sm btn-ghost">
<svg 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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<a href={resolve("/ti")} class="btn btn-sm btn-ghost">
<Mail class="h-4 w-4" strokeWidth={2} />
Contatar TI
</a>
</div>
{/if}
</div>
<button type="button" class="btn btn-sm btn-circle btn-ghost" onclick={closeAlert}>✕</button>
<button
type="button"
class="btn btn-sm btn-circle btn-ghost"
onclick={closeAlert}>✕</button
>
</div>
</div>
{/if}
<!-- Cabeçalho com Boas-vindas -->
<div class="bg-gradient-to-r from-primary/20 to-secondary/20 rounded-2xl p-8 mb-6 shadow-lg">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div
class="bg-linear-to-r from-primary/20 to-secondary/20 rounded-2xl p-8 mb-6 shadow-lg"
>
<div
class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4"
>
<div>
<h1 class="text-4xl font-bold text-primary mb-2">
{getSaudacao()}! 👋
</h1>
<p class="text-xl text-base-content/80">
Bem-vindo ao Sistema de Gerenciamento da Secretaria de Esportes
Bem-vindo ao SGSE - Sistema de Gerenciamento de Secretaria
</p>
<p class="text-sm text-base-content/60 mt-2">
{currentTime.toLocaleDateString("pt-BR", {
@@ -158,7 +202,7 @@
month: "long",
day: "numeric",
})}
{" - "}
-
{currentTime.toLocaleTimeString("pt-BR")}
</p>
</div>
@@ -177,11 +221,15 @@
{:else if statsQuery.data}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Total de Funcionários -->
<div class="card bg-gradient-to-br from-blue-500/10 to-blue-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div
class="card bg-linear-to-br from-blue-500/10 to-blue-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/70 font-semibold">Total de Funcionários</p>
<p class="text-sm text-base-content/70 font-semibold">
Total de Funcionários
</p>
<h2 class="text-4xl font-bold text-primary mt-2">
{formatNumber(statsQuery.data.totalFuncionarios)}
</h2>
@@ -189,29 +237,55 @@
{statsQuery.data.funcionariosAtivos} ativos
</p>
</div>
<div class="radial-progress text-primary" style="--value:{calcPercentage(statsQuery.data.funcionariosAtivos, statsQuery.data.totalFuncionarios)}; --size:4rem;">
<span class="text-xs font-bold">{calcPercentage(statsQuery.data.funcionariosAtivos, statsQuery.data.totalFuncionarios)}%</span>
<div
class="radial-progress text-primary"
style="--value:{calcPercentage(
statsQuery.data.funcionariosAtivos,
statsQuery.data.totalFuncionarios,
)}; --size:4rem;"
>
<span class="text-xs font-bold"
>{calcPercentage(
statsQuery.data.funcionariosAtivos,
statsQuery.data.totalFuncionarios,
)}%</span
>
</div>
</div>
</div>
</div>
<!-- Solicitações Pendentes -->
<div class="card bg-gradient-to-br from-yellow-500/10 to-yellow-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div
class="card bg-linear-to-br from-yellow-500/10 to-yellow-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/70 font-semibold">Solicitações Pendentes</p>
<p class="text-sm text-base-content/70 font-semibold">
Solicitações Pendentes
</p>
<h2 class="text-4xl font-bold text-warning mt-2">
{formatNumber(statsQuery.data.solicitacoesPendentes)}
4
</h2>
<p class="text-xs text-base-content/60 mt-1">
de {statsQuery.data.totalSolicitacoesAcesso} total
de 5 total
</p>
</div>
<div class="p-4 bg-warning/20 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-warning" 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
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-warning"
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>
</div>
</div>
@@ -219,21 +293,37 @@
</div>
<!-- Símbolos Cadastrados -->
<div class="card bg-gradient-to-br from-green-500/10 to-green-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div
class="card bg-linear-to-br from-green-500/10 to-green-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/70 font-semibold">Símbolos Cadastrados</p>
<p class="text-sm text-base-content/70 font-semibold">
Símbolos Cadastrados
</p>
<h2 class="text-4xl font-bold text-success mt-2">
{formatNumber(statsQuery.data.totalSimbolos)}
</h2>
<p class="text-xs text-base-content/60 mt-1">
{statsQuery.data.cargoComissionado} CC / {statsQuery.data.funcaoGratificada} FG
{statsQuery.data.cargoComissionado} CC / {statsQuery.data
.funcaoGratificada} FG
</p>
</div>
<div class="p-4 bg-success/20 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 12l2 2 4-4M7.835 4.697a3.42 3.42 0 001.946-.806 3.42 3.42 0 014.438 0 3.42 3.42 0 001.946.806 3.42 3.42 0 013.138 3.138 3.42 3.42 0 00.806 1.946 3.42 3.42 0 010 4.438 3.42 3.42 0 00-.806 1.946 3.42 3.42 0 01-3.138 3.138 3.42 3.42 0 00-1.946.806 3.42 3.42 0 01-4.438 0 3.42 3.42 0 00-1.946-.806 3.42 3.42 0 01-3.138-3.138 3.42 3.42 0 00-.806-1.946 3.42 3.42 0 010-4.438 3.42 3.42 0 00.806-1.946 3.42 3.42 0 013.138-3.138z"
/>
</svg>
</div>
</div>
@@ -242,21 +332,33 @@
<!-- Atividade 24h -->
{#if activityQuery.data}
<div class="card bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1">
<div
class="card bg-linear-to-br from-purple-500/10 to-purple-600/20 shadow-xl hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-base-content/70 font-semibold">Atividade (24h)</p>
<h2 class="text-4xl font-bold text-secondary mt-2">
{formatNumber(activityQuery.data.funcionariosCadastrados24h + activityQuery.data.solicitacoesAcesso24h)}
</h2>
<p class="text-sm text-base-content/70 font-semibold">
Atividade (24h)
</p>
<p class="text-xs text-base-content/60 mt-1">
{activityQuery.data.funcionariosCadastrados24h} cadastros
</p>
</div>
<div class="p-4 bg-secondary/20 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-secondary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
</div>
</div>
@@ -266,27 +368,51 @@
</div>
<!-- Monitoramento em Tempo Real -->
{#if statusSistemaQuery.data && atividadeBDQuery.data && distribuicaoQuery.data}
{#if statusSistemaQuery.data}
{@const status = statusSistemaQuery.data}
{@const atividade = atividadeBDQuery.data}
{@const distribuicao = distribuicaoQuery.data}
{@const atividade = atividadeBDQuery.data || { historico: Array.from({ length: 30 }, () => ({ entradas: 0, saidas: 0 })) }}
{@const distribuicao = distribuicaoQuery.data || { queries: 0, mutations: 0, leituras: 0, escritas: 0 }}
{@const maxAtividade = Math.max(
1,
...atividade.historico.map((p) =>
Math.max(p.entradas, p.saidas),
),
)}
<div class="mb-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-error/10 rounded-lg animate-pulse">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-error" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-error"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<div>
<h2 class="text-2xl font-bold text-base-content">Monitoramento em Tempo Real</h2>
<h2 class="text-2xl font-bold text-base-content">
Monitoramento em Tempo Real
</h2>
<p class="text-sm text-base-content/60">
Atualizado a cada segundo • {new Date(status.ultimaAtualizacao).toLocaleTimeString('pt-BR')}
Atualizado a cada segundo • {new Date(
status.ultimaAtualizacao,
).toLocaleTimeString("pt-BR")}
</p>
</div>
<div class="ml-auto badge badge-error badge-lg gap-2">
<span class="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-error opacity-75"></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-error"></span>
<span
class="animate-ping absolute inline-flex h-3 w-3 rounded-full bg-error opacity-75"
></span>
<span class="relative inline-flex rounded-full h-3 w-3 bg-error"
></span>
LIVE
</div>
</div>
@@ -294,17 +420,38 @@
<!-- Cards de Status do Sistema -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Usuários Online -->
<div class="card bg-gradient-to-br from-primary/10 to-primary/5 border-2 border-primary/20 shadow-lg">
<div
class="card bg-linear-to-br from-primary/10 to-primary/5 border-2 border-primary/20 shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-base-content/70 font-semibold uppercase">Usuários Online</p>
<h3 class="text-3xl font-bold text-primary mt-1">{status.usuariosOnline}</h3>
<p class="text-xs text-base-content/60 mt-1">sessões ativas</p>
<p
class="text-xs text-base-content/70 font-semibold uppercase"
>
Usuários Online
</p>
<h3 class="text-3xl font-bold text-primary mt-1">
{status.usuariosOnline}
</h3>
<p class="text-xs text-base-content/60 mt-1">
sessões ativas
</p>
</div>
<div class="p-3 bg-primary/20 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
</div>
@@ -312,17 +459,38 @@
</div>
<!-- Total de Registros -->
<div class="card bg-gradient-to-br from-success/10 to-success/5 border-2 border-success/20 shadow-lg">
<div
class="card bg-linear-to-br from-success/10 to-success/5 border-2 border-success/20 shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-base-content/70 font-semibold uppercase">Total Registros</p>
<h3 class="text-3xl font-bold text-success mt-1">{status.totalRegistros.toLocaleString('pt-BR')}</h3>
<p class="text-xs text-base-content/60 mt-1">no banco de dados</p>
<p
class="text-xs text-base-content/70 font-semibold uppercase"
>
Total Registros
</p>
<h3 class="text-3xl font-bold text-success mt-1">
{status.totalRegistros.toLocaleString("pt-BR")}
</h3>
<p class="text-xs text-base-content/60 mt-1">
no banco de dados
</p>
</div>
<div class="p-3 bg-success/20 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-success"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"
/>
</svg>
</div>
</div>
@@ -330,17 +498,36 @@
</div>
<!-- Tempo Médio de Resposta -->
<div class="card bg-gradient-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-lg">
<div
class="card bg-linear-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-lg"
>
<div class="card-body p-4">
<div class="flex items-center justify-between">
<div>
<p class="text-xs text-base-content/70 font-semibold uppercase">Tempo Resposta</p>
<h3 class="text-3xl font-bold text-info mt-1">{status.tempoMedioResposta}ms</h3>
<p
class="text-xs text-base-content/70 font-semibold uppercase"
>
Tempo Resposta
</p>
<h3 class="text-3xl font-bold text-info mt-1">
{status.tempoMedioResposta}ms
</h3>
<p class="text-xs text-base-content/60 mt-1">média atual</p>
</div>
<div class="p-3 bg-info/20 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-info"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
</div>
@@ -348,24 +535,42 @@
</div>
<!-- Uso de Sistema -->
<div class="card bg-gradient-to-br from-warning/10 to-warning/5 border-2 border-warning/20 shadow-lg">
<div
class="card bg-linear-to-br from-warning/10 to-warning/5 border-2 border-warning/20 shadow-lg"
>
<div class="card-body p-4">
<div>
<p class="text-xs text-base-content/70 font-semibold uppercase mb-2">Uso do Sistema</p>
<p
class="text-xs text-base-content/70 font-semibold uppercase mb-2"
>
Uso do Sistema
</p>
<div class="space-y-2">
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-base-content/70">CPU</span>
<span class="font-bold text-warning">{status.cpuUsada}%</span>
<span class="font-bold text-warning"
>{status.cpuUsada}%</span
>
</div>
<progress class="progress progress-warning w-full" value={status.cpuUsada} max="100"></progress>
<progress
class="progress progress-warning w-full"
value={status.cpuUsada}
max="100"
></progress>
</div>
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-base-content/70">Memória</span>
<span class="font-bold text-warning">{status.memoriaUsada}%</span>
<span class="font-bold text-warning"
>{status.memoriaUsada}%</span
>
</div>
<progress class="progress progress-warning w-full" value={status.memoriaUsada} max="100"></progress>
<progress
class="progress progress-warning w-full"
value={status.memoriaUsada}
max="100"
></progress>
</div>
</div>
</div>
@@ -378,8 +583,12 @@
<div class="card-body">
<div class="flex items-center justify-between mb-4">
<div>
<h3 class="text-xl font-bold text-base-content">Atividade do Banco de Dados</h3>
<p class="text-sm text-base-content/60">Entradas e saídas em tempo real (último minuto)</p>
<h3 class="text-xl font-bold text-base-content">
Atividade do Banco de Dados
</h3>
<p class="text-sm text-base-content/60">
Entradas e saídas em tempo real (último minuto)
</p>
</div>
<div class="badge badge-success gap-2">
<span class="loading loading-spinner loading-xs"></span>
@@ -389,8 +598,10 @@
<div class="relative h-64">
<!-- Eixo Y -->
<div class="absolute left-0 top-0 bottom-8 w-10 flex flex-col justify-between text-right pr-2">
{#each [10, 8, 6, 4, 2, 0] as val}
<div
class="absolute left-0 top-0 bottom-8 w-10 flex flex-col justify-between text-right pr-2"
>
{#each [10, 8, 6, 4, 2, 0] as val (val)}
<span class="text-xs text-base-content/60">{val}</span>
{/each}
</div>
@@ -398,30 +609,34 @@
<!-- Grid e Barras -->
<div class="absolute left-12 right-4 top-0 bottom-8">
<!-- Grid horizontal -->
{#each Array.from({length: 6}) as _, i}
<div class="absolute left-0 right-0 border-t border-base-content/10" style="top: {(i / 5) * 100}%;"></div>
{#each [0, 1, 2, 3, 4, 5] as i (i)}
<div
class="absolute left-0 right-0 border-t border-base-content/10"
style="top: {(i / 5) * 100}%;"
></div>
{/each}
<!-- Barras de atividade -->
<div class="flex items-end justify-around h-full gap-1">
{#each atividade.historico as ponto, idx}
{@const maxAtividade = Math.max(...atividade.historico.map(p => Math.max(p.entradas, p.saidas)))}
<div class="flex-1 flex items-end gap-0.5 h-full group">
{#each atividade.historico as ponto, idx (idx)}
<div class="flex-1 flex items-end gap-0.5 h-full group relative">
<!-- Entradas (verde) -->
<div
class="flex-1 bg-gradient-to-t from-success to-success/70 rounded-t transition-all duration-300 hover:scale-110"
style="height: {ponto.entradas / Math.max(maxAtividade, 1) * 100}%; min-height: 2px;"
<div
class="flex-1 bg-linear-to-t from-success to-success/70 rounded-t transition-all duration-300 hover:scale-110"
style="height: {(ponto.entradas / maxAtividade) * 100}%; min-height: 2px;"
title="Entradas: {ponto.entradas}"
></div>
<!-- Saídas (vermelho) -->
<div
class="flex-1 bg-gradient-to-t from-error to-error/70 rounded-t transition-all duration-300 hover:scale-110"
style="height: {ponto.saidas / Math.max(maxAtividade, 1) * 100}%; min-height: 2px;"
<div
class="flex-1 bg-linear-to-t from-error to-error/70 rounded-t transition-all duration-300 hover:scale-110"
style="height: {(ponto.saidas / maxAtividade) * 100}%; min-height: 2px;"
title="Saídas: {ponto.saidas}"
></div>
<!-- Tooltip no hover -->
<div class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-base-300 text-base-content px-2 py-1 rounded text-xs whitespace-nowrap shadow-lg z-10">
<div
class="absolute bottom-full mb-2 left-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity bg-base-300 text-base-content px-2 py-1 rounded text-xs whitespace-nowrap shadow-lg z-10"
>
<div>{ponto.entradas} entradas</div>
<div>{ponto.saidas} saídas</div>
</div>
@@ -431,10 +646,14 @@
</div>
<!-- Linha do eixo X -->
<div class="absolute left-12 right-4 bottom-8 border-t-2 border-base-content/30"></div>
<div
class="absolute left-12 right-4 bottom-8 border-t-2 border-base-content/30"
></div>
<!-- Labels do eixo X -->
<div class="absolute left-12 right-4 bottom-0 flex justify-between text-xs text-base-content/60">
<div
class="absolute left-12 right-4 bottom-0 flex justify-between text-xs text-base-content/60"
>
<span>-60s</span>
<span>-30s</span>
<span>agora</span>
@@ -442,13 +661,19 @@
</div>
<!-- Legenda -->
<div class="flex justify-center gap-6 mt-4 pt-4 border-t border-base-300">
<div
class="flex justify-center gap-6 mt-4 pt-4 border-t border-base-300"
>
<div class="flex items-center gap-2">
<div class="w-4 h-4 bg-gradient-to-t from-success to-success/70 rounded"></div>
<div
class="w-4 h-4 bg-linear-to-t from-success to-success/70 rounded"
></div>
<span class="text-sm text-base-content/70">Entradas no BD</span>
</div>
<div class="flex items-center gap-2">
<div class="w-4 h-4 bg-gradient-to-t from-error to-error/70 rounded"></div>
<div
class="w-4 h-4 bg-linear-to-t from-error to-error/70 rounded"
></div>
<span class="text-sm text-base-content/70">Saídas do BD</span>
</div>
</div>
@@ -459,21 +684,35 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-lg font-bold text-base-content mb-4">Tipos de Operações</h3>
<h3 class="text-lg font-bold text-base-content mb-4">
Tipos de Operações
</h3>
<div class="space-y-3">
<div>
<div class="flex justify-between text-sm mb-1">
<span>Queries (Leituras)</span>
<span class="font-bold text-primary">{distribuicao.queries}</span>
<span class="font-bold text-primary"
>{distribuicao.queries}</span
>
</div>
<progress class="progress progress-primary w-full" value={distribuicao.queries} max={distribuicao.queries + distribuicao.mutations}></progress>
<progress
class="progress progress-primary w-full"
value={distribuicao.queries}
max={Math.max(distribuicao.queries + distribuicao.mutations, 1)}
></progress>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span>Mutations (Escritas)</span>
<span class="font-bold text-secondary">{distribuicao.mutations}</span>
<span class="font-bold text-secondary"
>{distribuicao.mutations}</span
>
</div>
<progress class="progress progress-secondary w-full" value={distribuicao.mutations} max={distribuicao.queries + distribuicao.mutations}></progress>
<progress
class="progress progress-secondary w-full"
value={distribuicao.mutations}
max={Math.max(distribuicao.queries + distribuicao.mutations, 1)}
></progress>
</div>
</div>
</div>
@@ -481,21 +720,35 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h3 class="text-lg font-bold text-base-content mb-4">Operações no Banco</h3>
<h3 class="text-lg font-bold text-base-content mb-4">
Operações no Banco
</h3>
<div class="space-y-3">
<div>
<div class="flex justify-between text-sm mb-1">
<span>Leituras</span>
<span class="font-bold text-info">{distribuicao.leituras}</span>
<span class="font-bold text-info"
>{distribuicao.leituras}</span
>
</div>
<progress class="progress progress-info w-full" value={distribuicao.leituras} max={distribuicao.leituras + distribuicao.escritas}></progress>
<progress
class="progress progress-info w-full"
value={distribuicao.leituras}
max={Math.max(distribuicao.leituras + distribuicao.escritas, 1)}
></progress>
</div>
<div>
<div class="flex justify-between text-sm mb-1">
<span>Escritas</span>
<span class="font-bold text-warning">{distribuicao.escritas}</span>
<span class="font-bold text-warning"
>{distribuicao.escritas}</span
>
</div>
<progress class="progress progress-warning w-full" value={distribuicao.escritas} max={distribuicao.leituras + distribuicao.escritas}></progress>
<progress
class="progress progress-warning w-full"
value={distribuicao.escritas}
max={Math.max(distribuicao.leituras + distribuicao.escritas, 1)}
></progress>
</div>
</div>
</div>
@@ -504,7 +757,6 @@
</div>
{/if}
<!-- Cards de Status -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="card bg-base-100 shadow-xl">
@@ -531,18 +783,27 @@
<div class="card-body">
<h3 class="card-title text-lg">Acesso Rápido</h3>
<div class="space-y-2 mt-4">
<a href="/recursos-humanos/funcionarios/cadastro" class="btn btn-sm btn-primary w-full">
<a
href={resolve("/recursos-humanos/funcionarios/cadastro")}
class="btn btn-sm btn-primary w-full"
>
Novo Funcionário
</a>
<a href="/recursos-humanos/simbolos/cadastro" class="btn btn-sm btn-primary w-full">
<a
href={resolve("/recursos-humanos/simbolos/cadastro")}
class="btn btn-sm btn-primary w-full"
>
Novo Símbolo
</a>
<a href="/ti/painel-administrativo" class="btn btn-sm btn-primary w-full">
<a
href={resolve("/ti/painel-administrativo")}
class="btn btn-sm btn-primary w-full"
>
Painel Admin
</a>
</div>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
@@ -552,7 +813,8 @@
<strong>Versão:</strong> 1.0.0
</p>
<p class="text-base-content/70">
<strong>Última Atualização:</strong> {new Date().toLocaleDateString("pt-BR")}
<strong>Última Atualização:</strong>
{new Date().toLocaleDateString("pt-BR")}
</p>
<p class="text-base-content/70">
<strong>Suporte:</strong> TI SGSE
@@ -561,8 +823,14 @@
</div>
</div>
</div>
{:else}
<!-- Mensagem de erro ou estado vazio -->
<div class="alert alert-warning">
<span>Não foi possível carregar os dados do dashboard.</span>
</div>
{/if}
</main>
</ProtectedRoute>
<style>
@keyframes fadeIn {

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import { onMount } from "svelte";
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
import TicketForm from "$lib/components/chamados/TicketForm.svelte";
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
import { chamadosStore } from "$lib/stores/chamados";
import { resolve } from "$app/paths";
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
type Ticket = Doc<"tickets">;
const client = useConvexClient();
let submitLoading = $state(false);
let resetSignal = $state(0);
let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>(
null,
);
const exemploTimeline = $state<NonNullable<Ticket["timeline"]>>([
{
etapa: "abertura",
status: "concluido",
prazo: Date.now(),
concluidoEm: Date.now(),
observacao: "Chamado criado",
},
{
etapa: "resposta_inicial",
status: "pendente",
prazo: Date.now() + 4 * 60 * 60 * 1000,
},
{
etapa: "conclusao",
status: "pendente",
prazo: Date.now() + 24 * 60 * 60 * 1000,
},
]);
$effect(() => {
// Garante que o cliente Convex use o token do usuário logado
useConvexWithAuth();
});
async function uploadArquivo(file: File) {
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
const response = await fetch(uploadUrl, {
method: "POST",
headers: { "Content-Type": file.type },
body: file,
});
const data = await response.json();
if (!data?.storageId) {
throw new Error("Falha ao enviar arquivo. Tente novamente.");
}
return {
arquivoId: data.storageId as Id<"_storage">,
nome: file.name,
tipo: file.type,
tamanho: file.size,
};
}
async function handleSubmit(event: CustomEvent<{ values: any }>) {
const { values } = event.detail;
try {
submitLoading = true;
feedback = null;
const anexos = [];
for (const file of values.anexos ?? []) {
const uploaded = await uploadArquivo(file);
anexos.push(uploaded);
}
const resultado = await client.mutation(api.chamados.abrirChamado, {
titulo: values.titulo,
descricao: values.descricao,
tipo: values.tipo,
categoria: values.categoria,
prioridade: values.prioridade,
canalOrigem: values.canalOrigem,
anexos,
});
feedback = {
tipo: "success",
mensagem: "Chamado registrado com sucesso! Você pode acompanhar pelo seu perfil.",
numero: resultado.numero,
};
resetSignal = resetSignal + 1;
// Atualizar store local
const novoTicket = await client.query(api.chamados.obterChamado, {
ticketId: resultado.ticketId,
});
if (novoTicket?.ticket) {
chamadosStore.upsertTicket(novoTicket.ticket);
chamadosStore.setDetalhe(resultado.ticketId, novoTicket);
}
} catch (error) {
const mensagem =
error instanceof Error ? error.message : "Erro ao enviar o chamado. Tente novamente.";
feedback = {
tipo: "error",
mensagem,
};
} finally {
submitLoading = false;
}
}
</script>
<main class="mx-auto w-full max-w-6xl space-y-10 px-4 py-8">
<section
class="relative overflow-hidden rounded-3xl border border-primary/30 bg-linear-to-br from-primary/10 via-base-100 to-secondary/20 p-10 shadow-2xl"
>
<div class="absolute -left-16 top-0 h-52 w-52 rounded-full bg-primary/20 blur-3xl"></div>
<div class="absolute -bottom-20 right-0 h-64 w-64 rounded-full bg-secondary/20 blur-3xl"></div>
<div class="relative z-10 space-y-4">
<span
class="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-primary"
>
Central de Chamados
</span>
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-3xl space-y-4">
<h1 class="text-4xl font-black leading-tight text-base-content sm:text-5xl">
Abrir novo chamado
</h1>
<p class="text-base text-base-content/70 sm:text-lg">
Registre reclamações, sugestões, elogios ou chamados técnicos. Toda interação gera
notificações automáticas via e-mail e chat com a assinatura do SGSE - Sistema de Gerenciamento de Secretaria.
</p>
<div class="flex flex-wrap gap-3 text-sm text-base-content/70">
<span class="badge badge-success badge-sm">Resposta ágil</span>
<span class="badge badge-info badge-sm">Timeline com SLA</span>
<span class="badge badge-warning badge-sm">Alertas de vencimento</span>
</div>
</div>
<a href={resolve("/perfil/chamados")} class="btn btn-outline btn-sm">
Acompanhar meus chamados
</a>
</div>
</div>
</section>
{#if feedback}
<div class={`alert ${feedback.tipo === "success" ? "alert-success" : "alert-error"} shadow-lg`}>
<div>
<span class="font-semibold">{feedback.mensagem}</span>
{#if feedback.numero}
<p class="text-sm">Número do ticket: {feedback.numero}</p>
{/if}
</div>
</div>
{/if}
<div class="grid gap-8 lg:grid-cols-3">
<div class="lg:col-span-2">
<div class="rounded-3xl border-2 border-primary/20 bg-gradient-to-br from-base-100 via-base-100/95 to-primary/5 p-8 shadow-xl">
<div class="mb-6 flex items-center gap-3">
<div class="rounded-xl bg-primary/10 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 text-primary"
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>
</div>
<div>
<h2 class="text-2xl font-bold text-base-content">Formulário</h2>
<p class="text-sm text-base-content/60">
Informe os detalhes para que nossa equipe possa priorizar o atendimento.
</p>
</div>
</div>
<div class="mt-6">
{#if resetSignal % 2 === 0}
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
{:else}
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
{/if}
</div>
</div>
</div>
<aside class="space-y-6">
<div class="rounded-3xl border-2 border-info/20 bg-gradient-to-br from-base-100 via-base-100/95 to-info/5 p-6 shadow-lg">
<div class="mb-4 flex items-center gap-3">
<div class="rounded-xl bg-info/10 p-2">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-info"
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>
</div>
<h3 class="text-lg font-bold text-base-content">Como funciona a timeline</h3>
</div>
<div class="mb-4 space-y-2 rounded-xl bg-base-200/50 p-4">
<p class="text-sm font-medium text-base-content/80">
Todas as etapas do ticket são monitoradas automaticamente.
</p>
<p class="text-xs text-base-content/60">
Os prazos mudam de cor conforme o SLA: <span class="text-success">dentro do prazo</span>, <span class="text-warning">próximo ao vencimento</span> ou <span class="text-error">vencido</span>.
</p>
</div>
<div class="rounded-xl border border-base-300 bg-base-100/80 p-4">
<TicketTimeline timeline={exemploTimeline} />
</div>
</div>
</aside>
</div>
</main>

View File

@@ -1,371 +1,424 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { authStore } from "$lib/stores/auth.svelte";
import { goto } from "$app/navigation";
import { onMount } from "svelte";
import { useConvexClient, useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
import { authStore } from '$lib/stores/auth.svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { onMount } from 'svelte';
import { Key, Eye, EyeOff, CheckCircle2, XCircle, Shield, Lock, AlertCircle, Info } from 'lucide-svelte';
const convex = useConvexClient();
const convex = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {});
let senhaAtual = $state("");
let novaSenha = $state("");
let confirmarSenha = $state("");
let carregando = $state(false);
let notice = $state<{ type: "success" | "error"; message: string } | null>(null);
let mostrarSenhaAtual = $state(false);
let mostrarNovaSenha = $state(false);
let mostrarConfirmarSenha = $state(false);
let senhaAtual = $state('');
let novaSenha = $state('');
let confirmarSenha = $state('');
let carregando = $state(false);
let notice = $state<{ type: 'success' | 'error'; message: string } | null>(null);
let mostrarSenhaAtual = $state(false);
let mostrarNovaSenha = $state(false);
let mostrarConfirmarSenha = $state(false);
onMount(() => {
if (!authStore.autenticado) {
goto("/");
}
});
onMount(() => {
if (!currentUser?.data) {
goto(resolve('/'));
}
});
function validarSenha(senha: string): { valido: boolean; erros: string[] } {
const erros: string[] = [];
function validarSenha(senha: string): { valido: boolean; erros: string[] } {
const erros: string[] = [];
if (senha.length < 8) {
erros.push("A senha deve ter no mínimo 8 caracteres");
}
if (!/[A-Z]/.test(senha)) {
erros.push("A senha deve conter pelo menos uma letra maiúscula");
}
if (!/[a-z]/.test(senha)) {
erros.push("A senha deve conter pelo menos uma letra minúscula");
}
if (!/[0-9]/.test(senha)) {
erros.push("A senha deve conter pelo menos um número");
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(senha)) {
erros.push("A senha deve conter pelo menos um caractere especial");
}
if (senha.length < 8) {
erros.push('A senha deve ter no mínimo 8 caracteres');
}
if (!/[A-Z]/.test(senha)) {
erros.push('A senha deve conter pelo menos uma letra maiúscula');
}
if (!/[a-z]/.test(senha)) {
erros.push('A senha deve conter pelo menos uma letra minúscula');
}
if (!/[0-9]/.test(senha)) {
erros.push('A senha deve conter pelo menos um número');
}
if (!/[!@#$%^&*(),.?":{}|<>]/.test(senha)) {
erros.push('A senha deve conter pelo menos um caractere especial');
}
return {
valido: erros.length === 0,
erros,
};
}
return {
valido: erros.length === 0,
erros
};
}
async function handleSubmit(e: Event) {
e.preventDefault();
notice = null;
async function handleSubmit(e: Event) {
e.preventDefault();
notice = null;
// Validações
if (!senhaAtual || !novaSenha || !confirmarSenha) {
notice = {
type: "error",
message: "Todos os campos são obrigatórios",
};
return;
}
// Validações
if (!senhaAtual || !novaSenha || !confirmarSenha) {
notice = {
type: 'error',
message: 'Todos os campos são obrigatórios'
};
return;
}
if (novaSenha !== confirmarSenha) {
notice = {
type: "error",
message: "A nova senha e a confirmação não coincidem",
};
return;
}
if (novaSenha !== confirmarSenha) {
notice = {
type: 'error',
message: 'A nova senha e a confirmação não coincidem'
};
return;
}
if (senhaAtual === novaSenha) {
notice = {
type: "error",
message: "A nova senha deve ser diferente da senha atual",
};
return;
}
if (senhaAtual === novaSenha) {
notice = {
type: 'error',
message: 'A nova senha deve ser diferente da senha atual'
};
return;
}
const validacao = validarSenha(novaSenha);
if (!validacao.valido) {
notice = {
type: "error",
message: validacao.erros.join(". "),
};
return;
}
const validacao = validarSenha(novaSenha);
if (!validacao.valido) {
notice = {
type: 'error',
message: validacao.erros.join('. ')
};
return;
}
carregando = true;
carregando = true;
try {
if (!authStore.token) {
throw new Error("Token não encontrado");
}
try {
if (!authStore.token) {
throw new Error('Token não encontrado');
}
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
token: authStore.token,
senhaAntiga: senhaAtual,
novaSenha: novaSenha,
});
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
token: authStore.token,
senhaAtual: senhaAtual,
novaSenha: novaSenha
});
if (resultado.sucesso) {
notice = {
type: "success",
message: "Senha alterada com sucesso! Redirecionando...",
};
if (resultado.sucesso) {
notice = {
type: 'success',
message: 'Senha alterada com sucesso! Redirecionando...'
};
// Limpar campos
senhaAtual = "";
novaSenha = "";
confirmarSenha = "";
// Limpar campos
senhaAtual = '';
novaSenha = '';
confirmarSenha = '';
// Redirecionar após 2 segundos
setTimeout(() => {
goto("/");
}, 2000);
} else {
notice = {
type: "error",
message: resultado.erro || "Erro ao alterar senha",
};
}
} catch (error: any) {
notice = {
type: "error",
message: error.message || "Erro ao conectar com o servidor",
};
} finally {
carregando = false;
}
}
// Redirecionar após 2 segundos
setTimeout(() => {
goto(resolve('/'));
}, 2000);
} else {
notice = {
type: 'error',
message: resultado.erro || 'Erro ao alterar senha'
};
}
} catch (error: any) {
notice = {
type: 'error',
message: error.message || 'Erro ao conectar com o servidor'
};
} finally {
carregando = false;
}
}
function cancelar() {
goto("/");
}
function cancelar() {
goto(resolve('/'));
}
</script>
<main class="container mx-auto px-4 py-8 max-w-2xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
</svg>
<h1 class="text-4xl font-bold text-primary">Alterar Senha</h1>
</div>
<p class="text-base-content/70 text-lg">
Atualize sua senha de acesso ao sistema
</p>
</div>
<main class="container mx-auto max-w-4xl px-4 py-8">
<!-- Header Moderno -->
<div class="mb-8">
<div class="bg-linear-to-r from-primary/20 via-primary/10 to-primary/20 rounded-2xl p-6 mb-6 shadow-lg border border-primary/20">
<div class="flex items-center gap-4">
<div class="p-3 bg-primary/20 rounded-2xl">
<Key class="h-8 w-8 text-primary" strokeWidth={2.5} />
</div>
<div class="flex-1">
<h1 class="text-primary text-4xl font-bold mb-2">Alterar Senha</h1>
<p class="text-base-content/70 text-lg">
Atualize sua senha de acesso ao sistema de forma segura
</p>
</div>
<div class="badge badge-primary badge-lg gap-2">
<Shield class="h-4 w-4" strokeWidth={2} />
Seguro
</div>
</div>
</div>
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs mb-6">
<ul>
<li><a href="/">Dashboard</a></li>
<li>Alterar Senha</li>
</ul>
</div>
<!-- Breadcrumbs -->
<div class="breadcrumbs text-sm mb-6">
<ul>
<li><a href={resolve('/')} class="link link-hover">Dashboard</a></li>
<li>Alterar Senha</li>
</ul>
</div>
</div>
<!-- Alertas -->
{#if notice}
<div class="alert {notice.type === 'success' ? 'alert-success' : 'alert-error'} mb-6 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
{#if notice.type === "success"}
<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"
/>
{:else}
<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"
/>
{/if}
</svg>
<span>{notice.message}</span>
</div>
{/if}
<!-- Alertas -->
{#if notice}
<div
class="alert {notice.type === 'success'
? 'alert-success'
: 'alert-error'} mb-6 shadow-xl animate-in fade-in slide-in-from-top duration-300"
>
{#if notice.type === 'success'}
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
{:else}
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
{/if}
<span class="font-semibold">{notice.message}</span>
</div>
{/if}
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Senha Atual -->
<div class="form-control">
<label class="label" for="senha-atual">
<span class="label-text font-semibold">Senha Atual</span>
<span class="label-text-alt text-error">*</span>
</label>
<div class="relative">
<input
id="senha-atual"
type={mostrarSenhaAtual ? "text" : "password"}
placeholder="Digite sua senha atual"
class="input input-bordered input-primary w-full pr-12"
bind:value={senhaAtual}
required
disabled={carregando}
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
onclick={() => (mostrarSenhaAtual = !mostrarSenhaAtual)}
>
{#if mostrarSenhaAtual}
<svg 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{:else}
<svg 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 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{/if}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Formulário Principal -->
<div class="lg:col-span-2">
<div
class="card bg-base-100 border-base-300/50 border-2 shadow-xl hover:shadow-2xl transition-all duration-300"
>
<div class="card-body p-8">
<div class="flex items-center gap-3 mb-6">
<div class="p-2 bg-primary/10 rounded-xl">
<Lock class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h2 class="text-2xl font-bold text-base-content">Formulário de Alteração</h2>
</div>
<!-- Nova Senha -->
<div class="form-control">
<label class="label" for="nova-senha">
<span class="label-text font-semibold">Nova Senha</span>
<span class="label-text-alt text-error">*</span>
</label>
<div class="relative">
<input
id="nova-senha"
type={mostrarNovaSenha ? "text" : "password"}
placeholder="Digite sua nova senha"
class="input input-bordered input-primary w-full pr-12"
bind:value={novaSenha}
required
disabled={carregando}
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
onclick={() => (mostrarNovaSenha = !mostrarNovaSenha)}
>
{#if mostrarNovaSenha}
<svg 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{:else}
<svg 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 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{/if}
</button>
</div>
<label class="label">
<span class="label-text-alt text-base-content/60">
Mínimo 8 caracteres, com letras maiúsculas, minúsculas, números e caracteres especiais
</span>
</label>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Senha Atual -->
<div class="form-control">
<label class="label" for="senha-atual">
<span class="label-text font-semibold text-base">Senha Atual</span>
<span class="label-text-alt text-error font-bold">*</span>
</label>
<div class="relative">
<input
id="senha-atual"
type={mostrarSenhaAtual ? 'text' : 'password'}
placeholder="Digite sua senha atual"
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
bind:value={senhaAtual}
required
disabled={carregando}
/>
<button
type="button"
class="btn btn-sm btn-ghost btn-circle absolute top-1/2 right-2 -translate-y-1/2 hover:bg-primary/10"
onclick={() => (mostrarSenhaAtual = !mostrarSenhaAtual)}
disabled={carregando}
aria-label={mostrarSenhaAtual ? 'Ocultar senha' : 'Mostrar senha'}
>
{#if mostrarSenhaAtual}
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
{:else}
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
{/if}
</button>
</div>
</div>
<!-- Confirmar Senha -->
<div class="form-control">
<label class="label" for="confirmar-senha">
<span class="label-text font-semibold">Confirmar Nova Senha</span>
<span class="label-text-alt text-error">*</span>
</label>
<div class="relative">
<input
id="confirmar-senha"
type={mostrarConfirmarSenha ? "text" : "password"}
placeholder="Digite novamente sua nova senha"
class="input input-bordered input-primary w-full pr-12"
bind:value={confirmarSenha}
required
disabled={carregando}
/>
<button
type="button"
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
onclick={() => (mostrarConfirmarSenha = !mostrarConfirmarSenha)}
>
{#if mostrarConfirmarSenha}
<svg 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
{:else}
<svg 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 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
{/if}
</button>
</div>
</div>
<!-- Nova Senha -->
<div class="form-control">
<label class="label" for="nova-senha">
<span class="label-text font-semibold text-base">Nova Senha</span>
<span class="label-text-alt text-error font-bold">*</span>
</label>
<div class="relative">
<input
id="nova-senha"
type={mostrarNovaSenha ? 'text' : 'password'}
placeholder="Digite sua nova senha"
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
bind:value={novaSenha}
required
disabled={carregando}
/>
<button
type="button"
class="btn btn-sm btn-ghost btn-circle absolute top-1/2 right-2 -translate-y-1/2 hover:bg-primary/10"
onclick={() => (mostrarNovaSenha = !mostrarNovaSenha)}
disabled={carregando}
aria-label={mostrarNovaSenha ? 'Ocultar senha' : 'Mostrar senha'}
>
{#if mostrarNovaSenha}
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
{:else}
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
{/if}
</button>
</div>
<div class="label">
<span class="label-text-alt text-base-content/60 text-xs">
Mínimo 8 caracteres com maiúsculas, minúsculas, números e especiais
</span>
</div>
</div>
<!-- Requisitos de Senha -->
<div class="alert alert-info">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<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" />
</svg>
<div>
<h3 class="font-bold">Requisitos de Senha:</h3>
<ul class="text-sm list-disc list-inside mt-2 space-y-1">
<li>Mínimo de 8 caracteres</li>
<li>Pelo menos uma letra maiúscula (A-Z)</li>
<li>Pelo menos uma letra minúscula (a-z)</li>
<li>Pelo menos um número (0-9)</li>
<li>Pelo menos um caractere especial (!@#$%^&*...)</li>
</ul>
</div>
</div>
<!-- Confirmar Senha -->
<div class="form-control">
<label class="label" for="confirmar-senha">
<span class="label-text font-semibold text-base">Confirmar Nova Senha</span>
<span class="label-text-alt text-error font-bold">*</span>
</label>
<div class="relative">
<input
id="confirmar-senha"
type={mostrarConfirmarSenha ? 'text' : 'password'}
placeholder="Digite novamente sua nova senha"
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
bind:value={confirmarSenha}
required
disabled={carregando}
/>
<button
type="button"
class="btn btn-sm btn-ghost btn-circle absolute top-1/2 right-2 -translate-y-1/2 hover:bg-primary/10"
onclick={() => (mostrarConfirmarSenha = !mostrarConfirmarSenha)}
disabled={carregando}
aria-label={mostrarConfirmarSenha ? 'Ocultar senha' : 'Mostrar senha'}
>
{#if mostrarConfirmarSenha}
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
{:else}
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
{/if}
</button>
</div>
</div>
<!-- Botões -->
<div class="flex gap-4 justify-end mt-8">
<button
type="button"
class="btn btn-ghost"
onclick={cancelar}
disabled={carregando}
>
<svg 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>
Cancelar
</button>
<button
type="submit"
class="btn btn-primary"
disabled={carregando}
>
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Alterando...
{:else}
<svg 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>
Alterar Senha
{/if}
</button>
</div>
</form>
</div>
</div>
<!-- Botões -->
<div class="flex flex-col sm:flex-row justify-end gap-4 mt-8 pt-6 border-t border-base-300">
<button
type="button"
class="btn btn-outline btn-lg flex-1 sm:flex-initial"
onclick={cancelar}
disabled={carregando}
>
<XCircle class="h-5 w-5" strokeWidth={2} />
Cancelar
</button>
<button
type="submit"
class="btn btn-primary btn-lg flex-1 sm:flex-initial shadow-lg hover:shadow-xl transition-all duration-200"
disabled={carregando}
>
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Alterando...
{:else}
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
Alterar Senha
{/if}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Dicas de Segurança -->
<div class="mt-6 card bg-base-200 shadow-lg">
<div class="card-body">
<h3 class="card-title text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-warning" 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>
Dicas de Segurança
</h3>
<ul class="text-sm space-y-2 text-base-content/70">
<li>✅ Nunca compartilhe sua senha com ninguém</li>
<li>✅ Use uma senha única para cada sistema</li>
<li>✅ Altere sua senha regularmente</li>
<li>✅ Não use informações pessoais óbvias (nome, data de nascimento, etc.)</li>
<li>✅ Considere usar um gerenciador de senhas</li>
</ul>
</div>
</div>
<!-- Sidebar com Informações -->
<div class="space-y-6">
<!-- Requisitos de Senha -->
<div
class="card bg-linear-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-lg"
>
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-info/20 rounded-xl">
<Info class="h-6 w-6 text-info" strokeWidth={2} />
</div>
<h3 class="text-lg font-bold text-base-content">Requisitos de Senha</h3>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-start gap-2">
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Mínimo de 8 caracteres</span>
</li>
<li class="flex items-start gap-2">
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Pelo menos uma letra maiúscula (A-Z)</span>
</li>
<li class="flex items-start gap-2">
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Pelo menos uma letra minúscula (a-z)</span>
</li>
<li class="flex items-start gap-2">
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Pelo menos um número (0-9)</span>
</li>
<li class="flex items-start gap-2">
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Pelo menos um caractere especial (!@#$%...)</span>
</li>
</ul>
</div>
</div>
<!-- Dicas de Segurança -->
<div
class="card bg-linear-to-br from-warning/10 to-warning/5 border-2 border-warning/20 shadow-lg"
>
<div class="card-body p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-warning/20 rounded-xl">
<Shield class="h-6 w-6 text-warning" strokeWidth={2} />
</div>
<h3 class="text-lg font-bold text-base-content">Dicas de Segurança</h3>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-start gap-2">
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Nunca compartilhe sua senha</span>
</li>
<li class="flex items-start gap-2">
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Use uma senha única para cada sistema</span>
</li>
<li class="flex items-start gap-2">
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Altere sua senha regularmente</span>
</li>
<li class="flex items-start gap-2">
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Evite informações pessoais óbvias</span>
</li>
<li class="flex items-start gap-2">
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
<span class="text-base-content/80">Considere usar um gerenciador de senhas</span>
</li>
</ul>
</div>
</div>
</div>
</div>
</main>
<style>
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-in {
animation: fade-in 0.3s ease-out;
}
</style>

View File

@@ -1,48 +1,47 @@
<script lang="ts">
</script>
<main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
<li>Compras</li>
</ul>
</div>
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-cyan-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-cyan-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Compras</h1>
<p class="text-base-content/70">Gestão de compras e aquisições</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições.
</p>
<div class="badge badge-warning badge-lg gap-2">
<svg 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Em Desenvolvimento
</div>
</div>
</div>
</div>
</main>
<script lang="ts">
import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li>Compras</li>
</ul>
</div>
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-cyan-500/20 rounded-xl">
<ShoppingCart class="h-8 w-8 text-cyan-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Compras</h1>
<p class="text-base-content/70">Gestão de compras e aquisições</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<ShoppingBag class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições.
</p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div>
</div>
</div>
</div>
</main>
</ProtectedRoute>

View File

@@ -1,48 +1,47 @@
<script lang="ts">
</script>
<main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
<li>Comunicação</li>
</ul>
</div>
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-pink-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-pink-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5.882V19.24a1.76 1.76 0 01-3.417.592l-2.147-6.15M18 13a3 3 0 100-6M5.436 13.683A4.001 4.001 0 017 6h1.832c4.1 0 7.625-1.234 9.168-3v14c-1.543-1.766-5.067-3-9.168-3H7a3.988 3.988 0 01-1.564-.317z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Comunicação</h1>
<p class="text-base-content/70">Gestão de comunicação institucional</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Comunicação está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão de comunicação institucional.
</p>
<div class="badge badge-warning badge-lg gap-2">
<svg 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Em Desenvolvimento
</div>
</div>
</div>
</div>
</main>
<script lang="ts">
import { Megaphone, Edit, Plus } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li>Comunicação</li>
</ul>
</div>
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-pink-500/20 rounded-xl">
<Megaphone class="h-8 w-8 text-pink-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Comunicação</h1>
<p class="text-base-content/70">Gestão de comunicação institucional</p>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<Edit class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Comunicação está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão de comunicação institucional.
</p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div>
</div>
</div>
</div>
</main>
</ProtectedRoute>

View File

@@ -1,99 +1,92 @@
<script lang="ts">
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
<li>Controladoria</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-purple-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Controladoria</h1>
<p class="text-base-content/70">Controle e auditoria interna da secretaria</p>
</div>
</div>
</div>
<!-- Card de Aviso -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Controladoria está sendo desenvolvido e em breve estará disponível com funcionalidades completas de controle e auditoria.
</p>
<div class="badge badge-warning badge-lg gap-2">
<svg 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Em Desenvolvimento
</div>
</div>
</div>
</div>
<!-- Funcionalidades Previstas -->
<div class="mt-6">
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<h4 class="font-semibold">Auditoria Interna</h4>
</div>
<p class="text-sm text-base-content/70">Controle e verificação de processos internos</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" 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>
</div>
<h4 class="font-semibold">Compliance</h4>
</div>
<p class="text-sm text-base-content/70">Conformidade com normas e regulamentos</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
</svg>
</div>
<h4 class="font-semibold">Indicadores de Gestão</h4>
</div>
<p class="text-sm text-base-content/70">Monitoramento de KPIs e métricas</p>
</div>
</div>
</div>
</div>
</main>
<script lang="ts">
import { BarChart3, ClipboardCheck, Plus, CheckCircle2, Clock, TrendingUp } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li>Controladoria</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-purple-500/20 rounded-xl">
<BarChart3 class="h-8 w-8 text-purple-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Controladoria</h1>
<p class="text-base-content/70">Controle e auditoria interna da secretaria</p>
</div>
</div>
</div>
<!-- Card de Aviso -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<ClipboardCheck class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo de Controladoria está sendo desenvolvido e em breve estará disponível com funcionalidades completas de controle e auditoria.
</p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div>
</div>
</div>
</div>
<!-- Funcionalidades Previstas -->
<div class="mt-6">
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<CheckCircle2 class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Auditoria Interna</h4>
</div>
<p class="text-sm text-base-content/70">Controle e verificação de processos internos</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<Clock class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Compliance</h4>
</div>
<p class="text-sm text-base-content/70">Conformidade com normas e regulamentos</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<TrendingUp class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Indicadores de Gestão</h4>
</div>
<p class="text-sm text-base-content/70">Monitoramento de KPIs e métricas</p>
</div>
</div>
</div>
</div>
</main>
</ProtectedRoute>

View File

@@ -1,264 +1,364 @@
<script lang="ts">
import { useConvexClient } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import { useConvexClient } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api';
const convex = useConvexClient();
import { resolve } from '$app/paths';
const convex = useConvexClient();
let matricula = $state("");
let email = $state("");
let carregando = $state(false);
let notice = $state<{ type: "success" | "error" | "info"; message: string } | null>(null);
let solicitacaoEnviada = $state(false);
let matricula = $state('');
let email = $state('');
let carregando = $state(false);
let notice = $state<{ type: 'success' | 'error' | 'info'; message: string } | null>(null);
let solicitacaoEnviada = $state(false);
async function handleSubmit(e: Event) {
e.preventDefault();
notice = null;
async function handleSubmit(e: Event) {
e.preventDefault();
notice = null;
if (!matricula || !email) {
notice = {
type: "error",
message: "Por favor, preencha todos os campos",
};
return;
}
if (!matricula || !email) {
notice = {
type: 'error',
message: 'Por favor, preencha todos os campos'
};
return;
}
carregando = true;
carregando = true;
try {
// Verificar se o usuário existe
const usuarios = await convex.query(api.usuarios.listar, {
matricula: matricula,
});
try {
// Verificar se o usuário existe
const usuarios = await convex.query(api.usuarios.listar, {
matricula: matricula
});
const usuario = usuarios.find(u => u.matricula === matricula && u.email === email);
const usuario = usuarios.find((u) => u.matricula === matricula && u.email === email);
if (!usuario) {
notice = {
type: "error",
message: "Matrícula ou e-mail não encontrados. Verifique os dados e tente novamente.",
};
carregando = false;
return;
}
if (!usuario) {
notice = {
type: 'error',
message: 'Matrícula ou e-mail não encontrados. Verifique os dados e tente novamente.'
};
carregando = false;
return;
}
// Simular envio de solicitação
solicitacaoEnviada = true;
notice = {
type: "success",
message: "Solicitação enviada com sucesso! A equipe de TI entrará em contato em breve.",
};
// Simular envio de solicitação
solicitacaoEnviada = true;
notice = {
type: 'success',
message: 'Solicitação enviada com sucesso! A equipe de TI entrará em contato em breve.'
};
// Limpar campos
matricula = "";
email = "";
} catch (error: any) {
notice = {
type: "error",
message: error.message || "Erro ao processar solicitação",
};
} finally {
carregando = false;
}
}
// Limpar campos
matricula = '';
email = '';
} catch (error: any) {
notice = {
type: 'error',
message: error.message || 'Erro ao processar solicitação'
};
} finally {
carregando = false;
}
}
</script>
<main class="container mx-auto px-4 py-8 max-w-2xl">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h1 class="text-4xl font-bold text-primary">Esqueci Minha Senha</h1>
</div>
<p class="text-base-content/70 text-lg">
Solicite a recuperação da sua senha de acesso
</p>
</div>
<main class="container mx-auto max-w-2xl px-4 py-8">
<!-- Header -->
<div class="mb-8">
<div class="mb-2 flex items-center gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-primary h-10 w-10"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<h1 class="text-primary text-4xl font-bold">Esqueci Minha Senha</h1>
</div>
<p class="text-base-content/70 text-lg">Solicite a recuperação da sua senha de acesso</p>
</div>
<!-- Breadcrumbs -->
<div class="text-sm breadcrumbs mb-6">
<ul>
<li><a href="/">Dashboard</a></li>
<li>Esqueci Minha Senha</li>
</ul>
</div>
<!-- Breadcrumbs -->
<div class="breadcrumbs mb-6 text-sm">
<ul>
<li><a href={resolve('/')}>Dashboard</a></li>
<li>Esqueci Minha Senha</li>
</ul>
</div>
<!-- Alertas -->
{#if notice}
<div class="alert {notice.type === 'success' ? 'alert-success' : notice.type === 'error' ? 'alert-error' : 'alert-info'} mb-6 shadow-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6"
fill="none"
viewBox="0 0 24 24"
>
{#if notice.type === "success"}
<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"
/>
{:else if notice.type === "error"}
<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"
/>
{:else}
<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"
/>
{/if}
</svg>
<span>{notice.message}</span>
</div>
{/if}
<!-- Alertas -->
{#if notice}
<div
class="alert {notice.type === 'success'
? 'alert-success'
: notice.type === 'error'
? 'alert-error'
: 'alert-info'} mb-6 shadow-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
{#if notice.type === 'success'}
<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"
/>
{:else if notice.type === 'error'}
<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"
/>
{:else}
<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"
/>
{/if}
</svg>
<span>{notice.message}</span>
</div>
{/if}
{#if !solicitacaoEnviada}
<!-- Formulário -->
<div class="card bg-base-100 shadow-xl border border-base-300">
<div class="card-body">
<div class="alert alert-info mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
<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" />
</svg>
<div>
<h3 class="font-bold">Como funciona?</h3>
<p class="text-sm">
Informe sua matrícula e e-mail cadastrados. A equipe de TI receberá sua solicitação e entrará em contato para resetar sua senha.
</p>
</div>
</div>
{#if !solicitacaoEnviada}
<!-- Formulário -->
<div class="card bg-base-100 border-base-300 border shadow-xl">
<div class="card-body">
<div class="alert alert-info mb-6">
<svg
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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<h3 class="font-bold">Como funciona?</h3>
<p class="text-sm">
Informe sua matrícula e e-mail cadastrados. A equipe de TI receberá sua solicitação e
entrará em contato para resetar sua senha.
</p>
</div>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Matrícula -->
<div class="form-control">
<label class="label" for="matricula">
<span class="label-text font-semibold">Matrícula</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="matricula"
type="text"
placeholder="Digite sua matrícula"
class="input input-bordered input-primary w-full"
bind:value={matricula}
required
disabled={carregando}
/>
</div>
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Matrícula -->
<div class="form-control">
<label class="label" for="matricula">
<span class="label-text font-semibold">Matrícula</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="matricula"
type="text"
placeholder="Digite sua matrícula"
class="input input-bordered input-primary w-full"
bind:value={matricula}
required
disabled={carregando}
/>
</div>
<!-- E-mail -->
<div class="form-control">
<label class="label" for="email">
<span class="label-text font-semibold">E-mail</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="email"
type="email"
placeholder="Digite seu e-mail cadastrado"
class="input input-bordered input-primary w-full"
bind:value={email}
required
disabled={carregando}
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Use o e-mail cadastrado no sistema
</span>
</label>
</div>
<!-- E-mail -->
<div class="form-control">
<label class="label" for="email">
<span class="label-text font-semibold">E-mail</span>
<span class="label-text-alt text-error">*</span>
</label>
<input
id="email"
type="email"
placeholder="Digite seu e-mail cadastrado"
class="input input-bordered input-primary w-full"
bind:value={email}
required
disabled={carregando}
/>
<label class="label">
<span class="label-text-alt text-base-content/60">
Use o e-mail cadastrado no sistema
</span>
</label>
</div>
<!-- Botões -->
<div class="flex gap-4 justify-end mt-8">
<a href="/" class="btn btn-ghost" class:btn-disabled={carregando}>
<svg 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="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</a>
<button
type="submit"
class="btn btn-primary"
disabled={carregando}
>
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
<svg 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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
Enviar Solicitação
{/if}
</button>
</div>
</form>
</div>
</div>
{:else}
<!-- Mensagem de Sucesso -->
<div class="card bg-success/10 shadow-xl border border-success/30">
<div class="card-body text-center">
<div class="flex justify-center mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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>
<h2 class="text-2xl font-bold text-success mb-4">Solicitação Enviada!</h2>
<p class="text-base-content/70 mb-6">
Sua solicitação de recuperação de senha foi enviada para a equipe de TI.
Você receberá um contato em breve com as instruções para resetar sua senha.
</p>
<div class="flex gap-4 justify-center">
<a href="/" class="btn btn-primary">
<svg 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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Voltar ao Dashboard
</a>
<button type="button" class="btn btn-ghost" onclick={() => solicitacaoEnviada = false}>
Enviar Nova Solicitação
</button>
</div>
</div>
</div>
{/if}
<!-- Botões -->
<div class="mt-8 flex justify-end gap-4">
<a href={resolve('/')} class="btn" class:btn-disabled={carregando}>
<svg
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="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Voltar
</a>
<button type="submit" class="btn btn-primary" disabled={carregando}>
{#if carregando}
<span class="loading loading-spinner loading-sm"></span>
Enviando...
{:else}
<svg
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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
Enviar Solicitação
{/if}
</button>
</div>
</form>
</div>
</div>
{:else}
<!-- Mensagem de Sucesso -->
<div class="card bg-success/10 border-success/30 border shadow-xl">
<div class="card-body text-center">
<div class="mb-4 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-success h-24 w-24"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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>
<h2 class="text-success mb-4 text-2xl font-bold">Solicitação Enviada!</h2>
<p class="text-base-content/70 mb-6">
Sua solicitação de recuperação de senha foi enviada para a equipe de TI. Você receberá um
contato em breve com as instruções para resetar sua senha.
</p>
<div class="flex justify-center gap-4">
<a href={resolve('/')} class="btn btn-primary">
<svg
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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
Voltar ao Dashboard
</a>
<button type="button" class="btn" onclick={() => (solicitacaoEnviada = false)}>
Enviar Nova Solicitação
</button>
</div>
</div>
</div>
{/if}
<!-- Card de Contato -->
<div class="mt-6 card bg-base-200 shadow-lg">
<div class="card-body">
<h3 class="card-title text-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
Precisa de Ajuda?
</h3>
<p class="text-sm text-base-content/70">
Se você não conseguir recuperar sua senha ou tiver problemas com o sistema, entre em contato diretamente com a equipe de TI:
</p>
<div class="mt-4 space-y-2">
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span class="text-sm">ti@sgse.pe.gov.br</span>
</div>
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span class="text-sm">(81) 3183-8000</span>
</div>
</div>
</div>
</div>
<!-- Card de Contato -->
<div class="card bg-base-200 mt-6 shadow-lg">
<div class="card-body">
<h3 class="card-title text-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-info h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<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"
/>
</svg>
Precisa de Ajuda?
</h3>
<p class="text-base-content/70 text-sm">
Se você não conseguir recuperar sua senha ou tiver problemas com o sistema, entre em contato
diretamente com a equipe de TI:
</p>
<div class="mt-4 space-y-2">
<div class="flex items-center gap-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="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<span class="text-sm">ti@sgse.pe.gov.br</span>
</div>
<div class="flex items-center gap-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="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
/>
</svg>
<span class="text-sm">(81) 3183-8000</span>
</div>
</div>
</div>
</div>
</main>

View File

@@ -1,99 +1,92 @@
<script lang="ts">
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
<li>Financeiro</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-green-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Financeiro</h1>
<p class="text-base-content/70">Gestão financeira e orçamentária da secretaria</p>
</div>
</div>
</div>
<!-- Card de Aviso -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo Financeiro está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão financeira e orçamentária.
</p>
<div class="badge badge-warning badge-lg gap-2">
<svg 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Em Desenvolvimento
</div>
</div>
</div>
</div>
<!-- Funcionalidades Previstas -->
<div class="mt-6">
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
</div>
<h4 class="font-semibold">Controle Orçamentário</h4>
</div>
<p class="text-sm text-base-content/70">Gestão e acompanhamento do orçamento anual</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h4 class="font-semibold">Fluxo de Caixa</h4>
</div>
<p class="text-sm text-base-content/70">Controle de entradas e saídas financeiras</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" 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>
</div>
<h4 class="font-semibold">Relatórios Financeiros</h4>
</div>
<p class="text-sm text-base-content/70">Geração de relatórios e demonstrativos</p>
</div>
</div>
</div>
</div>
</main>
<script lang="ts">
import { DollarSign, Building2, Plus, Calculator, TrendingUp, FileText } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li>Financeiro</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-green-500/20 rounded-xl">
<DollarSign class="h-8 w-8 text-green-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Financeiro</h1>
<p class="text-base-content/70">Gestão financeira e orçamentária da secretaria</p>
</div>
</div>
</div>
<!-- Card de Aviso -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<Building2 class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo Financeiro está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão financeira e orçamentária.
</p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div>
</div>
</div>
</div>
<!-- Funcionalidades Previstas -->
<div class="mt-6">
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<Calculator class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Controle Orçamentário</h4>
</div>
<p class="text-sm text-base-content/70">Gestão e acompanhamento do orçamento anual</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<TrendingUp class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Fluxo de Caixa</h4>
</div>
<p class="text-sm text-base-content/70">Controle de entradas e saídas financeiras</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<FileText class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Relatórios Financeiros</h4>
</div>
<p class="text-sm text-base-content/70">Geração de relatórios e demonstrativos</p>
</div>
</div>
</div>
</div>
</main>
</ProtectedRoute>

View File

@@ -1,48 +1,165 @@
<script lang="ts">
import { resolve } from '$app/paths';
import WidgetGestaoPontos from '$lib/components/ponto/WidgetGestaoPontos.svelte';
const menuItems = [
{
categoria: "Gestão de Ausências",
descricao: "Gerencie solicitações de ausências e aprovações",
icon: `<svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12" 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>`,
gradient: "from-orange-500/10 to-orange-600/20",
accentColor: "text-orange-600",
bgIcon: "bg-orange-500/20",
opcoes: [
{
nome: "Gestão de Ausências",
descricao: "Visualizar e gerenciar solicitações de ausências",
href: "/gestao-pessoas/gestao-ausencias",
icon: `<svg 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 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>`,
},
],
},
];
</script>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li>Secretaria de Gestão de Pessoas</li>
</ul>
</div>
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-teal-500/20 rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-teal-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Secretaria de Gestão de Pessoas</h1>
<p class="text-base-content/70">Gestão estratégica de pessoas</p>
</div>
</div>
<!-- Cabeçalho -->
<div class="mb-8">
<h1 class="text-4xl font-bold text-primary mb-2">
Secretaria de Gestão de Pessoas
</h1>
<p class="text-lg text-base-content/70">
Gerencie processos estratégicos de gestão de pessoas
</p>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center">
<div class="mb-6">
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
<p class="text-base-content/70 max-w-md mb-6">
O módulo da Secretaria de Gestão de Pessoas está sendo desenvolvido e em breve estará disponível com funcionalidades completas de gestão estratégica de pessoas.
</p>
<div class="badge badge-warning badge-lg gap-2">
<svg 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="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
Em Desenvolvimento
<!-- Menu de Opções -->
<div class="space-y-8">
{#each menuItems as categoria}
<div
class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300"
>
<div class="card-body">
<!-- Cabeçalho da Categoria -->
<div class="flex items-start gap-6 mb-6">
<div class="p-4 {categoria.bgIcon} rounded-2xl">
<div class={categoria.accentColor}>
{@html categoria.icon}
</div>
</div>
<div class="flex-1">
<h2 class="card-title text-2xl mb-2 {categoria.accentColor}">
{categoria.categoria}
</h2>
<p class="text-base-content/70">{categoria.descricao}</p>
</div>
</div>
<!-- Grid de Opções -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{#each categoria.opcoes as opcao}
<a
href={opcao.href}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br {categoria.gradient} p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
>
<div
class="{categoria.accentColor} group-hover:text-white"
>
{@html opcao.icon}
</div>
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
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>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
>
{opcao.nome}
</h3>
<p class="text-sm text-base-content/70 flex-1">
{opcao.descricao}
</p>
</div>
</a>
{/each}
</div>
</div>
</div>
{/each}
</div>
<!-- Widget Gestão de Pontos -->
<div class="mt-8">
<WidgetGestaoPontos />
</div>
<!-- Card de Ajuda -->
<div class="alert alert-info shadow-lg mt-8">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6"
>
<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>
<h3 class="font-bold">Precisa de ajuda?</h3>
<div class="text-sm">
Entre em contato com o suporte técnico ou consulte a documentação do
sistema para mais informações sobre as funcionalidades da Secretaria de
Gestão de Pessoas.
</div>
</div>
</div>
</main>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.card {
animation: fadeInUp 0.5s ease-out;
}
</style>

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