Compare commits
169 Commits
feat-funci
...
feat-licit
| Author | SHA1 | Date | |
|---|---|---|---|
| 263d561301 | |||
| 372feed819 | |||
| ed5695cf28 | |||
| 7cdc726781 | |||
| f465bd973e | |||
| b660d123d4 | |||
| d16f76daeb | |||
| b8506b6d45 | |||
| 67d6b3ec72 | |||
| b01d2d6786 | |||
| b844260399 | |||
| f0c6e4468f | |||
| 801a39d221 | |||
| 029cd9c637 | |||
| 52123a33b3 | |||
| 031c151967 | |||
| af6353fa40 | |||
| 22e77d8890 | |||
| db098ceea9 | |||
| 422dc6f022 | |||
| 3420872a37 | |||
| 71550874ce | |||
| 7c8be8a818 | |||
| 0e5a26b5fd | |||
| f021e96eb4 | |||
| 2c3d231d20 | |||
| 2b94b56f6e | |||
| d4e70b5e52 | |||
| 8a613128a5 | |||
| 99258d620f | |||
| 917a1d03ca | |||
| 3d9e8ae1f8 | |||
| d173e2a255 | |||
| 7e3c100fb9 | |||
| 05d3a394da | |||
| 29118d22ce | |||
| 55847e2a77 | |||
| 5ef6ef8550 | |||
| fb784d6f7e | |||
| 24b8eb6a14 | |||
| 60e0bfa69e | |||
| 70d405d98d | |||
| 88983ea297 | |||
| ea01e2401a | |||
| 118051ad56 | |||
| 9b3b095c01 | |||
|
|
2420aafdba | ||
| d8da7e2a05 | |||
| b503045b41 | |||
| 3c371bc35c | |||
| 731f95d0b5 | |||
| 5b2c682870 | |||
|
|
33a9c9e81d | ||
| aa0b03ed3f | |||
| 73d550aa96 | |||
| c058865817 | |||
|
|
fe68dd9d11 | ||
| 1fea5f1f26 | |||
|
|
b65cf5b4a6 | ||
| 4ae5baffcc | |||
| fd7d3729c1 | |||
| ebde59c6d2 | |||
| 0b7f1ad621 | |||
| 4ffa403c46 | |||
| bd574aedc0 | |||
| a2451baafc | |||
| 34e4835c81 | |||
|
|
9822343561 | ||
| 11eef4aa2a | |||
| 94f4b23a39 | |||
| 3a783727dc | |||
| 553fc578a6 | |||
| 87b59af8da | |||
| 6087990eaf | |||
|
|
67ea8bd695 | ||
| da26a21f7e | |||
| 90bc5771ae | |||
| 1c56d71d43 | |||
| 9bb13b486e | |||
| 349a7bb1e4 | |||
|
|
c6e6ec4823 | ||
|
|
11543db953 | ||
| 2fb934ba18 | |||
|
|
81d96b8d88 | ||
|
|
caff7035f7 | ||
|
|
1c197a7534 | ||
| dab4754e47 | |||
|
|
3886dbd3ba | ||
| 8a0a4552f7 | |||
| d3d7744402 | |||
| e09d03ceb8 | |||
| 2772aa3112 | |||
| c7479222da | |||
| ed00739b30 | |||
|
|
3cc774d7df | ||
| 4ed90d380d | |||
| 5d76c375c2 | |||
| 57b5f6821b | |||
| 4e30d6a2ba | |||
| 9a5f2b294d | |||
| 01138b3e1c | |||
| 28107b4050 | |||
| 3a32f5e4eb | |||
| 427c78ec37 | |||
| 57dd9492ef | |||
| 6f4df44a00 | |||
| ca51839082 | |||
| ffeab9cace | |||
| 06f03b53e5 | |||
| 33f305220b | |||
|
|
db9a93a58b | ||
| 36933b53cb | |||
| 05244b9207 | |||
|
|
dc7447cfbc | ||
| 1b02ea7c22 | |||
| fe83a3d371 | |||
| 6166043735 | |||
| c459297968 | |||
| 6cb7414dcc | |||
|
|
d0bcef4d40 | ||
| 1774b135b3 | |||
| 8ca737c62f | |||
| aa3e3470cd | |||
| f6671e0f16 | |||
| 12db52a8a7 | |||
| 15374276d5 | |||
| c86397f150 | |||
| c1e0998a5f | |||
|
|
9ff61b325f | ||
| fbec5c46c2 | |||
| a93d55f02b | |||
| d0692c3608 | |||
| f02eb473ca | |||
| f7cc758d33 | |||
| d5c01aabab | |||
| 90eee27ba7 | |||
| 0fee0cfd35 | |||
| bc3c7df00f | |||
| ccc8c5d5f4 | |||
| 6d613fe618 | |||
| c6c88f85a7 | |||
| f278ad4d17 | |||
| 372b2b5bf9 | |||
| 5d2df8077b | |||
| e6105ae8ea | |||
| 3b89c496c6 | |||
| 7fb1693717 | |||
| ce24190b1a | |||
| 3d8f907fa5 | |||
| e59d96735a | |||
| 35ff55822d | |||
| 0d011b8f42 | |||
| c1d9958c9f | |||
| 5cb63f9437 | |||
| 5dec7d7da7 | |||
| 875b2ef201 | |||
| f1b9860310 | |||
| 5469c50d90 | |||
|
|
bf67faa470 | ||
| 1b751efc5e | |||
| ff9ca523cd | |||
|
|
726004dd73 | ||
| 23bdaa184a | |||
| 2841a2349d | |||
| fd445e8246 | |||
| 21b41121db | |||
| ef20d599eb | |||
| 16bcd2ac25 | |||
| 1058375a90 |
189
.cursor/commands/svelte-task.md
Normal file
189
.cursor/commands/svelte-task.md
Normal 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
19
.cursor/mcp.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
27
.cursor/rules/svelte_rules.mdc
Normal file
27
.cursor/rules/svelte_rules.mdc
Normal 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.
|
||||
117
.cursor/rules/typescript_rules.mdc
Normal file
117
.cursor/rules/typescript_rules.mdc
Normal 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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -48,3 +48,4 @@ coverage
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
.eslintcache
|
||||
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
18
.prettierrc
Normal file
18
.prettierrc
Normal 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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
nodejs 25.0.0
|
||||
nodejs 22.21.1
|
||||
|
||||
29
.vscode/settings.json
vendored
Normal file
29
.vscode/settings.json
vendored
Normal 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
23
AGENTS.md
Normal 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.
|
||||
@@ -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"
|
||||
|
||||
186
RELATORIO_TESTES.md
Normal file
186
RELATORIO_TESTES.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Relatório de Testes - Sistema de Central de Chamados
|
||||
|
||||
**Data:** 16 de novembro de 2025
|
||||
**Testador:** Sistema Automatizado
|
||||
**Página Testada:** `/ti/central-chamados`
|
||||
|
||||
## Resumo Executivo
|
||||
|
||||
Foram realizados testes completos na página de Central de Chamados do sistema SGSE. A maioria das funcionalidades está funcionando corretamente, mas foram identificados alguns problemas que precisam ser corrigidos.
|
||||
|
||||
## Testes Realizados
|
||||
|
||||
### ✅ Testes Bem-Sucedidos
|
||||
|
||||
1. **Login no Sistema**
|
||||
- Status: ✅ PASSOU
|
||||
- Usuário logado: Deyvison (dfw@poli.br)
|
||||
|
||||
2. **Visualização de SLAs Configurados**
|
||||
- Status: ✅ PASSOU
|
||||
- Tabela de SLAs exibe 7 SLAs ativos corretamente
|
||||
- Resumo mostra: 4 Baixa, 2 Média, 1 Alta/Crítica
|
||||
- Detalhes completos (tempos, prioridades) são exibidos corretamente
|
||||
|
||||
3. **Cards de Prioridade**
|
||||
- Status: ✅ PASSOU
|
||||
- Cards mostram corretamente "Configurado" ou "Não configurado"
|
||||
- Botão "Configurar" funciona corretamente
|
||||
- Detalhes dos SLAs configurados são exibidos nos cards
|
||||
|
||||
4. **Criação de SLA**
|
||||
- Status: ✅ PASSOU
|
||||
- SLA criado com sucesso para prioridade "Alta"
|
||||
- Formulário preenche corretamente quando clica em "Configurar"
|
||||
- Tabela atualiza automaticamente após criação
|
||||
- Card de prioridade atualiza para "Configurado"
|
||||
|
||||
5. **Edição de SLA**
|
||||
- Status: ✅ PASSOU
|
||||
- Botão "Editar" abre formulário com dados corretos
|
||||
- Atualização funciona corretamente
|
||||
|
||||
6. **Lista de Chamados**
|
||||
- Status: ✅ PASSOU
|
||||
- 4 chamados sendo exibidos corretamente
|
||||
- Filtros funcionando (status, responsável, setor)
|
||||
- Detalhes do chamado são exibidos ao selecionar
|
||||
|
||||
7. **Atribuição de Responsável**
|
||||
- Status: ✅ PASSOU
|
||||
- Dropdown mostra 2 usuários TI: Deyvison e Suporte_TI
|
||||
- Formulário está funcional
|
||||
|
||||
8. **Prorrogação de Prazo**
|
||||
- Status: ✅ PASSOU
|
||||
- Dropdown de tickets carrega corretamente (4 tickets)
|
||||
- Formulário permite selecionar tipo de prazo e horas
|
||||
- Botão habilita quando todos os campos estão preenchidos
|
||||
|
||||
### ⚠️ Problemas Identificados
|
||||
|
||||
#### 1. Templates de Email - Listagem Após Criação
|
||||
|
||||
- **Status:** ⚠️ PROBLEMA
|
||||
- **Descrição:** Templates são criados com sucesso (mensagem "Templates padrão criados com sucesso" aparece), mas não são listados na interface após criação
|
||||
- **Ação Realizada:** Botão "Criar templates padrão" foi clicado e retornou sucesso
|
||||
- **Comportamento Esperado:** Templates deveriam aparecer em uma lista após criação
|
||||
- **Comportamento Atual:** Seção continua mostrando "Nenhum template encontrado"
|
||||
- **Severidade:** MÉDIA
|
||||
- **Impacto:** Usuários não conseguem visualizar/editar templates de email após criação
|
||||
- **Possível Causa:** Query de templates pode não estar sendo atualizada após criação, ou filtro pode estar excluindo templates de chamados
|
||||
|
||||
#### 2. Warning no Console - Token de Autenticação
|
||||
|
||||
- **Status:** ⚠️ AVISO (Não crítico)
|
||||
- **Descrição:** `⚠️ [useConvexWithAuth] Token não disponível` aparece no console durante carregamento inicial
|
||||
- **Severidade:** BAIXA
|
||||
- **Impacto:** Não afeta funcionalidade (autenticação funciona corretamente após carregamento)
|
||||
- **Observação:** Parece ser um problema de timing durante inicialização da página
|
||||
|
||||
#### 3. Warning no Console - Formato de Query
|
||||
|
||||
- **Status:** ⚠️ AVISO (Não crítico)
|
||||
- **Descrição:** `🔍 [usuariosTI] Formato inesperado: object {data: undefined, isLoading: undefined, error: undefined, isStale: undefined}` aparece no console
|
||||
- **Severidade:** BAIXA
|
||||
- **Impacto:** Não afeta funcionalidade (usuários são carregados corretamente - 2 usuários TI encontrados)
|
||||
- **Observação:** Indica possível inconsistência no formato de retorno da query durante carregamento inicial
|
||||
|
||||
## Detalhes dos Testes
|
||||
|
||||
### Teste de Criação de SLA
|
||||
|
||||
- **Prioridade Testada:** Alta
|
||||
- **Valores Inseridos:**
|
||||
- Nome: "SLA - Alta - Teste"
|
||||
- Tempo de Resposta: 2h
|
||||
- Tempo de Conclusão: 8h
|
||||
- Auto-encerramento: 24h
|
||||
- Alerta: 2h antes
|
||||
- **Resultado:** ✅ SLA criado e exibido na tabela e no card
|
||||
|
||||
### Teste de Edição de SLA
|
||||
|
||||
- **SLA Editado:** Prioridade Baixa
|
||||
- **Alterações:**
|
||||
- Nome: "SLA Baixa - Editado em Teste"
|
||||
- Tempo de Resposta: 6h
|
||||
- **Resultado:** ✅ Atualização bem-sucedida
|
||||
|
||||
### Teste de Prorrogação
|
||||
|
||||
- **Ticket Selecionado:** SGSE-202511-3750
|
||||
- **Prazo:** Conclusão
|
||||
- **Horas Adicionais:** 24h
|
||||
- **Motivo:** "Teste de prorrogação de prazo - necessário mais tempo para análise"
|
||||
- **Resultado:** ✅ Formulário preenchido corretamente, botão habilitado
|
||||
|
||||
## Lista de Erros Encontrados
|
||||
|
||||
### Erros Críticos
|
||||
|
||||
- **Nenhum erro crítico encontrado**
|
||||
|
||||
### Erros de Funcionalidade
|
||||
|
||||
1. **Templates de Email não aparecem após criação**
|
||||
- Localização: Seção "Templates de Email - Chamados"
|
||||
- Ação necessária: Verificar query de templates e atualização reativa após criação
|
||||
|
||||
### Avisos (Warnings)
|
||||
|
||||
1. **Token de autenticação não disponível durante carregamento inicial**
|
||||
- Localização: Console do navegador
|
||||
- Ação necessária: Melhorar timing de inicialização de autenticação
|
||||
|
||||
2. **Formato inesperado de query durante carregamento**
|
||||
- Localização: Console do navegador (usuariosTI)
|
||||
- Ação necessária: Verificar formato de retorno de useQuery do convex-svelte
|
||||
|
||||
## Recomendações
|
||||
|
||||
### Prioridade ALTA
|
||||
|
||||
1. **Corrigir listagem de templates de email após criação**
|
||||
- Verificar se a query `templatesChamados` está sendo atualizada após criação
|
||||
- Verificar se o filtro de templates está correto (deve incluir templates de chamados)
|
||||
- Adicionar refresh automático após criação de templates
|
||||
|
||||
### Prioridade MÉDIA
|
||||
|
||||
2. **Investigar e corrigir warnings no console**
|
||||
- Melhorar timing de autenticação para evitar warning inicial
|
||||
- Padronizar formato de retorno de queries do convex-svelte
|
||||
|
||||
### Prioridade BAIXA
|
||||
|
||||
3. **Melhorar logs de debug**
|
||||
- Reduzir verbosidade de logs informativos
|
||||
- Manter apenas logs de erro e warnings importantes
|
||||
|
||||
## Conclusão
|
||||
|
||||
O sistema está **funcionalmente operacional**, com a maioria das funcionalidades testadas funcionando corretamente:
|
||||
|
||||
✅ **Funcionalidades Testadas e Funcionando:**
|
||||
|
||||
- Login e autenticação
|
||||
- Visualização de SLAs (tabela e cards)
|
||||
- Criação de SLAs
|
||||
- Edição de SLAs
|
||||
- Lista de chamados
|
||||
- Atribuição de responsável
|
||||
- Prorrogação de prazo (formulário funcional)
|
||||
- Criação de templates (backend funciona, frontend não atualiza)
|
||||
|
||||
⚠️ **Problemas Identificados:**
|
||||
|
||||
- Templates não aparecem na lista após criação (problema de atualização reativa)
|
||||
- Warnings no console (não afetam funcionalidade)
|
||||
|
||||
**Status Geral:** ✅ **OPERACIONAL COM PEQUENOS AJUSTES NECESSÁRIOS**
|
||||
|
||||
**Próximos Passos:**
|
||||
|
||||
1. Corrigir atualização reativa de templates após criação
|
||||
2. Investigar e resolver warnings do console (opcional, não crítico)
|
||||
28
apps/web/eslint.config.js
Normal file
28
apps/web/eslint.config.js
Normal 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/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -18,3 +20,58 @@
|
||||
.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;
|
||||
}
|
||||
10
apps/web/src/app.d.ts
vendored
10
apps/web/src/app.d.ts
vendored
@@ -1,12 +1,8 @@
|
||||
// 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 {}
|
||||
interface Locals {
|
||||
token: string | undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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()],
|
||||
});
|
||||
|
||||
73
apps/web/src/lib/components/ActionGuard.svelte
Normal file
73
apps/web/src/lib/components/ActionGuard.svelte
Normal 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}
|
||||
276
apps/web/src/lib/components/AlterarStatusFerias.svelte
Normal file
276
apps/web/src/lib/components/AlterarStatusFerias.svelte
Normal 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>
|
||||
428
apps/web/src/lib/components/AprovarAusencias.svelte
Normal file
428
apps/web/src/lib/components/AprovarAusencias.svelte
Normal 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>
|
||||
521
apps/web/src/lib/components/AprovarFerias.svelte
Normal file
521
apps/web/src/lib/components/AprovarFerias.svelte
Normal 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>
|
||||
540
apps/web/src/lib/components/CalendarioAfastamentos.svelte
Normal file
540
apps/web/src/lib/components/CalendarioAfastamentos.svelte
Normal 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>
|
||||
73
apps/web/src/lib/components/ErrorModal.svelte
Normal file
73
apps/web/src/lib/components/ErrorModal.svelte
Normal 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}
|
||||
@@ -1,12 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
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;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
required?: boolean;
|
||||
onUpload: (file: globalThis.File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -15,52 +26,95 @@
|
||||
helpUrl,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
required = false,
|
||||
onUpload,
|
||||
onRemove,
|
||||
onRemove
|
||||
}: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient() as unknown as {
|
||||
storage: {
|
||||
getUrl: (id: string) => Promise<string | null>;
|
||||
};
|
||||
};
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileName = $state<string>("");
|
||||
let fileType = $state<string>("");
|
||||
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",
|
||||
];
|
||||
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);
|
||||
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 as any);
|
||||
const url = await client.storage.getUrl(storageId);
|
||||
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";
|
||||
if (url.includes('.pdf') || url.includes('application/pdf')) {
|
||||
fileType = 'application/pdf';
|
||||
} else {
|
||||
fileType = "image/jpeg";
|
||||
previewUrl = url; // Para imagens, a URL serve como preview
|
||||
fileType = 'image/jpeg';
|
||||
// Para imagens, a URL serve como preview
|
||||
previewUrl = url;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erro ao carregar arquivo existente:", err);
|
||||
console.error('Erro ao carregar arquivo existente:', err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,21 +122,23 @@
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
error = null;
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = "Arquivo muito grande. Tamanho máximo: 10MB";
|
||||
target.value = "";
|
||||
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 = "";
|
||||
error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -92,39 +148,52 @@
|
||||
fileType = file.type;
|
||||
|
||||
// Create preview for images
|
||||
if (file.type.startsWith("image/")) {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
previewUrl = e.target?.result as string;
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
previewUrl = result;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
previewUrl = null;
|
||||
}
|
||||
|
||||
await onUpload(file);
|
||||
|
||||
} catch (err: any) {
|
||||
error = err?.message || "Erro ao fazer upload do arquivo";
|
||||
} 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 = "";
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm("Tem certeza que deseja remover este arquivo?")) {
|
||||
if (!confirm('Tem certeza que deseja remover este arquivo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
await onRemove();
|
||||
fileName = "";
|
||||
fileType = "";
|
||||
fileName = '';
|
||||
fileType = '';
|
||||
previewUrl = null;
|
||||
fileUrl = null;
|
||||
} catch (err: any) {
|
||||
error = err?.message || "Erro ao remover arquivo";
|
||||
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;
|
||||
}
|
||||
@@ -139,24 +208,36 @@
|
||||
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">
|
||||
<span class="label-text font-medium flex items-center gap-2">
|
||||
<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}
|
||||
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>
|
||||
<ExternalLink class="h-4 w-4" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -164,8 +245,9 @@
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
use:setFileInput
|
||||
onchange={handleFileSelect}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="hidden"
|
||||
@@ -173,30 +255,28 @@
|
||||
/>
|
||||
|
||||
{#if value || fileName}
|
||||
<div class="flex items-center gap-2 p-3 border border-base-300 rounded-lg bg-base-100">
|
||||
<div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3">
|
||||
<!-- Preview -->
|
||||
<div class="flex-shrink-0">
|
||||
<div class="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>
|
||||
<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="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 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="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{fileName || "Arquivo anexado"}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
<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}
|
||||
@@ -215,10 +295,7 @@
|
||||
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>
|
||||
<Eye class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
@@ -228,9 +305,7 @@
|
||||
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>
|
||||
<RefreshCw class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -239,9 +314,7 @@
|
||||
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>
|
||||
<Trash2 class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,18 +329,15 @@
|
||||
<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>
|
||||
<Upload class="h-5 w-5" strokeWidth={2} />
|
||||
Selecionar arquivo (PDF ou imagem, máx. 10MB)
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<label class="label">
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{error}</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
187
apps/web/src/lib/components/FuncionarioSelect.svelte
Normal file
187
apps/web/src/lib/components/FuncionarioSelect.svelte
Normal 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>
|
||||
@@ -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) {
|
||||
// Re-verificar quando o status do usuário atual mudar
|
||||
verificarPermissoes();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -54,15 +52,15 @@
|
||||
});
|
||||
|
||||
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";
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
gerarDeclaracaoIdoneidade,
|
||||
gerarTermoNepotismo,
|
||||
gerarTermoOpcaoRemuneracao,
|
||||
downloadBlob
|
||||
downloadBlob,
|
||||
} from "$lib/utils/declaracoesGenerator";
|
||||
import { FileText, Info } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
funcionario?: any;
|
||||
@@ -18,10 +19,10 @@
|
||||
let generating = $state(false);
|
||||
|
||||
function baixarModelo(arquivoUrl: string, nomeModelo: string) {
|
||||
const link = document.createElement('a');
|
||||
const link = document.createElement("a");
|
||||
link.href = arquivoUrl;
|
||||
link.download = nomeModelo + '.pdf';
|
||||
link.target = '_blank';
|
||||
link.download = nomeModelo + ".pdf";
|
||||
link.target = "_blank";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -29,7 +30,7 @@
|
||||
|
||||
async function gerarPreenchido(modeloId: string) {
|
||||
if (!funcionario) {
|
||||
alert('Dados do funcionário não disponíveis');
|
||||
alert("Dados do funcionário não disponíveis");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,40 +40,40 @@
|
||||
let nomeArquivo: string;
|
||||
|
||||
switch (modeloId) {
|
||||
case 'acumulacao_cargo':
|
||||
case "acumulacao_cargo":
|
||||
blob = await gerarDeclaracaoAcumulacaoCargo(funcionario);
|
||||
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case 'dependentes_ir':
|
||||
case "dependentes_ir":
|
||||
blob = await gerarDeclaracaoDependentesIR(funcionario);
|
||||
nomeArquivo = `Declaracao_Dependentes_IR_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Declaracao_Dependentes_IR_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case 'idoneidade':
|
||||
case "idoneidade":
|
||||
blob = await gerarDeclaracaoIdoneidade(funcionario);
|
||||
nomeArquivo = `Declaracao_Idoneidade_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Declaracao_Idoneidade_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case 'nepotismo':
|
||||
case "nepotismo":
|
||||
blob = await gerarTermoNepotismo(funcionario);
|
||||
nomeArquivo = `Termo_Nepotismo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Termo_Nepotismo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case 'opcao_remuneracao':
|
||||
case "opcao_remuneracao":
|
||||
blob = await gerarTermoOpcaoRemuneracao(funcionario);
|
||||
nomeArquivo = `Termo_Opcao_Remuneracao_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Termo_Opcao_Remuneracao_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
default:
|
||||
alert('Modelo não encontrado');
|
||||
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');
|
||||
console.error("Erro ao gerar declaração:", error);
|
||||
alert("Erro ao gerar declaração preenchida");
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
@@ -82,38 +83,58 @@
|
||||
<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>
|
||||
<FileText class="h-5 w-5" strokeWidth={2} />
|
||||
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>
|
||||
<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>
|
||||
<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 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" />
|
||||
<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>
|
||||
<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">
|
||||
@@ -122,8 +143,19 @@
|
||||
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
|
||||
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>
|
||||
@@ -139,8 +171,19 @@
|
||||
<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
|
||||
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}
|
||||
@@ -155,8 +198,10 @@
|
||||
</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>
|
||||
<p>
|
||||
💡 Dica: Após preencher e assinar os documentos, faça upload na seção
|
||||
"Documentação Anexa"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script lang="ts">
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { maskCPF, maskCEP, maskPhone } from "$lib/utils/masks";
|
||||
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";
|
||||
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;
|
||||
@@ -29,22 +34,38 @@
|
||||
endereco: true,
|
||||
contato: true,
|
||||
cargo: true,
|
||||
bancario: 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 getLabelFromOptions(
|
||||
value: string | undefined,
|
||||
options: Array<{ value: string; label: string }>
|
||||
): string {
|
||||
if (!value) return '-';
|
||||
return options.find((opt) => opt.value === value)?.label || value;
|
||||
}
|
||||
|
||||
function getRegimeLabel(value?: string) {
|
||||
if (!value) return '-';
|
||||
return REGIME_LABELS[value] ?? value;
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
Object.keys(sections).forEach(key => {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = true;
|
||||
});
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
Object.keys(sections).forEach(key => {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = false;
|
||||
});
|
||||
}
|
||||
@@ -97,7 +118,9 @@
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 105, yPosition, { align: 'center' });
|
||||
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
yPosition += 12;
|
||||
|
||||
@@ -108,14 +131,22 @@
|
||||
['Matrícula', funcionario.matricula],
|
||||
['CPF', maskCPF(funcionario.cpf)],
|
||||
['RG', funcionario.rg],
|
||||
['Data Nascimento', funcionario.nascimento],
|
||||
['Data Nascimento', funcionario.nascimento]
|
||||
];
|
||||
|
||||
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 (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]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
@@ -123,7 +154,7 @@
|
||||
body: dadosPessoais,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -141,7 +172,7 @@
|
||||
body: filiacao,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -159,7 +190,7 @@
|
||||
body: naturalidade,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -170,15 +201,28 @@
|
||||
const documentosData: any[] = [];
|
||||
|
||||
if (funcionario.carteiraProfissionalNumero) {
|
||||
documentosData.push(['Cart. Profissional', `Nº ${funcionario.carteiraProfissionalNumero}${funcionario.carteiraProfissionalSerie ? ' - Série: ' + funcionario.carteiraProfissionalSerie : ''}`]);
|
||||
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 : ''}`]);
|
||||
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}`;
|
||||
if (funcionario.tituloEleitorSecao)
|
||||
titulo += ` - Seção: ${funcionario.tituloEleitorSecao}`;
|
||||
documentosData.push(['Título Eleitor', titulo]);
|
||||
}
|
||||
if (funcionario.pisNumero) documentosData.push(['PIS/PASEP', funcionario.pisNumero]);
|
||||
@@ -190,7 +234,7 @@
|
||||
body: documentosData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -200,9 +244,14 @@
|
||||
// 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.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]);
|
||||
if (funcionario.formacaoRegistro)
|
||||
formacaoData.push(['Registro Nº', funcionario.formacaoRegistro]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
@@ -210,7 +259,7 @@
|
||||
body: formacaoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -219,8 +268,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)]);
|
||||
if (funcionario.grupoSanguineo)
|
||||
saudeData.push(['Grupo Sanguíneo', funcionario.grupoSanguineo]);
|
||||
if (funcionario.fatorRH)
|
||||
saudeData.push(['Fator RH', getLabelFromOptions(funcionario.fatorRH, FATOR_RH_OPTIONS)]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
@@ -228,7 +279,7 @@
|
||||
body: saudeData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -240,7 +291,7 @@
|
||||
['Endereço', funcionario.endereco],
|
||||
['Cidade', funcionario.cidade],
|
||||
['UF', funcionario.uf],
|
||||
['CEP', maskCEP(funcionario.cep)],
|
||||
['CEP', maskCEP(funcionario.cep)]
|
||||
];
|
||||
|
||||
autoTable(doc, {
|
||||
@@ -249,7 +300,7 @@
|
||||
body: enderecoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -259,7 +310,7 @@
|
||||
if (sections.contato) {
|
||||
const contatoData: any[] = [
|
||||
['E-mail', funcionario.email],
|
||||
['Telefone', maskPhone(funcionario.telefone)],
|
||||
['Telefone', maskPhone(funcionario.telefone)]
|
||||
];
|
||||
|
||||
autoTable(doc, {
|
||||
@@ -268,7 +319,7 @@
|
||||
body: contatoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -283,23 +334,40 @@
|
||||
// Cargo e Vínculo
|
||||
if (sections.cargo) {
|
||||
const cargoData: any[] = [
|
||||
['Tipo', funcionario.simboloTipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'],
|
||||
[
|
||||
'Tipo',
|
||||
funcionario.simboloTipo === 'cargo_comissionado'
|
||||
? 'Cargo Comissionado'
|
||||
: 'Função Gratificada'
|
||||
]
|
||||
];
|
||||
|
||||
if (funcionario.simbolo) {
|
||||
cargoData.push(['Símbolo', funcionario.simbolo.nome]);
|
||||
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.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]);
|
||||
}
|
||||
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)]);
|
||||
cargoData.push([
|
||||
'Aposentado',
|
||||
getLabelFromOptions(funcionario.aposentado, APOSENTADO_OPTIONS)
|
||||
]);
|
||||
}
|
||||
|
||||
autoTable(doc, {
|
||||
@@ -308,7 +376,40 @@
|
||||
body: cargoData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
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;
|
||||
@@ -317,9 +418,13 @@
|
||||
// Dados Bancários
|
||||
if (sections.bancario && funcionario.contaBradescoNumero) {
|
||||
const bancarioData: any[] = [
|
||||
['Conta', `${funcionario.contaBradescoNumero}${funcionario.contaBradescoDV ? '-' + funcionario.contaBradescoDV : ''}`],
|
||||
[
|
||||
'Conta',
|
||||
`${funcionario.contaBradescoNumero}${funcionario.contaBradescoDV ? '-' + funcionario.contaBradescoDV : ''}`
|
||||
]
|
||||
];
|
||||
if (funcionario.contaBradescoAgencia) bancarioData.push(['Agência', funcionario.contaBradescoAgencia]);
|
||||
if (funcionario.contaBradescoAgencia)
|
||||
bancarioData.push(['Agência', funcionario.contaBradescoAgencia]);
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
@@ -327,7 +432,7 @@
|
||||
body: bancarioData,
|
||||
theme: 'grid',
|
||||
headStyles: { fillColor: [41, 128, 185], fontStyle: 'bold' },
|
||||
styles: { fontSize: 9 },
|
||||
styles: { fontSize: 9 }
|
||||
});
|
||||
|
||||
yPosition = (doc as any).lastAutoTable.finalY + 10;
|
||||
@@ -340,7 +445,9 @@
|
||||
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('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
|
||||
align: 'center'
|
||||
});
|
||||
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });
|
||||
}
|
||||
|
||||
@@ -365,29 +472,31 @@
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<div class="mb-6 flex gap-2">
|
||||
<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>
|
||||
<CheckCircle2 class="h-4 w-4" strokeWidth={2} />
|
||||
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>
|
||||
<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">
|
||||
<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} />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.dadosPessoais}
|
||||
/>
|
||||
<span class="label-text">Dados Pessoais</span>
|
||||
</label>
|
||||
|
||||
@@ -397,12 +506,20 @@
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox" class="checkbox checkbox-primary" bind:checked={sections.naturalidade} />
|
||||
<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} />
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.documentos}
|
||||
/>
|
||||
<span class="label-text">Documentos</span>
|
||||
</label>
|
||||
|
||||
@@ -431,6 +548,15 @@
|
||||
<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.financeiro}
|
||||
/>
|
||||
<span class="label-text">Dados Financeiros</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>
|
||||
@@ -439,17 +565,13 @@
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" onclick={onClose} disabled={generating}>
|
||||
Cancelar
|
||||
</button>
|
||||
<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}
|
||||
<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>
|
||||
<Printer class="h-5 w-5" strokeWidth={2} />
|
||||
Gerar PDF
|
||||
{/if}
|
||||
</button>
|
||||
@@ -460,4 +582,3 @@
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
<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 = "/"
|
||||
redirectTo = '/'
|
||||
}: {
|
||||
children: Snippet;
|
||||
requireAuth?: boolean;
|
||||
@@ -21,26 +20,51 @@
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
function checkAccess() {
|
||||
isChecking = true;
|
||||
|
||||
// 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)}`;
|
||||
// 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() {
|
||||
// Limpar timeout anterior se existir
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
// Se a query ainda está carregando (undefined), aguardar
|
||||
if (currentUser === undefined) {
|
||||
isChecking = true;
|
||||
hasAccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Marcar que já verificou pelo menos uma vez
|
||||
hasCheckedOnce = true;
|
||||
|
||||
// Se a query retornou dados, verificar autenticação
|
||||
if (currentUser?.data) {
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0 && authStore.usuario) {
|
||||
const hasRole = allowedRoles.includes(authStore.usuario.role.nome);
|
||||
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)}`;
|
||||
@@ -49,26 +73,59 @@
|
||||
}
|
||||
|
||||
// Verificar nível
|
||||
if (authStore.usuario && authStore.usuario.role.nivel > maxLevel) {
|
||||
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;
|
||||
}, 100);
|
||||
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="flex min-h-screen items-center justify-center">
|
||||
<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>
|
||||
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasAccess}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
|
||||
157
apps/web/src/lib/components/PushNotificationManager.svelte
Normal file
157
apps/web/src/lib/components/PushNotificationManager.svelte
Normal 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 -->
|
||||
@@ -1,89 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { goto } from "$app/navigation";
|
||||
import logo from "$lib/assets/logo_governo_PE.png";
|
||||
import type { Snippet } from "svelte";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import { loginModalStore } from "$lib/stores/loginModal.svelte";
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import NotificationBell from "$lib/components/chat/NotificationBell.svelte";
|
||||
import ChatWidget from "$lib/components/chat/ChatWidget.svelte";
|
||||
import PresenceManager from "$lib/components/chat/PresenceManager.svelte";
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
||||
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
|
||||
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from 'lucide-svelte';
|
||||
import { authClient } from '$lib/auth';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const convex = useConvexClient();
|
||||
|
||||
// Caminho atual da página
|
||||
const currentPath = $derived(page.url.pathname);
|
||||
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
// Função para obter a URL do avatar/foto do usuário
|
||||
const avatarUrlDoUsuario = $derived(() => {
|
||||
if (!currentUser.data) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (currentUser.data.fotoPerfilUrl) {
|
||||
return currentUser.data.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
if (currentUser.data.avatar) {
|
||||
return currentUser.data.avatar;
|
||||
}
|
||||
|
||||
// Fallback: gerar avatar baseado no nome
|
||||
return getAvatarUrl(currentUser.data.nome);
|
||||
});
|
||||
|
||||
// Função para gerar classes do menu ativo
|
||||
function getMenuClasses(isActive: boolean) {
|
||||
const baseClasses = "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105";
|
||||
const baseClasses =
|
||||
'group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105';
|
||||
|
||||
if (isActive) {
|
||||
return `${baseClasses} border-primary bg-primary text-white shadow-lg scale-105`;
|
||||
}
|
||||
|
||||
return `${baseClasses} border-primary/30 bg-gradient-to-br from-base-100 to-base-200 text-base-content hover:from-primary hover:to-primary/80 hover:text-white`;
|
||||
return `${baseClasses} border-primary/30 bg-linear-to-br from-base-100 to-base-200 text-base-content hover:from-primary hover:to-primary/80 hover:text-white`;
|
||||
}
|
||||
|
||||
// Função para gerar classes do botão "Solicitar Acesso"
|
||||
function getSolicitarClasses(isActive: boolean) {
|
||||
const baseClasses = "group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105";
|
||||
const baseClasses =
|
||||
'group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105';
|
||||
|
||||
if (isActive) {
|
||||
return `${baseClasses} border-success bg-success text-white shadow-lg scale-105`;
|
||||
}
|
||||
|
||||
return `${baseClasses} border-success/30 bg-gradient-to-br from-success/10 to-success/20 text-base-content hover:from-success hover:to-success/80 hover:text-white`;
|
||||
return `${baseClasses} border-success/30 bg-linear-to-br from-success/10 to-success/20 text-base-content hover:from-success hover:to-success/80 hover:text-white`;
|
||||
}
|
||||
|
||||
const setores = [
|
||||
{ nome: "Recursos Humanos", link: "/recursos-humanos" },
|
||||
{ nome: "Financeiro", link: "/financeiro" },
|
||||
{ nome: "Controladoria", link: "/controladoria" },
|
||||
{ nome: "Licitações", link: "/licitacoes" },
|
||||
{ nome: "Compras", link: "/compras" },
|
||||
{ nome: "Jurídico", link: "/juridico" },
|
||||
{ nome: "Comunicação", link: "/comunicacao" },
|
||||
{ nome: "Programas Esportivos", link: "/programas-esportivos" },
|
||||
{ nome: "Secretaria Executiva", link: "/secretaria-executiva" },
|
||||
{ nome: 'Recursos Humanos', link: '/recursos-humanos' },
|
||||
{ nome: 'Financeiro', link: '/financeiro' },
|
||||
{ nome: 'Controladoria', link: '/controladoria' },
|
||||
{ nome: 'Licitações', link: '/licitacoes' },
|
||||
{ nome: 'Compras', link: '/compras' },
|
||||
{ nome: 'Jurídico', link: '/juridico' },
|
||||
{ nome: 'Comunicação', link: '/comunicacao' },
|
||||
{ nome: 'Programas Esportivos', link: '/programas-esportivos' },
|
||||
{ nome: 'Secretaria Executiva', link: '/secretaria-executiva' },
|
||||
{
|
||||
nome: "Secretaria de Gestão de Pessoas",
|
||||
link: "/gestao-pessoas",
|
||||
nome: 'Secretaria de Gestão de Pessoas',
|
||||
link: '/gestao-pessoas'
|
||||
},
|
||||
{ nome: "Tecnologia da Informação", link: "/ti" },
|
||||
{ nome: 'Tecnologia da Informação', link: '/ti' }
|
||||
];
|
||||
|
||||
let showAboutModal = $state(false);
|
||||
let matricula = $state("");
|
||||
let senha = $state("");
|
||||
let erroLogin = $state("");
|
||||
let matricula = $state('');
|
||||
let senha = $state('');
|
||||
let erroLogin = $state('');
|
||||
let carregandoLogin = $state(false);
|
||||
|
||||
// Sincronizar com o store global
|
||||
$effect(() => {
|
||||
if (loginModalStore.showModal && !matricula && !senha) {
|
||||
matricula = "";
|
||||
senha = "";
|
||||
erroLogin = "";
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
}
|
||||
});
|
||||
|
||||
function openLoginModal() {
|
||||
loginModalStore.open();
|
||||
matricula = "";
|
||||
senha = "";
|
||||
erroLogin = "";
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
carregandoLogin = false;
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
loginModalStore.close();
|
||||
matricula = "";
|
||||
senha = "";
|
||||
erroLogin = "";
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
carregandoLogin = false;
|
||||
}
|
||||
|
||||
function openAboutModal() {
|
||||
@@ -96,156 +119,187 @@
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
erroLogin = "";
|
||||
erroLogin = '';
|
||||
carregandoLogin = true;
|
||||
|
||||
try {
|
||||
const resultado = await convex.mutation(api.autenticacao.login, {
|
||||
matriculaOuEmail: matricula.trim(),
|
||||
senha: senha,
|
||||
});
|
||||
// const browserInfo = await getBrowserInfo();
|
||||
|
||||
if (resultado.sucesso) {
|
||||
authStore.login(resultado.usuario, resultado.token);
|
||||
const result = await authClient.signIn.email(
|
||||
{ email: matricula.trim(), password: senha },
|
||||
{
|
||||
onError: (ctx) => {
|
||||
alert(ctx.error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.data) {
|
||||
closeLoginModal();
|
||||
|
||||
// Redirecionar baseado no role
|
||||
if (resultado.usuario.role.nome === "ti" || resultado.usuario.role.nivel === 0) {
|
||||
goto("/ti/painel-administrativo");
|
||||
} else if (resultado.usuario.role.nome === "rh") {
|
||||
goto("/recursos-humanos");
|
||||
goto(resolve('/'));
|
||||
} else {
|
||||
goto("/");
|
||||
erroLogin = 'Erro ao fazer login';
|
||||
}
|
||||
} else {
|
||||
erroLogin = resultado.erro || "Erro ao fazer login";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer login:", error);
|
||||
erroLogin = "Erro ao conectar com o servidor. Tente novamente.";
|
||||
} finally {
|
||||
carregandoLogin = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
if (authStore.token) {
|
||||
try {
|
||||
await convex.mutation(api.autenticacao.logout, {
|
||||
token: authStore.token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer logout:", error);
|
||||
const result = await authClient.signOut();
|
||||
if (result.error) {
|
||||
console.error('Sign out error:', result.error);
|
||||
}
|
||||
}
|
||||
authStore.logout();
|
||||
goto("/");
|
||||
goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header Fixo acima de tudo -->
|
||||
<div class="navbar bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm shadow-lg border-b border-primary/10 px-6 lg:px-8 fixed top-0 left-0 right-0 z-50 min-h-24">
|
||||
<div
|
||||
class="navbar from-primary/30 via-primary/20 to-primary/30 border-primary/10 fixed top-0 right-0 left-0 z-50 min-h-24 border-b bg-linear-to-r px-6 shadow-lg backdrop-blur-sm lg:px-8"
|
||||
>
|
||||
<div class="flex-none lg:hidden">
|
||||
<label for="my-drawer-3" class="btn btn-square btn-ghost hover:bg-primary/20">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block w-6 h-6 stroke-current"
|
||||
<label
|
||||
for="my-drawer-3"
|
||||
class="group relative flex h-14 w-14 cursor-pointer items-center justify-center overflow-hidden rounded-2xl 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);"
|
||||
aria-label="Abrir menu"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path>
|
||||
</svg>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
|
||||
<!-- Ícone de menu hambúrguer -->
|
||||
<Menu
|
||||
class="relative z-10 h-7 w-7 text-white transition-transform duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center gap-4 lg:gap-6">
|
||||
<div class="flex flex-1 items-center gap-4 lg:gap-6">
|
||||
<!-- Logo MODERNO do Governo -->
|
||||
<div class="avatar">
|
||||
<div class="w-16 lg:w-20 rounded-lg shadow-md bg-white p-2">
|
||||
<img src={logo} alt="Logo do Governo de PE" class="w-full h-full object-contain" />
|
||||
<div
|
||||
class="group relative w-16 overflow-hidden rounded-2xl p-2 shadow-xl transition-all duration-300 hover:scale-105 lg:w-20"
|
||||
style="background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); border: 2px solid rgba(102, 126, 234, 0.1);"
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="from-primary/5 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
|
||||
<!-- Logo -->
|
||||
<img
|
||||
src={logo}
|
||||
alt="Logo do Governo de PE"
|
||||
class="relative z-10 h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.08));"
|
||||
/>
|
||||
|
||||
<!-- Brilho sutil no canto -->
|
||||
<div
|
||||
class="absolute top-0 right-0 h-8 w-8 rounded-bl-full bg-linear-to-br from-white/40 to-transparent opacity-70"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-xl lg:text-3xl font-bold text-primary tracking-tight">SGSE</h1>
|
||||
<p class="text-xs lg:text-base text-base-content/80 hidden sm:block font-medium leading-tight">
|
||||
Sistema de Gerenciamento da<br class="lg:hidden" /> Secretaria de Esportes
|
||||
<h1 class="text-primary text-xl font-bold tracking-tight lg:text-3xl">SGSE</h1>
|
||||
<p
|
||||
class="text-base-content/80 hidden text-xs leading-tight font-medium sm:block lg:text-base"
|
||||
>
|
||||
Sistema de Gerenciamento de Secretaria
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none flex items-center gap-4">
|
||||
{#if authStore.autenticado}
|
||||
<!-- Sino de notificações -->
|
||||
<div class="ml-auto flex flex-none items-center gap-4">
|
||||
{#if currentUser.data}
|
||||
<!-- Sino de notificações no canto superior direito -->
|
||||
<div class="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:flex flex-col items-end">
|
||||
<span class="text-sm font-semibold text-primary">{authStore.usuario?.nome}</span>
|
||||
<span class="text-xs text-base-content/60">{authStore.usuario?.role.nome}</span>
|
||||
<div class="mr-2 hidden flex-col items-end lg:flex">
|
||||
<span class="text-primary text-sm font-semibold">{currentUser.data.nome}</span>
|
||||
<span class="text-base-content/60 text-xs">{currentUser.data.role?.nome}</span>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<!-- Botão de Perfil ULTRA MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="btn btn-primary btn-circle btn-lg shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-105"
|
||||
class="group relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl 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);"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></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>
|
||||
|
||||
<!-- Avatar/Foto do usuário ou ícone padrão -->
|
||||
{#if avatarUrlDoUsuario()}
|
||||
<img
|
||||
src={avatarUrlDoUsuario()}
|
||||
alt={currentUser.data?.nome || 'Usuário'}
|
||||
class="relative z-10 h-full w-full object-cover"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Ícone de usuário moderno (fallback) -->
|
||||
<User
|
||||
class="relative z-10 h-7 w-7 text-white transition-transform duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Badge de status online -->
|
||||
<div
|
||||
class="bg-success absolute top-1 right-1 z-20 h-3 w-3 rounded-full border-2 border-white shadow-lg"
|
||||
style="animation: pulse-dot 2s ease-in-out infinite;"
|
||||
></div>
|
||||
</button>
|
||||
<ul tabindex="0" class="dropdown-content z-[1] menu p-2 shadow-xl bg-base-100 rounded-box w-52 mt-4 border border-primary/20">
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-primary/20 z-1 mt-4 w-52 border p-2 shadow-xl"
|
||||
>
|
||||
<li class="menu-title">
|
||||
<span class="text-primary font-bold">{authStore.usuario?.nome}</span>
|
||||
<span class="text-primary font-bold">{currentUser.data?.nome}</span>
|
||||
</li>
|
||||
<li><a href="/perfil">Meu Perfil</a></li>
|
||||
<li><a href="/alterar-senha">Alterar Senha</a></li>
|
||||
<li><a href={resolve('/perfil')}>Meu Perfil</a></li>
|
||||
<li><a href={resolve('/alterar-senha')}>Alterar Senha</a></li>
|
||||
<div class="divider my-0"></div>
|
||||
<li><button type="button" onclick={handleLogout} class="text-error">Sair</button></li>
|
||||
<li>
|
||||
<button type="button" onclick={handleLogout} class="text-error">Sair</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg shadow-2xl hover:shadow-primary/30 transition-all duration-500 hover:scale-110 group relative overflow-hidden border-0 bg-gradient-to-br from-primary via-primary to-primary/80 hover:from-primary/90 hover:via-primary/80 hover:to-primary/70"
|
||||
class="btn btn-lg hover:shadow-primary/30 group from-primary via-primary to-primary/80 hover:from-primary/90 hover:via-primary/80 hover:to-primary/70 relative overflow-hidden border-0 bg-linear-to-br shadow-2xl transition-all duration-500 hover:scale-110"
|
||||
style="width: 4rem; height: 4rem; border-radius: 9999px;"
|
||||
onclick={() => openLoginModal()}
|
||||
aria-label="Login"
|
||||
>
|
||||
<!-- Efeito de brilho animado -->
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-white/30 to-transparent -translate-x-full group-hover:translate-x-full transition-transform duration-1000"></div>
|
||||
<div
|
||||
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
|
||||
></div>
|
||||
|
||||
<!-- Anel pulsante de fundo -->
|
||||
<div class="absolute inset-0 rounded-full bg-white/10 group-hover:animate-ping"></div>
|
||||
|
||||
<!-- Ícone de login premium -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 relative z-10 text-white group-hover:scale-110 transition-all duration-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
<User
|
||||
class="relative z-10 h-8 w-8 text-white transition-all duration-500 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -253,62 +307,61 @@
|
||||
|
||||
<div class="drawer lg:drawer-open" style="margin-top: 96px;">
|
||||
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col lg:ml-72" style="height: calc(100vh - 96px);">
|
||||
<div class="drawer-content flex flex-col lg:ml-72" style="min-height: calc(100vh - 96px);">
|
||||
<!-- Page content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer footer-center bg-gradient-to-r from-primary/30 via-primary/20 to-primary/30 backdrop-blur-sm text-base-content p-6 border-t-2 border-primary/20 shadow-inner mt-8">
|
||||
<footer
|
||||
class="footer footer-center from-primary/30 via-primary/20 to-primary/30 text-base-content border-primary/20 shrink-0 border-t-2 bg-linear-to-r p-6 shadow-inner backdrop-blur-sm"
|
||||
>
|
||||
<div class="grid grid-flow-col gap-6 text-sm font-medium">
|
||||
<button type="button" class="link link-hover hover:text-primary transition-colors" onclick={() => openAboutModal()}>Sobre</button>
|
||||
<button
|
||||
type="button"
|
||||
class="link link-hover hover:text-primary transition-colors"
|
||||
onclick={() => openAboutModal()}>Sobre</button
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Contato</a>
|
||||
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors"
|
||||
>Contato</a
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Suporte</a>
|
||||
<a href={resolve('/abrir-chamado')} class="link link-hover hover:text-primary transition-colors"
|
||||
>Suporte</a
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href="/" class="link link-hover hover:text-primary transition-colors">Privacidade</a>
|
||||
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors"
|
||||
>Privacidade</a
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 mt-2">
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
|
||||
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
|
||||
<img src={logo} alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-xs font-bold text-primary">Governo do Estado de Pernambuco</p>
|
||||
<p class="text-xs text-base-content/70">Secretaria de Esportes</p>
|
||||
<p class="text-primary text-xs font-bold">Governo do Estado de Pernambuco</p>
|
||||
<p class="text-base-content/70 text-xs">Secretaria de Esportes</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-2">© {new Date().getFullYear()} - Todos os direitos reservados</p>
|
||||
<p class="text-base-content/60 mt-2 text-xs">
|
||||
© {new Date().getFullYear()} - Todos os direitos reservados
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="drawer-side z-40 fixed" style="margin-top: 96px;">
|
||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"
|
||||
></label>
|
||||
<div class="menu bg-gradient-to-b from-primary/25 to-primary/15 backdrop-blur-sm w-72 p-4 flex flex-col gap-2 h-[calc(100vh-96px)] overflow-y-auto border-r-2 border-primary/20 shadow-xl">
|
||||
<div class="drawer-side fixed z-40" style="margin-top: 96px;">
|
||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<div
|
||||
class="menu from-primary/25 to-primary/15 border-primary/20 flex h-[calc(100vh-96px)] w-72 flex-col gap-2 overflow-y-auto border-r-2 bg-linear-to-b p-4 shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<!-- Sidebar menu items -->
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li class="rounded-xl">
|
||||
<a
|
||||
href="/"
|
||||
class={getMenuClasses(currentPath === "/")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 group-hover:scale-110 transition-transform"
|
||||
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>
|
||||
<a href={resolve('/')} class={getMenuClasses(currentPath === '/')}>
|
||||
<Home class="h-5 w-5 transition-transform group-hover:scale-110" strokeWidth={2} />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
@@ -316,34 +369,21 @@
|
||||
{@const isActive = currentPath.startsWith(s.link)}
|
||||
<li class="rounded-xl">
|
||||
<a
|
||||
href={s.link}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
href={resolve(s.link)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class={getMenuClasses(isActive)}
|
||||
>
|
||||
<span>{s.nome}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="rounded-xl mt-auto">
|
||||
<li class="mt-auto rounded-xl">
|
||||
<a
|
||||
href="/solicitar-acesso"
|
||||
class={getSolicitarClasses(currentPath === "/solicitar-acesso")}
|
||||
href={resolve('/abrir-chamado')}
|
||||
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||
>
|
||||
<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="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>
|
||||
<span>Solicitar acesso</span>
|
||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||
<span>Abrir Chamado</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -354,99 +394,146 @@
|
||||
<!-- Modal de Login -->
|
||||
{#if loginModalStore.showModal}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box relative overflow-hidden bg-base-100 max-w-md">
|
||||
<div
|
||||
class="modal-box from-base-100 via-base-100 to-primary/5 relative max-w-md overflow-hidden bg-gradient-to-br shadow-2xl backdrop-blur-sm"
|
||||
>
|
||||
<!-- Botão de fechar moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-4 right-4 z-10 hover:bg-error/20 hover:text-error transition-all duration-200"
|
||||
onclick={closeLoginModal}
|
||||
aria-label="Fechar modal"
|
||||
>
|
||||
✕
|
||||
<XCircle class="h-5 w-5" strokeWidth={2.5} />
|
||||
</button>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="text-center mb-6">
|
||||
<div class="avatar mb-4">
|
||||
<div class="w-20 rounded-lg bg-primary/10 p-3">
|
||||
<img src={logo} alt="Logo" class="w-full h-full object-contain" />
|
||||
<!-- Decoração de fundo -->
|
||||
<div
|
||||
class="absolute -top-20 -right-20 h-40 w-40 rounded-full bg-primary/10 blur-3xl"
|
||||
></div>
|
||||
<div
|
||||
class="absolute -bottom-20 -left-20 h-40 w-40 rounded-full bg-primary/5 blur-3xl"
|
||||
></div>
|
||||
|
||||
<div class="relative z-10 p-8">
|
||||
<!-- Header com logo e título -->
|
||||
<div class="mb-8 text-center">
|
||||
<div class="avatar mb-5 mx-auto">
|
||||
<div
|
||||
class="group relative w-24 overflow-hidden rounded-2xl bg-white p-4 shadow-xl ring-2 ring-primary/20 transition-all duration-300 hover:scale-105 hover:shadow-2xl"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
<img
|
||||
src={logo}
|
||||
alt="Logo SGSE"
|
||||
class="relative z-10 h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="font-bold text-3xl text-primary">Login</h3>
|
||||
<p class="text-sm text-base-content/60 mt-2">Acesse o sistema com suas credenciais</p>
|
||||
<h3 class="text-primary mb-2 text-4xl font-bold tracking-tight">Login</h3>
|
||||
<p class="text-base-content/70 text-sm font-medium">
|
||||
Acesse o sistema com suas credenciais
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Mensagem de erro -->
|
||||
{#if erroLogin}
|
||||
<div class="alert alert-error mb-4">
|
||||
<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="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{erroLogin}</span>
|
||||
<div
|
||||
class="alert alert-error mb-6 border-error/30 bg-error/10 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2.5} />
|
||||
<span class="font-medium">{erroLogin}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleLogin}>
|
||||
<!-- Formulário -->
|
||||
<form class="space-y-5" onsubmit={handleLogin}>
|
||||
<!-- Campo Matrícula/E-mail -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="login-matricula">
|
||||
<span class="label-text font-semibold">Matrícula ou E-mail</span>
|
||||
<label class="label pb-2" for="login-matricula">
|
||||
<span class="text-primary label-text text-sm font-semibold"
|
||||
>Matrícula ou E-mail</span
|
||||
>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-matricula"
|
||||
type="text"
|
||||
placeholder="Digite sua matrícula ou e-mail"
|
||||
class="input input-bordered input-primary w-full"
|
||||
class="input input-bordered input-primary w-full border-2 transition-all duration-200 focus:border-primary focus:shadow-lg focus:shadow-primary/20 disabled:opacity-50"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campo Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="login-password">
|
||||
<span class="label-text font-semibold">Senha</span>
|
||||
<label class="label pb-2" for="login-password">
|
||||
<span class="text-primary label-text text-sm font-semibold">Senha</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
class="input input-bordered input-primary w-full"
|
||||
class="input input-bordered input-primary w-full border-2 transition-all duration-200 focus:border-primary focus:shadow-lg focus:shadow-primary/20 disabled:opacity-50"
|
||||
bind:value={senha}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mt-6">
|
||||
</div>
|
||||
|
||||
<!-- Botão de submit -->
|
||||
<div class="form-control pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary w-full"
|
||||
class="btn btn-primary btn-lg group relative w-full overflow-hidden border-0 bg-gradient-to-r from-primary via-primary to-primary/90 shadow-xl transition-all duration-300 hover:scale-[1.02] hover:shadow-2xl disabled:opacity-50"
|
||||
disabled={carregandoLogin}
|
||||
>
|
||||
<!-- Efeito de brilho animado -->
|
||||
<div
|
||||
class="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
|
||||
></div>
|
||||
|
||||
{#if carregandoLogin}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Entrando...
|
||||
<span class="font-semibold">Entrando...</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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Entrar
|
||||
<LogIn class="h-5 w-5 transition-transform duration-300 group-hover:scale-110" strokeWidth={2.5} />
|
||||
<span class="font-semibold">Entrar</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-center mt-4 space-y-2">
|
||||
<a href="/solicitar-acesso" class="link link-primary text-sm block" onclick={closeLoginModal}>
|
||||
Não tem acesso? Solicite aqui
|
||||
|
||||
<!-- Links auxiliares -->
|
||||
<div class="pt-4 space-y-3 text-center">
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class="link link-primary block text-sm font-medium transition-all duration-200 hover:scale-105"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Abrir Chamado
|
||||
</a>
|
||||
<a href="/esqueci-senha" class="link link-secondary text-sm block" onclick={closeLoginModal}>
|
||||
<a
|
||||
href={resolve('/esqueci-senha')}
|
||||
class="link link-secondary block text-sm font-medium transition-all duration-200 hover:scale-105"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Esqueceu sua senha?
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider text-xs text-base-content/40">Credenciais de teste</div>
|
||||
<div class="bg-base-200 p-3 rounded-lg text-xs">
|
||||
<p class="font-semibold mb-1">Admin:</p>
|
||||
<p>Matrícula: <code class="bg-base-300 px-2 py-1 rounded">0000</code></p>
|
||||
<p>Senha: <code class="bg-base-300 px-2 py-1 rounded">Admin@123</code></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
@@ -456,97 +543,123 @@
|
||||
<!-- Modal Sobre -->
|
||||
{#if showAboutModal}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box max-w-2xl relative overflow-hidden bg-gradient-to-br from-base-100 to-base-200">
|
||||
<div
|
||||
class="modal-box from-base-100 to-base-200 relative max-w-md overflow-hidden bg-gradient-to-br shadow-xl"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2 z-10 hover:bg-base-300"
|
||||
onclick={closeAboutModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="text-center space-y-6 py-4">
|
||||
<div class="space-y-5 px-6 py-6 text-center">
|
||||
<!-- Logo e Título -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-xl bg-white p-3 shadow-lg">
|
||||
<img src={logo} alt="Logo SGSE" class="w-full h-full object-contain" />
|
||||
<div class="w-20 rounded-xl bg-white p-3 shadow-lg ring-2 ring-primary/20">
|
||||
<img src={logo} alt="Logo SGSE" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-3xl font-bold text-primary mb-2">SGSE</h3>
|
||||
<p class="text-lg font-semibold text-base-content/80">
|
||||
Sistema de Gerenciamento da<br />Secretaria de Esportes
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-primary text-2xl font-bold tracking-tight">SGSE</h3>
|
||||
<p class="text-base-content/70 text-sm font-medium">
|
||||
Sistema de Gerenciamento de Secretaria
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-1"></div>
|
||||
|
||||
<!-- Informações de Versão -->
|
||||
<div class="bg-primary/10 rounded-xl p-6 space-y-3">
|
||||
<div class="bg-gradient-to-br from-primary/10 to-primary/5 space-y-2 rounded-xl border border-primary/10 p-4 shadow-sm">
|
||||
<div class="flex items-center justify-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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-sm font-medium text-base-content/70">Versão</p>
|
||||
<Tag class="text-primary h-4 w-4" strokeWidth={2} />
|
||||
<p class="text-base-content/60 text-xs font-medium uppercase tracking-wide">Versão</p>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-primary">1.0 26_2025</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>
|
||||
<p class="text-primary text-2xl font-bold tracking-tight">1.0 11_2025</p>
|
||||
<div class="badge badge-warning badge-sm gap-1.5 px-3 py-1.5 text-xs">
|
||||
<Plus class="h-3.5 w-3.5" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desenvolvido por -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium text-base-content/60">Desenvolvido por</p>
|
||||
<p class="text-lg font-bold text-primary">
|
||||
Secretaria de Esportes de Pernambuco
|
||||
</p>
|
||||
<div class="space-y-1.5">
|
||||
<p class="text-base-content/50 text-xs font-medium uppercase tracking-wide">Desenvolvido por</p>
|
||||
<p class="text-primary text-sm font-semibold">Secretaria de Esportes de Pernambuco</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-1"></div>
|
||||
|
||||
<!-- Informações Adicionais -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<p class="font-semibold text-primary">Governo</p>
|
||||
<p class="text-xs text-base-content/70">Estado de Pernambuco</p>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="bg-base-200/60 rounded-lg border border-base-300/50 p-3 shadow-sm transition-all hover:shadow-md">
|
||||
<p class="text-primary mb-1 text-xs font-semibold uppercase tracking-wide">Governo</p>
|
||||
<p class="text-base-content/60 text-xs font-medium">Estado de Pernambuco</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<p class="font-semibold text-primary">Ano</p>
|
||||
<p class="text-xs text-base-content/70">2025</p>
|
||||
<div class="bg-base-200/60 rounded-lg border border-base-300/50 p-3 shadow-sm transition-all hover:shadow-md">
|
||||
<p class="text-primary mb-1 text-xs font-semibold uppercase tracking-wide">Ano</p>
|
||||
<p class="text-base-content/60 text-xs font-medium">2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão OK -->
|
||||
<div class="pt-4">
|
||||
<div class="pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-lg w-full max-w-xs mx-auto shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
class="btn btn-primary btn-sm mx-auto w-full max-w-xs shadow-md transition-all duration-200 hover:shadow-lg"
|
||||
onclick={closeAboutModal}
|
||||
>
|
||||
<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>
|
||||
<Check class="h-4 w-4" strokeWidth={2} />
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop" onclick={closeAboutModal} role="button" tabindex="0" onkeydown={(e) => e.key === 'Escape' && closeAboutModal()}>
|
||||
</div>
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={closeAboutModal}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Escape' && closeAboutModal()}
|
||||
></div>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<!-- Componentes de Chat (apenas se autenticado) -->
|
||||
{#if authStore.autenticado}
|
||||
{#if currentUser.data}
|
||||
<PresenceManager />
|
||||
<ChatWidget />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Animação de pulso sutil para o anel do botão de perfil */
|
||||
@keyframes pulse-ring-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animação de pulso para o badge de status online */
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
353
apps/web/src/lib/components/SolicitarFerias.svelte
Normal file
353
apps/web/src/lib/components/SolicitarFerias.svelte
Normal 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>
|
||||
1002
apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte
Normal file
1002
apps/web/src/lib/components/ausencias/CalendarioAusencias.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,486 @@
|
||||
<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';
|
||||
|
||||
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 Date();
|
||||
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-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
>
|
||||
<div class="card-body">
|
||||
<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">
|
||||
<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>
|
||||
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal 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>
|
||||
|
||||
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal 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>
|
||||
|
||||
308
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
308
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal 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>
|
||||
|
||||
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal 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>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import NewConversationModal from "./NewConversationModal.svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -15,29 +16,84 @@
|
||||
// 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 [];
|
||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
||||
|
||||
// Filtrar o próprio usuário da lista
|
||||
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuPerfil.data._id);
|
||||
// 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) =>
|
||||
listaFiltrada = listaFiltrada.filter(
|
||||
(u: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.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, {
|
||||
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,24 +227,92 @@
|
||||
</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>
|
||||
<!-- 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 -->
|
||||
<!-- 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="2"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4 mr-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
Nova Conversa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de conteúdo -->
|
||||
<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"
|
||||
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 flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
@@ -136,7 +321,10 @@
|
||||
/>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,12 +334,16 @@
|
||||
<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'
|
||||
}">
|
||||
<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>
|
||||
@@ -170,7 +362,9 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhum usuário encontrado -->
|
||||
<div class="flex flex-col items-center justify-center h-full text-center px-4">
|
||||
<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"
|
||||
@@ -188,7 +382,133 @@
|
||||
<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
@@ -1,12 +1,16 @@
|
||||
<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';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
@@ -14,156 +18,476 @@
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
let showScheduleModal = $state(false);
|
||||
let showSalaManager = $state(false);
|
||||
let showAdminMenu = $state(false);
|
||||
let showNotificacaoModal = $state(false);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
|
||||
const conversa = $derived(() => {
|
||||
if (!conversas) return null;
|
||||
return conversas.find((c: any) => c._id === conversaId);
|
||||
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") {
|
||||
return c.nome || "Grupo sem nome";
|
||||
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";
|
||||
return c.outroUsuario?.nome || 'Usuário';
|
||||
}
|
||||
|
||||
function getAvatarConversa(): string {
|
||||
const c = conversa();
|
||||
if (!c) return "💬";
|
||||
if (c.tipo === "grupo") {
|
||||
return c.avatar || "👥";
|
||||
if (!c) return '💬';
|
||||
if (c.tipo === 'grupo') {
|
||||
return c.avatar || '👥';
|
||||
}
|
||||
if (c.outroUsuario?.avatar) {
|
||||
return c.outroUsuario.avatar;
|
||||
}
|
||||
return "👤";
|
||||
return '👤';
|
||||
}
|
||||
|
||||
function getStatusConversa(): any {
|
||||
function getStatusConversa(): 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao' | null {
|
||||
const c = conversa();
|
||||
if (c && c.tipo === "individual" && c.outroUsuario) {
|
||||
return c.outroUsuario.statusPresenca || "offline";
|
||||
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) {
|
||||
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">
|
||||
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-3 px-4 py-3 border-b border-base-300 bg-base-200">
|
||||
<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-ghost btn-sm btn-circle"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
<div class="relative shrink-0">
|
||||
{#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario}
|
||||
<UserAvatar
|
||||
avatar={conversa()?.outroUsuario?.avatar}
|
||||
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 bottom-0 right-0">
|
||||
<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"}
|
||||
<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 if participante.avatar}
|
||||
<img
|
||||
src={getAvatarUrl(participante.avatar)}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={getAvatarUrl(participante.nome)}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{/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 -->
|
||||
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
|
||||
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
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}
|
||||
|
||||
<!-- 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}
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<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"
|
||||
<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}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as any} />
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as Id<'conversas'>} />
|
||||
</div>
|
||||
|
||||
<!-- Input -->
|
||||
<div class="border-t border-base-300">
|
||||
<MessageInput conversaId={conversaId as any} />
|
||||
<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}
|
||||
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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
try {
|
||||
enviando = true;
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
// 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;
|
||||
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"
|
||||
<!-- Ícone de clipe moderno -->
|
||||
<Paperclip
|
||||
class="w-5 h-5 text-primary relative z-10 group-hover:scale-110 transition-transform"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</svg>
|
||||
{/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>
|
||||
</div>
|
||||
|
||||
<!-- Botão de enviar -->
|
||||
<!-- 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="btn btn-primary btn-circle flex-shrink-0"
|
||||
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 MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
<!-- Í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"
|
||||
/>
|
||||
</svg>
|
||||
{/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>
|
||||
|
||||
|
||||
@@ -13,30 +13,231 @@
|
||||
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];
|
||||
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 as any,
|
||||
mensagemId: ultimaMensagem._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function formatarDataMensagem(timestamp: number): string {
|
||||
@@ -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"}`}
|
||||
>
|
||||
<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}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
<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;
|
||||
@@ -12,24 +23,51 @@
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
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 activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
|
||||
let searchQuery = $state('');
|
||||
let selectedUsers = $state<string[]>([]);
|
||||
let groupName = $state("");
|
||||
let groupName = $state('');
|
||||
let salaReuniaoName = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios) return [];
|
||||
if (!searchQuery.trim()) return usuarios;
|
||||
if (!usuarios?.data) return [];
|
||||
|
||||
// Filtrar o próprio usuário
|
||||
const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
|
||||
let lista = usuarios.data.filter((u: any) => u._id !== meuId);
|
||||
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return usuarios.filter((u: any) =>
|
||||
u.nome.toLowerCase().includes(query) ||
|
||||
u.email.toLowerCase().includes(query) ||
|
||||
u.matricula.toLowerCase().includes(query)
|
||||
lista = lista.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 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;
|
||||
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return (a.nome || '').localeCompare(b.nome || '');
|
||||
});
|
||||
});
|
||||
|
||||
function toggleUserSelection(userId: string) {
|
||||
@@ -44,14 +82,14 @@
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: "individual",
|
||||
participantes: [userId as any],
|
||||
tipo: 'individual',
|
||||
participantes: [userId as any]
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar conversa:", error);
|
||||
alert("Erro ao criar conversa");
|
||||
console.error('Erro ao criar conversa:', error);
|
||||
alert('Erro ao criar conversa');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -59,127 +97,211 @@
|
||||
|
||||
async function handleCriarGrupo() {
|
||||
if (selectedUsers.length < 2) {
|
||||
alert("Selecione pelo menos 2 participantes");
|
||||
alert('Selecione pelo menos 2 participantes');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!groupName.trim()) {
|
||||
alert("Digite um nome para o grupo");
|
||||
alert('Digite um nome para o grupo');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: "grupo",
|
||||
tipo: 'grupo',
|
||||
participantes: selectedUsers as any,
|
||||
nome: groupName.trim(),
|
||||
nome: groupName.trim()
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar grupo:", error);
|
||||
alert("Erro ao criar grupo");
|
||||
} 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}>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col m-4"
|
||||
class="modal-box flex max-h-[85vh] max-w-2xl flex-col p-0"
|
||||
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>
|
||||
<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">
|
||||
<!-- Tabs melhoradas -->
|
||||
<div class="tabs tabs-boxed bg-base-200/50 p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab ${activeTab === "individual" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "individual")}
|
||||
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 ${activeTab === "grupo" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "grupo")}
|
||||
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"}
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === 'grupo'}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Nome do Grupo</span>
|
||||
<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 w-full"
|
||||
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})` : ""}
|
||||
<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>
|
||||
|
||||
<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}
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-4">
|
||||
<!-- Search melhorado -->
|
||||
<div class="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full"
|
||||
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>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios && usuariosFiltrados().length > 0}
|
||||
{#if usuarios?.data && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isSelected = selectedUsers.includes(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"
|
||||
}`}
|
||||
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 (activeTab === "individual") {
|
||||
if (loading) return;
|
||||
if (activeTab === 'individual') {
|
||||
handleCriarIndividual(usuario._id);
|
||||
} else {
|
||||
toggleUserSelection(usuario._id);
|
||||
@@ -188,67 +310,113 @@
|
||||
disabled={loading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative flex-shrink-0">
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfil}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="sm"
|
||||
size="md"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca} size="sm" />
|
||||
<div class="absolute -right-1 -bottom-1">
|
||||
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</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}
|
||||
<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>
|
||||
|
||||
<!-- Checkbox (apenas para grupo) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<!-- 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"
|
||||
checked={selectedUsers.includes(usuario._id)}
|
||||
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}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
{: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="text-center py-8 text-base-content/50">
|
||||
Nenhum usuário encontrado
|
||||
<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 (apenas para grupo) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<div class="px-6 py-4 border-t border-base-300">
|
||||
<!-- 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"
|
||||
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...
|
||||
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>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<style>
|
||||
@keyframes badge-bounce {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="dropdown dropdown-end notification-bell">
|
||||
<div class="notification-bell relative">
|
||||
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
|
||||
<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}
|
||||
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"
|
||||
>
|
||||
<!-- Glow effect -->
|
||||
<!-- 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-full bg-error/20 blur-xl animate-pulse"></div>
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl bg-error/30 blur-lg 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"
|
||||
<!-- Í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"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- Badge premium com gradiente -->
|
||||
{#if count && count > 0}
|
||||
<!-- 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 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;"
|
||||
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;"
|
||||
>
|
||||
{count > 9 ? "9+" : count}
|
||||
{totalCount > 9 ? "9+" : totalCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if dropdownOpen}
|
||||
<!-- Popup Flutuante de Notificações -->
|
||||
{#if modalOpen}
|
||||
<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"
|
||||
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-4 py-2 border-b border-base-300">
|
||||
<h3 class="text-lg font-semibold">Notificações</h3>
|
||||
{#if count && count > 0}
|
||||
<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-ghost btn-xs"
|
||||
onclick={handleMarcarTodasLidas}
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={handleLimparNotificacoesNaoLidas}
|
||||
disabled={limpandoNotificacoes}
|
||||
>
|
||||
Marcar todas como lidas
|
||||
<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="py-2">
|
||||
{#if notificacoes && notificacoes.length > 0}
|
||||
{#each notificacoes.slice(0, 10) as notificacao (notificacao._id)}
|
||||
<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"
|
||||
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="flex-shrink-0 mt-1">
|
||||
<div class="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>
|
||||
<Mail class="w-5 h-5 text-primary" strokeWidth={1.5} />
|
||||
{: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"
|
||||
<AtSign
|
||||
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"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</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>
|
||||
<Users class="w-5 h-5 text-info" strokeWidth={1.5} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content">
|
||||
{notificacao.titulo}
|
||||
{#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 truncate">
|
||||
<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 -->
|
||||
{#if !notificacao.lida}
|
||||
<div class="flex-shrink-0">
|
||||
<div class="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"
|
||||
</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)}
|
||||
>
|
||||
<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"
|
||||
<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}
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-sm">Nenhuma notificação</p>
|
||||
{: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% {
|
||||
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>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
|
||||
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;
|
||||
let lastActivity = Date.now();
|
||||
|
||||
435
apps/web/src/lib/components/chat/SalaReuniaoManager.svelte
Normal file
435
apps/web/src/lib/components/chat/SalaReuniaoManager.svelte
Normal 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>
|
||||
@@ -1,44 +1,52 @@
|
||||
<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">;
|
||||
conversaId: Id<'conversas'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { conversaId });
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
conversaId
|
||||
});
|
||||
|
||||
let mensagem = $state("");
|
||||
let data = $state("");
|
||||
let hora = $state("");
|
||||
let mensagem = $state('');
|
||||
let data = $state('');
|
||||
let hora = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Rastrear mudanças nas mensagens agendadas
|
||||
$effect(() => {
|
||||
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
|
||||
});
|
||||
|
||||
// Definir data/hora mínima (agora)
|
||||
const now = new Date();
|
||||
const minDate = format(now, "yyyy-MM-dd");
|
||||
const minTime = format(now, "HH:mm");
|
||||
const minDate = format(now, 'yyyy-MM-dd');
|
||||
const minTime = format(now, 'HH:mm');
|
||||
|
||||
function getPreviewText(): string {
|
||||
if (!data || !hora) return "";
|
||||
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 "";
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgendar() {
|
||||
if (!mensagem.trim() || !data || !hora) {
|
||||
alert("Preencha todos os campos");
|
||||
alert('Preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,103 +56,103 @@
|
||||
|
||||
// Validar data futura
|
||||
if (dataHora.getTime() <= Date.now()) {
|
||||
alert("A data e hora devem ser futuras");
|
||||
alert('A data e hora devem ser futuras');
|
||||
return;
|
||||
}
|
||||
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId,
|
||||
conteudo: mensagem.trim(),
|
||||
agendadaPara: dataHora.getTime(),
|
||||
agendadaPara: dataHora.getTime()
|
||||
});
|
||||
|
||||
mensagem = "";
|
||||
data = "";
|
||||
hora = "";
|
||||
alert("Mensagem agendada com sucesso!");
|
||||
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");
|
||||
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;
|
||||
if (!confirm('Deseja cancelar esta mensagem agendada?')) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.chat.cancelarMensagemAgendada, { mensagemId: mensagemId as any });
|
||||
await client.mutation(api.chat.cancelarMensagemAgendada, {
|
||||
mensagemId: mensagemId as any
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erro ao cancelar mensagem:", error);
|
||||
alert("Erro ao cancelar mensagem");
|
||||
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 });
|
||||
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
|
||||
locale: ptBR
|
||||
});
|
||||
} catch {
|
||||
return "Data inválida";
|
||||
return 'Data inválida';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50" onclick={onClose}>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col m-4"
|
||||
class="modal-box flex max-h-[90vh] max-w-2xl flex-col p-0"
|
||||
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>
|
||||
<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">
|
||||
<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="form-control">
|
||||
<label class="label">
|
||||
<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>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">{mensagem.length}/500</span>
|
||||
</label>
|
||||
<div class="label">
|
||||
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-4">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<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}
|
||||
@@ -153,10 +161,11 @@
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<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}
|
||||
@@ -167,51 +176,34 @@
|
||||
|
||||
{#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>
|
||||
<Clock class="h-6 w-6" />
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<!-- Botão AGENDAR ultra moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
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>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Agendando...
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Agendando...</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="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
|
||||
/>
|
||||
</svg>
|
||||
Agendar
|
||||
<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>
|
||||
@@ -222,80 +214,48 @@
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
|
||||
|
||||
{#if mensagensAgendadas && mensagensAgendadas.length > 0}
|
||||
{#if mensagensAgendadas?.data && mensagensAgendadas.data.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>
|
||||
{#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="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content/80">
|
||||
<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-sm text-base-content mt-1 line-clamp-2">
|
||||
<p class="text-base-content mt-1 line-clamp-2 text-sm">
|
||||
{msg.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Botão cancelar moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle text-error"
|
||||
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"
|
||||
>
|
||||
<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"
|
||||
<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"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !mensagensAgendadas}
|
||||
{: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-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>
|
||||
<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}
|
||||
@@ -303,5 +263,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
399
apps/web/src/lib/components/ferias/CalendarioFerias.svelte
Normal file
399
apps/web/src/lib/components/ferias/CalendarioFerias.svelte
Normal 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>
|
||||
406
apps/web/src/lib/components/ferias/DashboardFerias.svelte
Normal file
406
apps/web/src/lib/components/ferias/DashboardFerias.svelte
Normal 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>
|
||||
@@ -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>
|
||||
344
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
344
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal 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>
|
||||
|
||||
1046
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
1046
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
File diff suppressed because it is too large
Load Diff
118
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
118
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<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, {});
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
tempoAtual = new Date(resultado.timestamp);
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||
offsetSegundos = resultado.offsetSegundos || 0;
|
||||
erro = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao sincronizar:', error);
|
||||
if (config.fallbackParaPC) {
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||
} else {
|
||||
erro = 'Falha ao sincronizar tempo';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Usar tempo do servidor Convex
|
||||
const tempoServidor = await obterTempoServidor(client);
|
||||
tempoAtual = new Date(tempoServidor);
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = false;
|
||||
erro = null;
|
||||
}
|
||||
} 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>
|
||||
|
||||
650
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
650
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal 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>
|
||||
68
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
68
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { Clock } 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
479
apps/web/src/lib/components/ti/AlertConfigModal.svelte
Normal file
479
apps/web/src/lib/components/ti/AlertConfigModal.svelte
Normal 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>
|
||||
2744
apps/web/src/lib/components/ti/CybersecurityWizcard.svelte
Normal file
2744
apps/web/src/lib/components/ti/CybersecurityWizcard.svelte
Normal file
File diff suppressed because it is too large
Load Diff
509
apps/web/src/lib/components/ti/ReportGeneratorModal.svelte
Normal file
509
apps/web/src/lib/components/ti/ReportGeneratorModal.svelte
Normal 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>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Component } from "svelte";
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string;
|
||||
Icon?: Component;
|
||||
icon?: string; // Mantido para compatibilidade retroativa
|
||||
trend?: {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
@@ -11,13 +14,15 @@
|
||||
color?: "primary" | "secondary" | "accent" | "success" | "warning" | "error";
|
||||
}
|
||||
|
||||
let { title, value, icon, trend, description, color = "primary" }: Props = $props();
|
||||
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}
|
||||
{#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>
|
||||
@@ -35,5 +40,3 @@
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
470
apps/web/src/lib/components/ti/SystemMonitorCard.svelte
Normal file
470
apps/web/src/lib/components/ti/SystemMonitorCard.svelte
Normal 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}
|
||||
1637
apps/web/src/lib/components/ti/SystemMonitorCardLocal.svelte
Normal file
1637
apps/web/src/lib/components/ti/SystemMonitorCardLocal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
125
apps/web/src/lib/components/ti/charts/AreaChart.svelte
Normal file
125
apps/web/src/lib/components/ti/charts/AreaChart.svelte
Normal 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>
|
||||
|
||||
115
apps/web/src/lib/components/ti/charts/BarChart.svelte
Normal file
115
apps/web/src/lib/components/ti/charts/BarChart.svelte
Normal 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>
|
||||
|
||||
372
apps/web/src/lib/components/ti/charts/BarChart3D.svelte
Normal file
372
apps/web/src/lib/components/ti/charts/BarChart3D.svelte
Normal 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>
|
||||
102
apps/web/src/lib/components/ti/charts/DoughnutChart.svelte
Normal file
102
apps/web/src/lib/components/ti/charts/DoughnutChart.svelte
Normal 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>
|
||||
|
||||
129
apps/web/src/lib/components/ti/charts/LineChart.svelte
Normal file
129
apps/web/src/lib/components/ti/charts/LineChart.svelte
Normal 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>
|
||||
|
||||
26
apps/web/src/lib/hooks/convexAuth.ts
Normal file
26
apps/web/src/lib/hooks/convexAuth.ts
Normal 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) + "...");
|
||||
}
|
||||
}
|
||||
|
||||
55
apps/web/src/lib/hooks/useConvexWithAuth.ts
Normal file
55
apps/web/src/lib/hooks/useConvexWithAuth.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
53
apps/web/src/lib/stores/chamados.ts
Normal file
53
apps/web/src/lib/stores/chamados.ts
Normal 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();
|
||||
|
||||
64
apps/web/src/lib/stores/convexAuth.ts
Normal file
64
apps/web/src/lib/stores/convexAuth.ts
Normal 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
|
||||
}
|
||||
|
||||
283
apps/web/src/lib/utils/avatars.ts
Normal file
283
apps/web/src/lib/utils/avatars.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
// Galeria de avatares inspirados em artistas do cinema
|
||||
// Usando DiceBear API com estilos variados para aparência cinematográfica
|
||||
|
||||
export interface Avatar {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
seed: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
// Avatares inspirados em artistas do cinema (30 avatares estilizados)
|
||||
const cinemaArtistsAvatars = [
|
||||
// 15 Masculinos - Inspirados em grandes atores
|
||||
{
|
||||
id: 'avatar-male-1',
|
||||
name: 'Leonardo DiCaprio',
|
||||
seed: 'Leonardo',
|
||||
style: 'adventurer',
|
||||
bgColor: 'C5CAE9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-2',
|
||||
name: 'Brad Pitt',
|
||||
seed: 'Bradley',
|
||||
style: 'adventurer',
|
||||
bgColor: 'B2DFDB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-3',
|
||||
name: 'Tom Hanks',
|
||||
seed: 'Thomas',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'DCEDC8',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-4',
|
||||
name: 'Morgan Freeman',
|
||||
seed: 'Morgan',
|
||||
style: 'adventurer',
|
||||
bgColor: 'F0F4C3',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-5',
|
||||
name: 'Robert De Niro',
|
||||
seed: 'Robert',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'E0E0E0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-6',
|
||||
name: 'Al Pacino',
|
||||
seed: 'Alfredo',
|
||||
style: 'adventurer',
|
||||
bgColor: 'FFCCBC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-7',
|
||||
name: 'Johnny Depp',
|
||||
seed: 'John',
|
||||
style: 'adventurer',
|
||||
bgColor: 'D1C4E9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-8',
|
||||
name: 'Denzel Washington',
|
||||
seed: 'Denzel',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'B3E5FC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-9',
|
||||
name: 'Will Smith',
|
||||
seed: 'Willard',
|
||||
style: 'adventurer',
|
||||
bgColor: 'FFF9C4',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-10',
|
||||
name: 'Tom Cruise',
|
||||
seed: 'TomC',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'CFD8DC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-11',
|
||||
name: 'Samuel L Jackson',
|
||||
seed: 'Samuel',
|
||||
style: 'adventurer',
|
||||
bgColor: 'F8BBD0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-12',
|
||||
name: 'Harrison Ford',
|
||||
seed: 'Harrison',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'C8E6C9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-13',
|
||||
name: 'Keanu Reeves',
|
||||
seed: 'Keanu',
|
||||
style: 'adventurer',
|
||||
bgColor: 'BBDEFB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-14',
|
||||
name: 'Matt Damon',
|
||||
seed: 'Matthew',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'FFE0B2',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-15',
|
||||
name: 'Christian Bale',
|
||||
seed: 'Christian',
|
||||
style: 'adventurer',
|
||||
bgColor: 'E1BEE7',
|
||||
},
|
||||
// 15 Femininos - Inspiradas em grandes atrizes
|
||||
{
|
||||
id: 'avatar-female-1',
|
||||
name: 'Meryl Streep',
|
||||
seed: 'Meryl',
|
||||
style: 'lorelei',
|
||||
bgColor: 'F8BBD0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-2',
|
||||
name: 'Scarlett Johansson',
|
||||
seed: 'Scarlett',
|
||||
style: 'lorelei',
|
||||
bgColor: 'FFCCBC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-3',
|
||||
name: 'Jennifer Lawrence',
|
||||
seed: 'Jennifer',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'E1BEE7',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-4',
|
||||
name: 'Angelina Jolie',
|
||||
seed: 'Angelina',
|
||||
style: 'lorelei',
|
||||
bgColor: 'C5CAE9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-5',
|
||||
name: 'Cate Blanchett',
|
||||
seed: 'Catherine',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'B2DFDB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-6',
|
||||
name: 'Nicole Kidman',
|
||||
seed: 'Nicole',
|
||||
style: 'lorelei',
|
||||
bgColor: 'DCEDC8',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-7',
|
||||
name: 'Julia Roberts',
|
||||
seed: 'Julia',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'FFF9C4',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-8',
|
||||
name: 'Emma Stone',
|
||||
seed: 'Emma',
|
||||
style: 'lorelei',
|
||||
bgColor: 'CFD8DC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-9',
|
||||
name: 'Natalie Portman',
|
||||
seed: 'Natalie',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'F0F4C3',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-10',
|
||||
name: 'Charlize Theron',
|
||||
seed: 'Charlize',
|
||||
style: 'lorelei',
|
||||
bgColor: 'E0E0E0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-11',
|
||||
name: 'Kate Winslet',
|
||||
seed: 'Kate',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'D1C4E9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-12',
|
||||
name: 'Sandra Bullock',
|
||||
seed: 'Sandra',
|
||||
style: 'lorelei',
|
||||
bgColor: 'B3E5FC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-13',
|
||||
name: 'Halle Berry',
|
||||
seed: 'Halle',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'C8E6C9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-14',
|
||||
name: 'Anne Hathaway',
|
||||
seed: 'Anne',
|
||||
style: 'lorelei',
|
||||
bgColor: 'BBDEFB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-15',
|
||||
name: 'Amy Adams',
|
||||
seed: 'Amy',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'FFE0B2',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Gera uma galeria de avatares inspirados em artistas do cinema
|
||||
* Usa DiceBear API com estilos cinematográficos
|
||||
* @param count Número de avatares a gerar (padrão: 30)
|
||||
* @returns Array de objetos com id, name, url, seed e style
|
||||
*/
|
||||
export function generateAvatarGallery(count: number = 30): Avatar[] {
|
||||
const avatars: Avatar[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(count, cinemaArtistsAvatars.length); i++) {
|
||||
const avatar = cinemaArtistsAvatars[i];
|
||||
|
||||
// URL do DiceBear com estilo cinematográfico
|
||||
const url = `https://api.dicebear.com/7.x/${avatar.style}/svg?seed=${encodeURIComponent(avatar.seed)}&backgroundColor=${avatar.bgColor}&radius=50&size=200`;
|
||||
|
||||
avatars.push({
|
||||
id: avatar.id,
|
||||
name: avatar.name,
|
||||
url,
|
||||
seed: avatar.seed,
|
||||
style: avatar.style,
|
||||
});
|
||||
}
|
||||
|
||||
return avatars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter URL do avatar por ID
|
||||
* @param avatarId ID do avatar (ex: "avatar-male-1")
|
||||
* @returns URL do avatar ou string vazia se não encontrado
|
||||
*/
|
||||
export function getAvatarUrl(avatarId: string): string {
|
||||
const gallery = generateAvatarGallery();
|
||||
const avatar = gallery.find(a => a.id === avatarId);
|
||||
return avatar?.url || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar avatar aleatório da galeria
|
||||
* @returns Avatar aleatório
|
||||
*/
|
||||
export function getRandomAvatar(): Avatar {
|
||||
const gallery = generateAvatarGallery();
|
||||
const randomIndex = Math.floor(Math.random() * gallery.length);
|
||||
return gallery[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar avatar selecionado (retorna o ID para salvar no backend)
|
||||
* @param avatarId ID do avatar selecionado
|
||||
* @returns ID do avatar
|
||||
*/
|
||||
export function saveAvatarSelection(avatarId: string): string {
|
||||
return avatarId;
|
||||
}
|
||||
212
apps/web/src/lib/utils/browserInfo.ts
Normal file
212
apps/web/src/lib/utils/browserInfo.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
||||
123
apps/web/src/lib/utils/chamados.ts
Normal file
123
apps/web/src/lib/utils/chamados.ts
Normal 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;
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
452
apps/web/src/lib/utils/deviceInfo.ts
Normal file
452
apps/web/src/lib/utils/deviceInfo.ts
Normal file
@@ -0,0 +1,452 @@
|
||||
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;
|
||||
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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém localização via GPS com múltiplas tentativas
|
||||
*/
|
||||
async function obterLocalizacao(): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
}> {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
console.warn('Geolocalização não suportada');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Tentar múltiplas estratégias
|
||||
const estrategias = [
|
||||
// Estratégia 1: Alta precisão (mais lento, mas mais preciso)
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
},
|
||||
// Estratégia 2: Precisão média (balanceado)
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 8000,
|
||||
maximumAge: 30000
|
||||
},
|
||||
// Estratégia 3: Rápido (usa cache)
|
||||
{
|
||||
enableHighAccuracy: false,
|
||||
timeout: 5000,
|
||||
maximumAge: 60000
|
||||
}
|
||||
];
|
||||
|
||||
for (const options of estrategias) {
|
||||
try {
|
||||
const resultado = await new Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
}>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({});
|
||||
}, options.timeout + 1000);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
clearTimeout(timeout);
|
||||
const { latitude, longitude, accuracy } = position.coords;
|
||||
|
||||
// Validar coordenadas
|
||||
if (isNaN(latitude) || isNaN(longitude) || latitude === 0 || longitude === 0) {
|
||||
resolve({});
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
resolve({
|
||||
latitude,
|
||||
longitude,
|
||||
precisao: accuracy,
|
||||
endereco,
|
||||
cidade,
|
||||
estado,
|
||||
pais,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeout);
|
||||
console.warn('Erro ao obter localização:', error.code, error.message);
|
||||
resolve({});
|
||||
},
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
// Se obteve localização, retornar
|
||||
if (resultado.latitude && resultado.longitude) {
|
||||
console.log('Localização obtida com sucesso:', resultado);
|
||||
return resultado;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro na estratégia de geolocalização:', error);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Se todas as estratégias falharam, retornar vazio
|
||||
console.warn('Não foi possível obter localização após todas as tentativas');
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
325
apps/web/src/lib/utils/metricsCollector.ts
Normal file
325
apps/web/src/lib/utils/metricsCollector.ts
Normal 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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
124
apps/web/src/lib/utils/ponto.ts
Normal file
124
apps/web/src/lib/utils/ponto.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
56
apps/web/src/lib/utils/sincronizacaoTempo.ts
Normal file
56
apps/web/src/lib/utils/sincronizacaoTempo.ts
Normal 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;
|
||||
}
|
||||
|
||||
150
apps/web/src/lib/utils/webcam.ts
Normal file
150
apps/web/src/lib/utils/webcam.ts
Normal 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
apps/web/src/routes/(dashboard)/+layout.server.ts
Normal file
9
apps/web/src/routes/(dashboard)/+layout.server.ts
Normal 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 };
|
||||
};
|
||||
@@ -1,84 +1,64 @@
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import MenuProtection from "$lib/components/MenuProtection.svelte";
|
||||
|
||||
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();
|
||||
|
||||
// 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" },
|
||||
};
|
||||
// Resolver recurso/ação a partir da rota
|
||||
const routeAction = $derived.by(() => {
|
||||
const p = page.url.pathname;
|
||||
if (p === '/' || p === '/abrir-chamado') return null;
|
||||
|
||||
// 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];
|
||||
// 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' };
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
// 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' };
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
// 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 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"
|
||||
>
|
||||
{#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>
|
||||
</MenuProtection>
|
||||
</ActionGuard>
|
||||
{:else}
|
||||
<main
|
||||
id="container-central"
|
||||
class="w-full max-w-none px-3 lg:px-4 py-4"
|
||||
>
|
||||
<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 />
|
||||
|
||||
@@ -3,7 +3,21 @@
|
||||
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 { useAuth } from "@mmailaender/convex-better-auth-svelte/svelte";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
import { loginModalStore } from "$lib/stores/loginModal.svelte";
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const auth = useAuth();
|
||||
const isLoading = $derived(auth.isLoading && !data?.currentUser);
|
||||
const isAuthenticated = $derived(auth.isAuthenticated || !!data?.currentUser);
|
||||
|
||||
$inspect({ isLoading, isAuthenticated });
|
||||
|
||||
// Queries para dados do dashboard
|
||||
const statsQuery = useQuery(api.dashboard.getStats, {});
|
||||
@@ -11,41 +25,72 @@
|
||||
|
||||
// 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") || "";
|
||||
// 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 any;
|
||||
redirectRoute = route;
|
||||
showAlert = true;
|
||||
|
||||
// Limpar URL
|
||||
const newUrl = window.location.pathname;
|
||||
window.history.replaceState({}, "", newUrl);
|
||||
// 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 {
|
||||
replaceState(to.url.pathname, {});
|
||||
} catch (e) {
|
||||
// Se ainda não estiver pronto, usar goto com replaceState
|
||||
goto(to.url.pathname, { replaceState: true, noScroll: true });
|
||||
}
|
||||
|
||||
// Auto-fechar após 10 segundos
|
||||
setTimeout(() => {
|
||||
showAlert = false;
|
||||
}, 10000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
|
||||
// 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 e forçar refresh das queries a cada segundo
|
||||
const interval = setInterval(() => {
|
||||
@@ -66,25 +111,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 +154,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 +173,43 @@
|
||||
<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">
|
||||
<svelte:component
|
||||
this={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">
|
||||
<svelte:component this={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", {
|
||||
@@ -177,11 +237,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,19 +253,34 @@
|
||||
{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)}
|
||||
</h2>
|
||||
@@ -210,8 +289,19 @@
|
||||
</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 +309,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 +348,39 @@
|
||||
|
||||
<!-- 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>
|
||||
<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)}
|
||||
{formatNumber(
|
||||
activityQuery.data.funcionariosCadastrados24h +
|
||||
activityQuery.data.solicitacoesAcesso24h,
|
||||
)}
|
||||
</h2>
|
||||
<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 +390,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 +442,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 +481,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 +520,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 +557,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 +605,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,7 +620,9 @@
|
||||
|
||||
<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">
|
||||
<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}
|
||||
<span class="text-xs text-base-content/60">{val}</span>
|
||||
{/each}
|
||||
@@ -398,30 +631,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 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}
|
||||
|
||||
<!-- 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">
|
||||
<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;"
|
||||
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;"
|
||||
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 +668,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 +683,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 +706,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 +742,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 +779,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 +805,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 class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
@@ -552,7 +835,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
|
||||
@@ -563,6 +847,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
|
||||
238
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
238
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal 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>
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
<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 currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let senhaAtual = $state("");
|
||||
let novaSenha = $state("");
|
||||
let confirmarSenha = $state("");
|
||||
let senhaAtual = $state('');
|
||||
let novaSenha = $state('');
|
||||
let confirmarSenha = $state('');
|
||||
let carregando = $state(false);
|
||||
let notice = $state<{ type: "success" | "error"; message: string } | null>(null);
|
||||
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("/");
|
||||
if (!currentUser?.data) {
|
||||
goto(resolve('/'));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,24 +29,24 @@
|
||||
const erros: string[] = [];
|
||||
|
||||
if (senha.length < 8) {
|
||||
erros.push("A senha deve ter no mínimo 8 caracteres");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
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");
|
||||
erros.push('A senha deve conter pelo menos um caractere especial');
|
||||
}
|
||||
|
||||
return {
|
||||
valido: erros.length === 0,
|
||||
erros,
|
||||
erros
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,24 +57,24 @@
|
||||
// Validações
|
||||
if (!senhaAtual || !novaSenha || !confirmarSenha) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "Todos os campos são obrigatórios",
|
||||
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",
|
||||
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",
|
||||
type: 'error',
|
||||
message: 'A nova senha deve ser diferente da senha atual'
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -79,8 +82,8 @@
|
||||
const validacao = validarSenha(novaSenha);
|
||||
if (!validacao.valido) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: validacao.erros.join(". "),
|
||||
type: 'error',
|
||||
message: validacao.erros.join('. ')
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -89,40 +92,40 @@
|
||||
|
||||
try {
|
||||
if (!authStore.token) {
|
||||
throw new Error("Token não encontrado");
|
||||
throw new Error('Token não encontrado');
|
||||
}
|
||||
|
||||
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
|
||||
token: authStore.token,
|
||||
senhaAntiga: senhaAtual,
|
||||
novaSenha: novaSenha,
|
||||
senhaAtual: senhaAtual,
|
||||
novaSenha: novaSenha
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
notice = {
|
||||
type: "success",
|
||||
message: "Senha alterada com sucesso! Redirecionando...",
|
||||
type: 'success',
|
||||
message: 'Senha alterada com sucesso! Redirecionando...'
|
||||
};
|
||||
|
||||
// Limpar campos
|
||||
senhaAtual = "";
|
||||
novaSenha = "";
|
||||
confirmarSenha = "";
|
||||
senhaAtual = '';
|
||||
novaSenha = '';
|
||||
confirmarSenha = '';
|
||||
|
||||
// Redirecionar após 2 segundos
|
||||
setTimeout(() => {
|
||||
goto("/");
|
||||
goto(resolve('/'));
|
||||
}, 2000);
|
||||
} else {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: resultado.erro || "Erro ao alterar senha",
|
||||
type: 'error',
|
||||
message: resultado.erro || 'Erro ao alterar senha'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: error.message || "Erro ao conectar com o servidor",
|
||||
type: 'error',
|
||||
message: error.message || 'Erro ao conectar com o servidor'
|
||||
};
|
||||
} finally {
|
||||
carregando = false;
|
||||
@@ -130,95 +133,98 @@
|
||||
}
|
||||
|
||||
function cancelar() {
|
||||
goto("/");
|
||||
goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<!-- Header -->
|
||||
<main class="container mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Header Moderno -->
|
||||
<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 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
|
||||
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">
|
||||
<div class="breadcrumbs text-sm mb-6">
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<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"
|
||||
<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"}
|
||||
<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"
|
||||
/>
|
||||
{#if notice.type === 'success'}
|
||||
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
{: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"
|
||||
/>
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{notice.message}</span>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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"}
|
||||
type={mostrarSenhaAtual ? 'text' : 'password'}
|
||||
placeholder="Digite sua senha atual"
|
||||
class="input input-bordered input-primary w-full pr-12"
|
||||
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
|
||||
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"
|
||||
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}
|
||||
<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>
|
||||
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{: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>
|
||||
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
@@ -227,120 +233,93 @@
|
||||
<!-- 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>
|
||||
<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"}
|
||||
type={mostrarNovaSenha ? 'text' : 'password'}
|
||||
placeholder="Digite sua nova senha"
|
||||
class="input input-bordered input-primary w-full pr-12"
|
||||
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
|
||||
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"
|
||||
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}
|
||||
<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>
|
||||
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{: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>
|
||||
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{/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
|
||||
<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>
|
||||
</label>
|
||||
</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>
|
||||
<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"}
|
||||
type={mostrarConfirmarSenha ? 'text' : 'password'}
|
||||
placeholder="Digite novamente sua nova senha"
|
||||
class="input input-bordered input-primary w-full pr-12"
|
||||
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
|
||||
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"
|
||||
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}
|
||||
<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>
|
||||
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{: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>
|
||||
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{/if}
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="flex gap-4 justify-end mt-8">
|
||||
<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-ghost"
|
||||
class="btn btn-outline btn-lg flex-1 sm:flex-initial"
|
||||
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>
|
||||
<XCircle class="h-5 w-5" strokeWidth={2} />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
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}
|
||||
<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>
|
||||
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
||||
Alterar Senha
|
||||
{/if}
|
||||
</button>
|
||||
@@ -348,24 +327,98 @@
|
||||
</form>
|
||||
</div>
|
||||
</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="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>
|
||||
<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>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<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="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Compras</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -12,9 +16,7 @@
|
||||
<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>
|
||||
<ShoppingCart class="h-8 w-8 text-cyan-600" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Compras</h1>
|
||||
@@ -27,22 +29,19 @@
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
<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="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Comunicação</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -12,9 +16,7 @@
|
||||
<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>
|
||||
<Megaphone class="h-8 w-8 text-pink-600" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Comunicação</h1>
|
||||
@@ -27,22 +29,19 @@
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<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="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Controladoria</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -14,9 +18,7 @@
|
||||
<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>
|
||||
<BarChart3 class="h-8 w-8 text-purple-600" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Controladoria</h1>
|
||||
@@ -30,18 +32,14 @@
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,9 +54,7 @@
|
||||
<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>
|
||||
<CheckCircle2 class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Auditoria Interna</h4>
|
||||
</div>
|
||||
@@ -70,9 +66,7 @@
|
||||
<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>
|
||||
<Clock class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Compliance</h4>
|
||||
</div>
|
||||
@@ -84,9 +78,7 @@
|
||||
<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>
|
||||
<TrendingUp class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Indicadores de Gestão</h4>
|
||||
</div>
|
||||
@@ -96,4 +88,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<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';
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
const convex = useConvexClient();
|
||||
|
||||
let matricula = $state("");
|
||||
let email = $state("");
|
||||
let matricula = $state('');
|
||||
let email = $state('');
|
||||
let carregando = $state(false);
|
||||
let notice = $state<{ type: "success" | "error" | "info"; message: string } | null>(null);
|
||||
let notice = $state<{ type: 'success' | 'error' | 'info'; message: string } | null>(null);
|
||||
let solicitacaoEnviada = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
@@ -16,8 +17,8 @@
|
||||
|
||||
if (!matricula || !email) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "Por favor, preencha todos os campos",
|
||||
type: 'error',
|
||||
message: 'Por favor, preencha todos os campos'
|
||||
};
|
||||
return;
|
||||
}
|
||||
@@ -27,15 +28,15 @@
|
||||
try {
|
||||
// Verificar se o usuário existe
|
||||
const usuarios = await convex.query(api.usuarios.listar, {
|
||||
matricula: matricula,
|
||||
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.",
|
||||
type: 'error',
|
||||
message: 'Matrícula ou e-mail não encontrados. Verifique os dados e tente novamente.'
|
||||
};
|
||||
carregando = false;
|
||||
return;
|
||||
@@ -44,17 +45,17 @@
|
||||
// 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.",
|
||||
type: 'success',
|
||||
message: 'Solicitação enviada com sucesso! A equipe de TI entrará em contato em breve.'
|
||||
};
|
||||
|
||||
// Limpar campos
|
||||
matricula = "";
|
||||
email = "";
|
||||
matricula = '';
|
||||
email = '';
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: error.message || "Erro ao processar solicitação",
|
||||
type: 'error',
|
||||
message: error.message || 'Erro ao processar solicitação'
|
||||
};
|
||||
} finally {
|
||||
carregando = false;
|
||||
@@ -62,45 +63,60 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<main class="container mx-auto max-w-2xl px-4 py-8">
|
||||
<!-- 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" />
|
||||
<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-4xl font-bold text-primary">Esqueci Minha Senha</h1>
|
||||
<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>
|
||||
<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">
|
||||
<div class="breadcrumbs mb-6 text-sm">
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<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">
|
||||
<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"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if notice.type === "success"}
|
||||
{#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"}
|
||||
{:else if notice.type === 'error'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
@@ -122,16 +138,27 @@
|
||||
|
||||
{#if !solicitacaoEnviada}
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||
<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="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
|
||||
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.
|
||||
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>
|
||||
@@ -177,24 +204,42 @@
|
||||
</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" />
|
||||
<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}
|
||||
>
|
||||
<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
|
||||
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}
|
||||
@@ -205,26 +250,48 @@
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mensagem de Sucesso -->
|
||||
<div class="card bg-success/10 shadow-xl border border-success/30">
|
||||
<div class="card bg-success/10 border-success/30 border shadow-xl">
|
||||
<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" />
|
||||
<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-2xl font-bold text-success mb-4">Solicitação Enviada!</h2>
|
||||
<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.
|
||||
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" />
|
||||
<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 btn-ghost" onclick={() => solicitacaoEnviada = false}>
|
||||
<button type="button" class="btn" onclick={() => (solicitacaoEnviada = false)}>
|
||||
Enviar Nova Solicitação
|
||||
</button>
|
||||
</div>
|
||||
@@ -233,27 +300,61 @@
|
||||
{/if}
|
||||
|
||||
<!-- Card de Contato -->
|
||||
<div class="mt-6 card bg-base-200 shadow-lg">
|
||||
<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="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
|
||||
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-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 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="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
|
||||
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="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
|
||||
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>
|
||||
@@ -261,4 +362,3 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<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="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Financeiro</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -14,9 +18,7 @@
|
||||
<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>
|
||||
<DollarSign class="h-8 w-8 text-green-600" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Financeiro</h1>
|
||||
@@ -30,18 +32,14 @@
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,9 +54,7 @@
|
||||
<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>
|
||||
<Calculator class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Controle Orçamentário</h4>
|
||||
</div>
|
||||
@@ -70,9 +66,7 @@
|
||||
<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>
|
||||
<TrendingUp class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Fluxo de Caixa</h4>
|
||||
</div>
|
||||
@@ -84,9 +78,7 @@
|
||||
<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>
|
||||
<FileText class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Relatórios Financeiros</h4>
|
||||
</div>
|
||||
@@ -96,4 +88,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -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>
|
||||
<!-- 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>
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<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" />
|
||||
<!-- 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>
|
||||
<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.
|
||||
<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 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>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
// Buscar TODAS as solicitações de ausências
|
||||
const todasAusenciasQuery = useQuery(api.ausencias.listarTodas, {});
|
||||
|
||||
let filtroStatus = $state<string>('todos');
|
||||
let solicitacaoSelecionada = $state<Id<'solicitacoesAusencias'> | null>(null);
|
||||
|
||||
const ausencias = $derived(todasAusenciasQuery?.data || []);
|
||||
|
||||
// Filtrar solicitações
|
||||
const ausenciasFiltradas = $derived(
|
||||
ausencias.filter((a) => {
|
||||
// Filtro de status
|
||||
if (filtroStatus !== 'todos' && a.status !== filtroStatus) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
// Estatísticas gerais
|
||||
const stats = $derived({
|
||||
total: ausencias.length,
|
||||
aguardando: ausencias.filter((a) => a.status === 'aguardando_aprovacao').length,
|
||||
aprovadas: ausencias.filter((a) => a.status === 'aprovado').length,
|
||||
reprovadas: ausencias.filter((a) => a.status === 'reprovado').length
|
||||
});
|
||||
|
||||
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',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
async function selecionarSolicitacao(solicitacaoId: Id<'solicitacoesAusencias'>) {
|
||||
solicitacaoSelecionada = solicitacaoId;
|
||||
}
|
||||
|
||||
async function recarregar() {
|
||||
solicitacaoSelecionada = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto max-w-7xl px-4 py-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<ul>
|
||||
<li>
|
||||
<a href={resolve('/gestao-pessoas')} class="text-primary hover:underline"
|
||||
>Secretaria de Gestão de Pessoas</a
|
||||
>
|
||||
</li>
|
||||
<li>Gestão de Ausências</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-xl bg-orange-500/20 p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-orange-600"
|
||||
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>
|
||||
<h1 class="text-primary text-3xl font-bold">Gestão de Ausências</h1>
|
||||
<p class="text-base-content/70">Visão geral de todas as solicitações de ausências</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn gap-2" onclick={() => goto(resolve('/gestao-pessoas'))}>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estatísticas -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="stat bg-base-100 rounded-box border-base-300 border shadow-lg">
|
||||
<div class="stat-figure text-orange-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
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 class="stat-title">Total</div>
|
||||
<div class="stat-value text-orange-500">{stats.total}</div>
|
||||
<div class="stat-desc">Solicitações</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-box border-warning/30 border shadow-lg">
|
||||
<div class="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
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">Pendentes</div>
|
||||
<div class="stat-value text-warning">{stats.aguardando}</div>
|
||||
<div class="stat-desc">Aguardando</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-box border-success/30 border shadow-lg">
|
||||
<div class="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
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>
|
||||
<div class="stat-title">Aprovadas</div>
|
||||
<div class="stat-value text-success">{stats.aprovadas}</div>
|
||||
<div class="stat-desc">Deferidas</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 rounded-box border-error/30 border shadow-lg">
|
||||
<div class="stat-figure text-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
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>
|
||||
</div>
|
||||
<div class="stat-title">Reprovadas</div>
|
||||
<div class="stat-value text-error">{stats.reprovadas}</div>
|
||||
<div class="stat-desc">Indeferidas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card bg-base-100 mb-6 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-lg">Filtros</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for="filtro-status">
|
||||
<span class="label-text">Status</span>
|
||||
</label>
|
||||
<select id="filtro-status" class="select select-bordered" bind:value={filtroStatus}>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="aguardando_aprovacao">Aguardando Aprovação</option>
|
||||
<option value="aprovado">Aprovado</option>
|
||||
<option value="reprovado">Reprovado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Solicitações -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-lg">
|
||||
Todas as Solicitações ({ausenciasFiltradas.length})
|
||||
</h2>
|
||||
|
||||
{#if ausenciasFiltradas.length === 0}
|
||||
<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>Nenhuma solicitação encontrada com os filtros aplicados.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funcionário</th>
|
||||
<th>Time</th>
|
||||
<th>Período</th>
|
||||
<th>Dias</th>
|
||||
<th>Motivo</th>
|
||||
<th>Status</th>
|
||||
<th>Solicitado em</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ausenciasFiltradas as ausencia}
|
||||
<tr>
|
||||
<td class="font-semibold">
|
||||
{ausencia.funcionario?.nome || 'N/A'}
|
||||
</td>
|
||||
<td>
|
||||
{#if ausencia.time}
|
||||
<div
|
||||
class="badge badge-sm font-semibold"
|
||||
style="background-color: {ausencia.time.cor}20; border-color: {ausencia.time
|
||||
.cor}; color: {ausencia.time.cor}"
|
||||
>
|
||||
{ausencia.time.nome}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-base-content/50">Sem time</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title={ausencia.motivo}>
|
||||
{ausencia.motivo}
|
||||
</td>
|
||||
<td>
|
||||
<div class={`badge ${getStatusBadge(ausencia.status)}`}>
|
||||
{getStatusTexto(ausencia.status)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
{new Date(ausencia.criadoEm).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td>
|
||||
{#if ausencia.status === 'aguardando_aprovacao'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={() => selecionarSolicitacao(ausencia._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="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>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm gap-2"
|
||||
onclick={() => selecionarSolicitacao(ausencia._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="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>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Aprovação -->
|
||||
{#if solicitacaoSelecionada && currentUser.data}
|
||||
{#await client.query( api.ausencias.obterDetalhes, { solicitacaoId: solicitacaoSelecionada } ) then detalhes}
|
||||
{#if detalhes}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<AprovarAusencias
|
||||
solicitacao={detalhes}
|
||||
gestorId={currentUser.data._id}
|
||||
onSucesso={recarregar}
|
||||
onCancelar={() => (solicitacaoSelecionada = null)}
|
||||
/>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (solicitacaoSelecionada = null)}
|
||||
aria-label="Fechar modal">Fechar</button
|
||||
>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
@@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Scale, BookOpen, 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="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Jurídico</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -12,9 +16,7 @@
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="p-3 bg-red-500/20 rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
|
||||
</svg>
|
||||
<Scale class="h-8 w-8 text-red-600" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Jurídico</h1>
|
||||
@@ -27,22 +29,19 @@
|
||||
<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="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
<BookOpen 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 Jurídico está sendo desenvolvido e em breve estará disponível com funcionalidades completas de assessoria jurídica e gestão de processos.
|
||||
</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>
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,99 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { FileText, ClipboardCopy, Building2 } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<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>Licitações</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="p-3 bg-orange-500/20 rounded-xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-orange-600" 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>
|
||||
<h1 class="text-3xl font-bold text-primary">Licitações</h1>
|
||||
<p class="text-base-content/70">Gestão de processos licitatórios</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card de Aviso -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<a
|
||||
href={resolve('/licitacoes/empresas')}
|
||||
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<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 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 class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<Building2 class="text-primary h-6 w-6" strokeWidth={2} />
|
||||
</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 Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios.
|
||||
<h4 class="font-semibold">Empresas</h4>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Cadastro, listagem e edição de empresas e seus contatos.
|
||||
</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>
|
||||
</a>
|
||||
|
||||
<!-- 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">
|
||||
<a
|
||||
href={resolve('/licitacoes/contratos')}
|
||||
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<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 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 2" />
|
||||
</svg>
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<FileText class="text-primary h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Processos Licitatórios</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Cadastro e acompanhamento de licitações</p>
|
||||
<h4 class="font-semibold">Contratos</h4>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-sm">Gestão de contratos, vigências e situações.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||
<div class="card bg-base-100 opacity-70 shadow-md">
|
||||
<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 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 class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-base-200 rounded-lg p-2">
|
||||
<ClipboardCopy class="text-base-content/50 h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Fornecedores</h4>
|
||||
<h4 class="text-base-content/70 font-semibold">Processos Licitatórios</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Cadastro e gestão de fornecedores</p>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Em breve: cadastro e acompanhamento de licitações.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||
<div class="card bg-base-100 opacity-70 shadow-md">
|
||||
<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 class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-base-200 rounded-lg p-2">
|
||||
<FileText class="text-base-content/50 h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Documentação</h4>
|
||||
<h4 class="text-base-content/70 font-semibold">Documentação</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Gestão de documentos e editais</p>
|
||||
<p class="text-base-content/60 text-sm">Em breve: gestão de documentos e editais.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<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 { Plus, AlertTriangle } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { format } from 'date-fns';
|
||||
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
|
||||
|
||||
// Client para mutations
|
||||
const client = useConvexClient();
|
||||
|
||||
// Autenticação
|
||||
$effect(() => {
|
||||
useConvexWithAuth();
|
||||
});
|
||||
|
||||
// Filtros
|
||||
let responsavelId = $state<Id<'funcionarios'> | undefined>(undefined);
|
||||
let dataInicio = $state<string | undefined>(undefined);
|
||||
let dataFim = $state<string | undefined>(undefined);
|
||||
|
||||
// Queries
|
||||
const contratosQuery = useQuery(api.contratos.listar, () => ({
|
||||
responsavelId: responsavelId,
|
||||
dataInicio,
|
||||
dataFim
|
||||
}));
|
||||
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
// Derivados para facilitar acesso aos dados
|
||||
const contratos = $derived.by(() => {
|
||||
if (contratosQuery === undefined || contratosQuery === null) return [];
|
||||
if (Array.isArray(contratosQuery)) return contratosQuery;
|
||||
if ('data' in contratosQuery) return contratosQuery.data || [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const funcionarios = $derived.by(() => {
|
||||
if (funcionariosQuery === undefined || funcionariosQuery === null) return [];
|
||||
if (Array.isArray(funcionariosQuery)) return funcionariosQuery;
|
||||
if ('data' in funcionariosQuery) return funcionariosQuery.data || [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const isLoading = $derived(contratosQuery === undefined);
|
||||
const error = $derived(contratosQuery instanceof Error ? contratosQuery : null);
|
||||
|
||||
// Helpers
|
||||
function formatarData(data: string) {
|
||||
if (!data) return '-';
|
||||
return format(new Date(data), 'dd/MM/yyyy');
|
||||
}
|
||||
|
||||
function formatarMoeda(valor: string) {
|
||||
if (!valor) return '-';
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(parseFloat(valor));
|
||||
}
|
||||
|
||||
async function handleDelete(id: Id<'contratos'>) {
|
||||
if (confirm('Tem certeza que deseja excluir este contrato?')) {
|
||||
await client.mutation(api.contratos.excluir, { id });
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar vencimento (lógica visual simples baseada na data de fim)
|
||||
function isProximoVencimento(dataFim: string, diasAviso: number) {
|
||||
if (!dataFim) return false;
|
||||
const fim = new Date(dataFim).getTime();
|
||||
const hoje = Date.now();
|
||||
const aviso = fim - diasAviso * 24 * 60 * 60 * 1000;
|
||||
return hoje >= aviso && hoje <= fim;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Contratos</h1>
|
||||
<p class="text-muted-foreground">Gerencie os contratos, vigências e situações.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={() => goto(resolve('/licitacoes/contratos/novo'))}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Novo Contrato
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="bg-base-100 grid gap-4 rounded-lg border p-4 shadow-sm md:grid-cols-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtroResponsavel">
|
||||
<span class="label-text">Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="filtroResponsavel"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={responsavelId}
|
||||
>
|
||||
<option value={undefined}>Todos</option>
|
||||
{#each funcionarios as func (func._id)}
|
||||
<option value={func._id}>{func.nome}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtroDataInicio">
|
||||
<span class="label-text">Vigência Início (A partir de)</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtroDataInicio"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={dataInicio}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtroDataFim">
|
||||
<span class="label-text">Vigência Fim (Até)</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtroDataFim"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={dataFim}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
class="btn btn-outline w-full"
|
||||
onclick={() => {
|
||||
responsavelId = undefined;
|
||||
dataInicio = undefined;
|
||||
dataFim = undefined;
|
||||
}}
|
||||
>
|
||||
Limpar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="bg-base-100 overflow-x-auto rounded-md border">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nº Contrato</th>
|
||||
<th>Objeto</th>
|
||||
<th>Contratada</th>
|
||||
<th>Vigência</th>
|
||||
<th>Valor</th>
|
||||
<th>Situação</th>
|
||||
<th>Responsável</th>
|
||||
<th class="text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="8" class="h-24 text-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if error}
|
||||
<tr>
|
||||
<td colspan="8" class="text-error h-24 text-center">
|
||||
Erro ao carregar contratos: {error.message}
|
||||
</td>
|
||||
</tr>
|
||||
{:else if contratos.length === 0}
|
||||
<tr>
|
||||
<td colspan="8" class="text-base-content/70 h-24 text-center">
|
||||
Nenhum contrato encontrado.
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each contratos as contrato (contrato._id)}
|
||||
<tr>
|
||||
<td class="font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{contrato.numeroContrato}/{contrato.anoContrato}
|
||||
{#if isProximoVencimento(contrato.dataFimVigencia, contrato.diasAvisoVencimento)}
|
||||
<div class="tooltip" data-tip="Próximo do vencimento">
|
||||
<AlertTriangle class="text-warning h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="max-w-[200px] truncate" title={contrato.objeto}>
|
||||
{contrato.objeto}
|
||||
</td>
|
||||
<td>
|
||||
{contrato.contratada?.razao_social || 'Empresa não encontrada'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-xs">
|
||||
{formatarData(contrato.dataInicioVigencia)} até
|
||||
<br />
|
||||
{formatarData(contrato.dataFimVigencia)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatarMoeda(contrato.valorTotal)}</td>
|
||||
<td>
|
||||
<div
|
||||
class="badge gap-2
|
||||
{contrato.situacao === 'em_execucao'
|
||||
? 'badge-success'
|
||||
: contrato.situacao === 'rescendido'
|
||||
? 'badge-error'
|
||||
: contrato.situacao === 'aguardando_assinatura'
|
||||
? 'badge-warning'
|
||||
: 'badge-ghost'}"
|
||||
>
|
||||
{contrato.situacao.replace('_', ' ').toUpperCase()}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{contrato.responsavel?.nome || '-'}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => goto(resolve(`/licitacoes/contratos/${contrato._id}`))}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDelete(contrato._id)}
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user