Compare commits

..

1 Commits

Author SHA1 Message Date
8167a407e7 refactor: update StatsCard component and improve page layouts
- Changed the icon prop type in StatsCard from optional string to any for better flexibility with lucide-svelte components.
- Enhanced the layout of various pages by improving indentation and formatting for better readability.
- Updated the usage of lucide-svelte icons across multiple components, ensuring a consistent and modern UI.
- Refactored the handling of derived states and queries for improved performance and clarity in the codebase.
2025-11-04 15:28:13 -03:00
455 changed files with 43703 additions and 144142 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,189 +0,0 @@
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.

View File

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

View File

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

View File

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

View File

@@ -1 +0,0 @@
node_modules

View File

@@ -4,9 +4,9 @@
root = true root = true
[*] [*]
indent_style = tab indent_style = space
indent_size = 2 indent_size = 2
end_of_line = lf end_of_line = lf
charset = utf-8 charset = utf-8
trim_trailing_whitespace = false trim_trailing_whitespace = false
insert_final_newline = true insert_final_newline = false

View File

@@ -1,37 +0,0 @@
name: Build Docker images
on:
push:
branches: ["master"]
jobs:
build-and-push-dockerfile-image:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
password: ${{ secrets.DOCKERHUB_TOKEN }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
- name: Build and push Docker image
uses: docker/build-push-action@v4
with:
context: .
file: ./apps/web/Dockerfile
push: true
# Make sure to replace with your own namespace and repository
tags: |
killercf/sgc:latest
platforms: linux/amd64
build-args: |
PUBLIC_CONVEX_URL=${{ secrets.PUBLIC_CONVEX_URL }}
PUBLIC_CONVEX_SITE_URL=${{ secrets.PUBLIC_CONVEX_SITE_URL }}

5
.gitignore vendored
View File

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

View File

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

View File

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

38
.vscode/settings.json vendored
View File

@@ -1,38 +0,0 @@
{
// "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", "typescript", "svelte"],
"eslint.options": {
"cache": true,
"cacheLocation": ".eslintcache"
},
"editor.formatOnSave": true,
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[jsonc]": {
"editor.defaultFormatter": "vscode.json-language-features"
},
"[svelte]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.tabSize": 2
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -1,91 +1,9 @@
import type { Handle, HandleServerError } from '@sveltejs/kit'; import type { Handle } from "@sveltejs/kit";
import { createAuth } from '@sgse-app/backend/convex/auth';
import { getToken, createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit'; // Middleware desabilitado - proteção de rotas feita no lado do cliente
import { api } from '@sgse-app/backend/convex/_generated/api'; // para compatibilidade com localStorage do authStore
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
event.locals.token = await getToken(createAuth, event.cookies); return resolve(event);
return resolve(event);
}; };
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
// Notificar erros 404 e 500+ (erros internos do servidor)
if (status === 404 || status === 500 || status >= 500) {
// Evitar loop infinito: não registrar erros relacionados à própria página de erros
const urlPath = event.url.pathname;
if (urlPath.includes('/ti/erros-servidor')) {
console.warn(
`⚠️ Erro na página de erros do servidor (${status}): Não será registrado para evitar loop.`
);
} else {
try {
// Obter token do usuário (se autenticado)
const token = event.locals.token;
// Criar cliente Convex para chamar a action
const client = createConvexHttpClient({
token: token || undefined
});
// Extrair informações do erro
const errorMessage = error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
const url = event.url.toString();
const method = event.request.method;
const ipAddress = event.getClientAddress();
const userAgent = event.request.headers.get('user-agent') || undefined;
// Log para debug
console.log(`📝 Registrando erro ${status} no servidor:`, {
url,
method,
mensagem: errorMessage.substring(0, 100)
});
// Chamar action para registrar e notificar erro
// Aguardar a promise mas não bloquear a resposta se falhar
try {
// Usar Promise.race com timeout para evitar bloquear a resposta
const actionPromise = client.action(api.errosServidor.registrarErroServidor, {
statusCode: status,
mensagem: errorMessage,
stack: errorStack,
url,
method,
ipAddress,
userAgent,
usuarioId: undefined // Pode ser implementado depois para obter do token
});
// Timeout de 3 segundos
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error('Timeout ao registrar erro')), 3000);
});
const resultado = await Promise.race([actionPromise, timeoutPromise]);
console.log(`✅ Erro ${status} registrado com sucesso:`, resultado);
} catch (actionError) {
// Log do erro de notificação, mas não falhar a resposta
console.error(
`❌ Erro ao registrar notificação de erro ${status}:`,
actionError instanceof Error ? actionError.message : actionError
);
}
} catch (err) {
// Se falhar ao criar cliente ou chamar action, apenas logar
// Não queremos que falhas na notificação quebrem a resposta de erro
console.error(
`❌ Erro ao tentar notificar equipe técnica sobre erro ${status}:`,
err instanceof Error ? err.message : err
);
}
}
}
// Retornar mensagem de erro padrão
return {
message: message || 'Erro interno do servidor',
status
};
};

View File

@@ -1,18 +1,7 @@
/** import { createAuthClient } from "better-auth/client";
* Cliente Better Auth para frontend SvelteKit import { convexClient } from "@convex-dev/better-auth/client/plugins";
*
* 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({ export const authClient = createAuthClient({
// baseURL padrão é window.location.origin, que é o correto para SvelteKit baseURL: "http://localhost:5173",
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts plugins: [convexClient()],
plugins: [convexClient()]
}); });

View File

@@ -0,0 +1,83 @@
<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 { authStore } from "$lib/stores/auth.svelte";
import { loginModalStore } from "$lib/stores/loginModal.svelte";
interface Props {
recurso: string;
acao: string;
children?: any;
}
let { recurso, acao, children }: Props = $props();
let verificando = $state(true);
let permitido = $state(false);
const permissaoQuery = $derived(
authStore.usuario
? useQuery(api.permissoesAcoes.verificarAcao, {
usuarioId: authStore.usuario._id as Id<"usuarios">,
recurso,
acao,
})
: null
);
$effect(() => {
if (!authStore.autenticado) {
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">
<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 ação.
</p>
</div>
</div>
{/if}

View File

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

View File

@@ -1,204 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } from 'convex-svelte';
import { XCircle, AlertTriangle, X, Clock } from 'lucide-svelte';
type PeriodoFerias = Doc<'ferias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
solicitacao: PeriodoFerias;
usuarioId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
const { 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">
<Clock class="h-3 w-3" strokeWidth={2} />
<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">
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
<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}
>
<X class="h-5 w-5" strokeWidth={2} />
Cancelar Férias (RH)
</button>
</div>
{:else}
<div class="divider mt-6"></div>
<div class="alert alert-error">
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
<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">
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
<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">
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
<span>{erro}</span>
</div>
{/if}
<!-- Botão Fechar -->
{#if onCancelar}
<div class="card-actions mt-4 justify-end">
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
Cancelar
</button>
</div>
{/if}
</div>
</div>

View File

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

View File

@@ -1,424 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient } from 'convex-svelte';
import ErrorModal from './ErrorModal.svelte';
import UserAvatar from './chat/UserAvatar.svelte';
import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte';
import { parseLocalDate } from '$lib/utils/datas';
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
funcionario?: Doc<'funcionarios'> | null;
gestor?: Doc<'usuarios'> | null;
time?: Doc<'times'> | null;
};
interface Props {
solicitacao: SolicitacaoAusencia;
gestorId: Id<'usuarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
const { 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 = parseLocalDate(dataInicio);
const fim = parseLocalDate(dataFim);
const diff = fim.getTime() - inicio.getTime();
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
}
let 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-4">
<h2 class="text-primary mb-1 text-2xl font-bold">Aprovar/Reprovar Ausência</h2>
<p class="text-base-content/70 text-sm">Analise a solicitação e tome uma decisão</p>
</div>
<!-- Card Principal -->
<div class="card bg-base-100 border-primary border-t-4 shadow-2xl">
<div class="card-body p-4 md:p-6">
<!-- Informações do Funcionário -->
<div class="mb-4">
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<div class="bg-primary/10 rounded-lg p-1.5">
<User class="text-primary h-5 w-5" strokeWidth={2} />
</div>
Funcionário
</h3>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
<p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
Nome
</p>
<div class="flex items-center gap-2">
<UserAvatar
fotoPerfilUrl={solicitacao.funcionario?.fotoPerfilUrl}
nome={solicitacao.funcionario?.nome || 'N/A'}
size="sm"
/>
<p class="text-base-content text-base font-bold truncate">
{solicitacao.funcionario?.nome || 'N/A'}
</p>
</div>
</div>
{#if solicitacao.time}
<div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
<p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
Time
</p>
<div
class="badge badge-sm font-semibold max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
.cor}; color: {solicitacao.time.cor}"
title={solicitacao.time.nome}
>
{solicitacao.time.nome}
</div>
</div>
{/if}
</div>
</div>
<div class="divider my-4"></div>
<!-- Período da Ausência -->
<div class="mb-4">
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<div class="bg-primary/10 rounded-lg p-1.5">
<Calendar class="text-primary h-5 w-5" strokeWidth={2} />
</div>
Período da Ausência
</h3>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div
class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
>
<div class="stat-title text-base-content/70 text-xs">Data Início</div>
<div class="stat-value text-primary text-lg font-bold">
{parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
</div>
</div>
<div
class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
>
<div class="stat-title text-base-content/70 text-xs">Data Fim</div>
<div class="stat-value text-primary text-lg font-bold">
{parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
</div>
</div>
<div
class="stat border-primary/30 from-primary/10 to-primary/15 hover:border-primary/40 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
>
<div class="stat-title text-base-content/70 text-xs">Total de Dias</div>
<div class="stat-value text-primary text-2xl font-bold">
{totalDias}
</div>
<div class="stat-desc text-base-content/60 text-xs">dias corridos</div>
</div>
</div>
</div>
<div class="divider my-4"></div>
<!-- Motivo -->
<div class="mb-4">
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<div class="bg-primary/10 rounded-lg p-1.5">
<FileText class="text-primary h-5 w-5" strokeWidth={2} />
</div>
Motivo da Ausência
</h3>
<div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
<div class="card-body p-3">
<p class="text-base-content text-sm leading-relaxed whitespace-pre-wrap">
{solicitacao.motivo}
</p>
</div>
</div>
</div>
<!-- Status Atual -->
<div class="bg-base-200/30 mb-4 rounded-lg p-3">
<div class="flex items-center gap-2">
<span class="text-base-content/70 text-xs font-semibold tracking-wide uppercase"
>Status:</span
>
<div class={`badge badge-sm ${getStatusBadge(solicitacao.status)}`}>
{getStatusTexto(solicitacao.status)}
</div>
</div>
</div>
<!-- Informações de Aprovação/Reprovação -->
{#if solicitacao.status === 'aprovado'}
<div class="alert alert-success mb-4 shadow-lg py-3">
<Check class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
<div class="flex-1">
<div class="font-bold text-sm">Aprovado</div>
{#if solicitacao.gestor}
<div class="text-xs mt-1">
Por: <strong>{solicitacao.gestor.nome}</strong>
</div>
{/if}
{#if solicitacao.dataAprovacao}
<div class="text-xs mt-1 opacity-80">
Em: {new Date(solicitacao.dataAprovacao).toLocaleString('pt-BR')}
</div>
{/if}
</div>
</div>
{/if}
{#if solicitacao.status === 'reprovado'}
<div class="alert alert-error mb-4 shadow-lg py-3">
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
<div class="flex-1">
<div class="font-bold text-sm">Reprovado</div>
{#if solicitacao.gestor}
<div class="text-xs mt-1">
Por: <strong>{solicitacao.gestor.nome}</strong>
</div>
{/if}
{#if solicitacao.dataReprovacao}
<div class="text-xs mt-1 opacity-80">
Em: {new Date(solicitacao.dataReprovacao).toLocaleString('pt-BR')}
</div>
{/if}
{#if solicitacao.motivoReprovacao}
<div class="mt-2">
<div class="text-xs font-semibold">Motivo:</div>
<div class="text-xs">{solicitacao.motivoReprovacao}</div>
</div>
{/if}
</div>
</div>
{/if}
<!-- Histórico de Alterações -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
<div class="mb-4">
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
<div class="bg-primary/10 rounded-lg p-1.5">
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
</div>
Histórico de Alterações
</h3>
<div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
<div class="card-body p-3">
<div class="space-y-2">
{#each solicitacao.historicoAlteracoes as hist}
<div class="border-base-300 flex items-start gap-2 border-b pb-2 last:border-0 last:pb-0">
<Clock class="text-primary mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={2} />
<div class="flex-1">
<div class="text-base-content text-xs font-semibold">{hist.acao}</div>
<div class="text-base-content/60 text-xs">
{new Date(hist.data).toLocaleString('pt-BR')}
</div>
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
{/if}
<!-- Erro -->
{#if erro}
<div class="alert alert-error mb-4 shadow-lg py-3">
<XCircle class="h-5 w-5 shrink-0 stroke-current" />
<span class="text-sm">{erro}</span>
</div>
{/if}
<!-- Ações -->
{#if solicitacao.status === 'aguardando_aprovacao'}
<div class="card-actions mt-4 justify-end gap-2 flex-wrap">
<button
type="button"
class="btn btn-error btn-sm md:btn-md gap-2"
onclick={reprovar}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<X class="h-4 w-4" strokeWidth={2} />
{/if}
Reprovar
</button>
<button
type="button"
class="btn btn-success btn-sm md:btn-md gap-2"
onclick={aprovar}
disabled={processando}
>
{#if processando}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<Check class="h-4 w-4" strokeWidth={2} />
{/if}
Aprovar
</button>
</div>
<!-- Modal de Reprovação -->
{#if motivoReprovacao !== undefined}
<div class="border-error/20 bg-error/5 mt-4 rounded-lg border-2 p-3">
<div class="form-control">
<label class="label py-1" for="motivo-reprovacao">
<span class="label-text text-error text-sm font-bold">Motivo da Reprovação</span>
</label>
<textarea
id="motivo-reprovacao"
class="textarea textarea-bordered textarea-sm focus:border-error focus:outline-error h-20"
placeholder="Informe o motivo da reprovação..."
bind:value={motivoReprovacao}
></textarea>
</div>
</div>
{/if}
{:else}
<div class="alert alert-info shadow-lg py-3">
<Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
<span class="text-sm">Esta solicitação já foi processada.</span>
</div>
{/if}
<!-- Botão Cancelar -->
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-ghost btn-sm"
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: 100%;
margin: 0 auto;
}
</style>

View File

@@ -1,477 +1,384 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { useConvexClient } from "convex-svelte";
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel'; import { api } from "@sgse-app/backend/convex/_generated/api";
import UserAvatar from './chat/UserAvatar.svelte'; import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel";
import { Clock, Check, Edit, X, XCircle } from 'lucide-svelte';
import { useConvexClient } from 'convex-svelte'; interface Periodo {
dataInicio: string;
type PeriodoFerias = Doc<'ferias'> & { dataFim: string;
funcionario?: Doc<'funcionarios'> | null; diasCorridos: number;
gestor?: Doc<'usuarios'> | null; }
time?: Doc<'times'> | null;
}; type SolicitacaoFerias = Doc<"solicitacoesFerias"> & {
funcionario?: Doc<"funcionarios"> | null;
interface Props { gestor?: Doc<"usuarios"> | null;
periodo: PeriodoFerias; };
gestorId: Id<'usuarios'>;
onSucesso?: () => void; interface Props {
onCancelar?: () => void; solicitacao: SolicitacaoFerias;
} gestorId: Id<"usuarios">;
onSucesso?: () => void;
const { periodo, gestorId, onSucesso, onCancelar }: Props = $props(); onCancelar?: () => void;
}
const client = useConvexClient();
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
let modoAjuste = $state(false);
let novaDataInicio = $state(periodo.dataInicio); const client = useConvexClient();
let novaDataFim = $state(periodo.dataFim);
let motivoReprovacao = $state(''); let modoAjuste = $state(false);
let processando = $state(false); let periodos = $state<Periodo[]>([]);
let erro = $state(''); let motivoReprovacao = $state("");
let processando = $state(false);
// Calcular dias do período ajustado let erro = $state("");
let diasAjustados = $derived.by(() => {
if (!novaDataInicio || !novaDataFim) return 0; $effect(() => {
const inicio = new Date(novaDataInicio); if (modoAjuste && periodos.length === 0) {
const fim = new Date(novaDataFim); periodos = solicitacao.periodos.map((p) => ({...p}));
const diffTime = Math.abs(fim.getTime() - inicio.getTime()); }
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; });
return diffDays;
}); function calcularDias(periodo: Periodo) {
if (!periodo.dataInicio || !periodo.dataFim) {
function calcularDias(dataInicio: string, dataFim: string): number { periodo.diasCorridos = 0;
if (!dataInicio || !dataFim) return 0; return;
}
const inicio = new Date(dataInicio);
const fim = new Date(dataFim); 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'; if (fim < inicio) {
return 0; 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;
erro = ''; const diff = fim.getTime() - inicio.getTime();
return dias; const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
} periodo.diasCorridos = dias;
erro = "";
async function aprovar() { }
try {
processando = true; async function aprovar() {
erro = ''; try {
processando = true;
// Validar se as datas e condições estão dentro do regime do funcionário erro = "";
if (!periodo.funcionario?._id) {
erro = 'Funcionário não encontrado'; await client.mutation(api.ferias.aprovar, {
processando = false; solicitacaoId: solicitacao._id,
return; gestorId: gestorId,
} });
const validacao = await client.query(api.saldoFerias.validarSolicitacao, { if (onSucesso) onSucesso();
funcionarioId: periodo.funcionario._id, } catch (e) {
anoReferencia: periodo.anoReferencia, erro = e instanceof Error ? e.message : String(e);
periodos: [ } finally {
{ processando = false;
dataInicio: periodo.dataInicio, }
dataFim: periodo.dataFim }
}
], async function reprovar() {
feriasIdExcluir: periodo._id // Excluir este período do cálculo de saldo pendente if (!motivoReprovacao.trim()) {
}); erro = "Informe o motivo da reprovação";
return;
if (!validacao.valido) { }
erro = `Não é possível aprovar: ${validacao.erros.join('; ')}`;
processando = false; try {
return; processando = true;
} erro = "";
await client.mutation(api.ferias.aprovar, { await client.mutation(api.ferias.reprovar, {
feriasId: periodo._id, solicitacaoId: solicitacao._id,
gestorId: gestorId gestorId: gestorId,
}); motivoReprovacao,
});
if (onSucesso) onSucesso();
} catch (e) { if (onSucesso) onSucesso();
erro = e instanceof Error ? e.message : String(e); } catch (e) {
} finally { erro = e instanceof Error ? e.message : String(e);
processando = false; } finally {
} processando = false;
} }
}
async function reprovar() {
if (!motivoReprovacao.trim()) { async function ajustarEAprovar() {
erro = 'Informe o motivo da reprovação'; try {
return; processando = true;
} erro = "";
try { await client.mutation(api.ferias.ajustarEAprovar, {
processando = true; solicitacaoId: solicitacao._id,
erro = ''; gestorId: gestorId,
novosPeriodos: periodos,
await client.mutation(api.ferias.reprovar, { });
feriasId: periodo._id,
gestorId: gestorId, if (onSucesso) onSucesso();
motivoReprovacao } catch (e) {
}); erro = e instanceof Error ? e.message : String(e);
} finally {
if (onSucesso) onSucesso(); processando = false;
} 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",
async function ajustarEAprovar() { reprovado: "badge-error",
try { data_ajustada_aprovada: "badge-info",
processando = true; };
erro = ''; return badges[status] || "badge-neutral";
}
// Validar se as datas ajustadas e condições estão dentro do regime do funcionário
if (!periodo.funcionario?._id) { function getStatusTexto(status: string) {
erro = 'Funcionário não encontrado'; const textos: Record<string, string> = {
processando = false; aguardando_aprovacao: "Aguardando Aprovação",
return; aprovado: "Aprovado",
} reprovado: "Reprovado",
data_ajustada_aprovada: "Data Ajustada e Aprovada",
// Validar datas ajustadas };
if (!novaDataInicio || !novaDataFim) { return textos[status] || status;
erro = 'Informe as novas datas de início e fim'; }
processando = false;
return; function formatarData(data: number) {
} return new Date(data).toLocaleString("pt-BR");
}
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> </script>
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="mb-4 flex items-start justify-between"> <div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3"> <div>
<UserAvatar <h2 class="card-title text-2xl">
fotoPerfilUrl={periodo.funcionario?.fotoPerfilUrl} {solicitacao.funcionario?.nome || "Funcionário"}
nome={periodo.funcionario?.nome || 'Funcionário'} </h2>
size="md" <p class="text-sm text-base-content/70 mt-1">
/> Ano de Referência: {solicitacao.anoReferencia}
<div> </p>
<h2 class="card-title text-2xl"> </div>
{periodo.funcionario?.nome || 'Funcionário'} <div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
</h2> {getStatusTexto(solicitacao.status)}
<p class="text-base-content/70 mt-1 text-sm"> </div>
Ano de Referência: {periodo.anoReferencia} </div>
</p>
</div> <!-- Períodos Solicitados -->
</div> <div class="mt-4">
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}> <h3 class="font-semibold text-lg mb-3">Períodos Solicitados</h3>
{getStatusTexto(periodo.status)} <div class="space-y-2">
</div> {#each solicitacao.periodos as periodo, index}
</div> <div class="flex items-center gap-4 p-3 bg-base-200 rounded-lg">
<div class="badge badge-primary">{index + 1}</div>
<!-- Período Solicitado --> <div class="flex-1 grid grid-cols-3 gap-2 text-sm">
<div class="mt-4"> <div>
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3> <span class="text-base-content/70">Início:</span>
<div class="bg-base-200 rounded-lg p-4"> <span class="font-semibold ml-1">{new Date(periodo.dataInicio).toLocaleDateString("pt-BR")}</span>
<div class="grid grid-cols-3 gap-4 text-sm"> </div>
<div> <div>
<span class="text-base-content/70">Início:</span> <span class="text-base-content/70">Fim:</span>
<span class="ml-1 font-semibold">{formatarDataString(periodo.dataInicio)}</span> <span class="font-semibold ml-1">{new Date(periodo.dataFim).toLocaleDateString("pt-BR")}</span>
</div> </div>
<div> <div>
<span class="text-base-content/70">Fim:</span> <span class="text-base-content/70">Dias:</span>
<span class="ml-1 font-semibold">{formatarDataString(periodo.dataFim)}</span> <span class="font-bold ml-1 text-primary">{periodo.diasCorridos}</span>
</div> </div>
<div> </div>
<span class="text-base-content/70">Dias:</span> </div>
<span class="text-primary ml-1 font-bold">{periodo.diasFerias}</span> {/each}
</div> </div>
</div> </div>
</div>
</div> <!-- Observações -->
{#if solicitacao.observacao}
<!-- Observações --> <div class="mt-4">
{#if periodo.observacao} <h3 class="font-semibold mb-2">Observações</h3>
<div class="mt-4"> <div class="p-3 bg-base-200 rounded-lg text-sm">
<h3 class="mb-2 font-semibold">Observações</h3> {solicitacao.observacao}
<div class="bg-base-200 rounded-lg p-3 text-sm"> </div>
{periodo.observacao} </div>
</div> {/if}
</div>
{/if} <!-- Histórico -->
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
<!-- Histórico --> <div class="mt-4">
{#if periodo.historicoAlteracoes && periodo.historicoAlteracoes.length > 0} <h3 class="font-semibold mb-2">Histórico</h3>
<div class="mt-4"> <div class="space-y-1">
<h3 class="mb-2 font-semibold">Histórico</h3> {#each solicitacao.historicoAlteracoes as hist}
<div class="space-y-1"> <div class="text-xs text-base-content/70 flex items-center gap-2">
{#each periodo.historicoAlteracoes as hist} <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="text-base-content/70 flex items-center gap-2 text-xs"> <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" />
<Clock class="h-3 w-3" strokeWidth={2} /> </svg>
<span>{formatarData(hist.data)}</span> <span>{formatarData(hist.data)}</span>
<span>-</span> <span>-</span>
<span>{hist.acao}</span> <span>{hist.acao}</span>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
<!-- Ações (apenas para status aguardando_aprovacao) --> <!-- Ações (apenas para status aguardando_aprovacao) -->
{#if periodo.status === 'aguardando_aprovacao'} {#if solicitacao.status === "aguardando_aprovacao"}
<div class="divider mt-6"></div> <div class="divider mt-6"></div>
{#if !modoAjuste} {#if !modoAjuste}
<!-- Modo Normal --> <!-- Modo Normal -->
<div class="space-y-4"> <div class="space-y-4">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
<button <button
type="button" type="button"
class="btn btn-success gap-2" class="btn btn-success gap-2"
onclick={aprovar} onclick={aprovar}
disabled={processando} disabled={processando}
> >
<Check class="h-5 w-5" strokeWidth={2} /> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
Aprovar <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</button> </svg>
Aprovar
<button </button>
type="button"
class="btn btn-info gap-2" <button
onclick={() => (modoAjuste = true)} type="button"
disabled={processando} class="btn btn-info gap-2"
> onclick={() => modoAjuste = true}
<Edit class="h-5 w-5" strokeWidth={2} /> disabled={processando}
Ajustar Datas e Aprovar >
</button> <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</div> <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>
<!-- Reprovar --> Ajustar Datas e Aprovar
<div class="card bg-base-200"> </button>
<div class="card-body p-4"> </div>
<h4 class="mb-2 text-sm font-semibold">Reprovar Período</h4>
<textarea <!-- Reprovar -->
class="textarea textarea-bordered textarea-sm mb-2" <div class="card bg-base-200">
placeholder="Motivo da reprovação..." <div class="card-body p-4">
bind:value={motivoReprovacao} <h4 class="font-semibold text-sm mb-2">Reprovar Solicitação</h4>
rows="2" <textarea
></textarea> class="textarea textarea-bordered textarea-sm mb-2"
<button placeholder="Motivo da reprovação..."
type="button" bind:value={motivoReprovacao}
class="btn btn-error btn-sm gap-2" rows="2"
onclick={reprovar} ></textarea>
disabled={processando || !motivoReprovacao.trim()} <button
> type="button"
<X class="h-4 w-4" strokeWidth={2} /> class="btn btn-error btn-sm gap-2"
Reprovar onclick={reprovar}
</button> disabled={processando || !motivoReprovacao.trim()}
</div> >
</div> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</div> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
{:else} </svg>
<!-- Modo Ajuste --> Reprovar
<div class="space-y-4"> </button>
<h4 class="font-semibold">Ajustar Período</h4> </div>
<div class="card bg-base-200"> </div>
<div class="card-body p-4"> </div>
<div class="grid grid-cols-3 gap-3"> {:else}
<div class="form-control"> <!-- Modo Ajuste -->
<label class="label" for="ajuste-inicio"> <div class="space-y-4">
<span class="label-text text-xs">Início</span> <h4 class="font-semibold">Ajustar Períodos</h4>
</label> {#each periodos as periodo, index}
<input <div class="card bg-base-200">
id="ajuste-inicio" <div class="card-body p-4">
type="date" <h5 class="font-medium mb-2">Período {index + 1}</h5>
class="input input-bordered input-sm" <div class="grid grid-cols-3 gap-3">
bind:value={novaDataInicio} <div class="form-control">
/> <label class="label" for={`ajuste-inicio-${index}`}>
</div> <span class="label-text text-xs">Início</span>
<div class="form-control"> </label>
<label class="label" for="ajuste-fim"> <input
<span class="label-text text-xs">Fim</span> id={`ajuste-inicio-${index}`}
</label> type="date"
<input class="input input-bordered input-sm"
id="ajuste-fim" bind:value={periodo.dataInicio}
type="date" onchange={() => calcularDias(periodo)}
class="input input-bordered input-sm" />
bind:value={novaDataFim} </div>
/> <div class="form-control">
</div> <label class="label" for={`ajuste-fim-${index}`}>
<div class="form-control"> <span class="label-text text-xs">Fim</span>
<label class="label" for="ajuste-dias"> </label>
<span class="label-text text-xs">Dias</span> <input
</label> id={`ajuste-fim-${index}`}
<div type="date"
id="ajuste-dias" class="input input-bordered input-sm"
class="bg-base-300 flex h-9 items-center rounded-lg px-3" bind:value={periodo.dataFim}
role="textbox" onchange={() => calcularDias(periodo)}
aria-readonly="true" />
> </div>
<span class="font-bold">{diasAjustados}</span> <div class="form-control">
<span class="ml-2 text-xs opacity-70">dias</span> <label class="label" for={`ajuste-dias-${index}`}>
</div> <span class="label-text text-xs">Dias</span>
</div> </label>
</div> <div id={`ajuste-dias-${index}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
</div> <span class="font-bold">{periodo.diasCorridos}</span>
</div> </div>
</div>
<div class="flex gap-2"> </div>
<button </div>
type="button" </div>
class="btn btn-sm" {/each}
onclick={() => (modoAjuste = false)}
disabled={processando} <div class="flex gap-2">
> <button
Cancelar Ajuste type="button"
</button> class="btn btn-ghost btn-sm"
<button onclick={() => modoAjuste = false}
type="button" disabled={processando}
class="btn btn-primary btn-sm gap-2" >
onclick={ajustarEAprovar} Cancelar Ajuste
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0} </button>
> <button
<Check class="h-4 w-4" strokeWidth={2} /> type="button"
Confirmar e Aprovar class="btn btn-primary btn-sm gap-2"
</button> onclick={ajustarEAprovar}
</div> disabled={processando}
</div> >
{/if} <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{/if} <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<!-- Informações de Aprovação/Reprovação --> Confirmar e Aprovar
{#if periodo.status === 'aprovado' || periodo.status === 'data_ajustada_aprovada' || periodo.status === 'EmFérias'} </button>
<div class="alert alert-success mt-4"> </div>
<Check class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} /> </div>
<div class="flex-1"> {/if}
<div class="font-bold">Aprovado</div> {/if}
{#if periodo.gestor}
<div class="text-sm mt-1"> <!-- Motivo Reprovação (se reprovado) -->
Por: <strong>{periodo.gestor.nome}</strong> {#if solicitacao.status === "reprovado" && solicitacao.motivoReprovacao}
</div> <div class="alert alert-error mt-4">
{/if} <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
{#if periodo.dataAprovacao} <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" />
<div class="text-xs mt-1 opacity-80"> </svg>
Em: {formatarData(periodo.dataAprovacao)} <div>
</div> <div class="font-bold">Motivo da Reprovação:</div>
{/if} <div class="text-sm">{solicitacao.motivoReprovacao}</div>
</div> </div>
</div> </div>
{/if} {/if}
<!-- Motivo Reprovação (se reprovado) --> <!-- Erro -->
{#if periodo.status === 'reprovado'} {#if erro}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} /> <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="flex-1"> <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" />
<div class="font-bold">Reprovado</div> </svg>
{#if periodo.gestor} <span>{erro}</span>
<div class="text-sm mt-1"> </div>
Por: <strong>{periodo.gestor.nome}</strong> {/if}
</div>
{/if} <!-- Botão Fechar -->
{#if periodo.dataReprovacao} {#if onCancelar}
<div class="text-xs mt-1 opacity-80"> <div class="card-actions justify-end mt-4">
Em: {formatarData(periodo.dataReprovacao)} <button
</div> type="button"
{/if} class="btn btn-ghost"
{#if periodo.motivoReprovacao} onclick={onCancelar}
<div class="mt-2"> disabled={processando}
<div class="text-sm font-semibold">Motivo:</div> >
<div class="text-sm">{periodo.motivoReprovacao}</div> Fechar
</div> </button>
{/if} </div>
</div> {/if}
</div> </div>
{/if}
<!-- Erro -->
{#if erro}
<div class="alert alert-error mt-4">
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
<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> </div>

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,187 +1,189 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { useQuery } from "convex-svelte";
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { api } from "@sgse-app/backend/convex/_generated/api";
import { useQuery } from 'convex-svelte'; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
interface Props { interface Props {
value?: string; // Id do funcionário selecionado value?: string; // Id do funcionário selecionado
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
required?: boolean; required?: boolean;
} }
let { let {
value = $bindable(), value = $bindable(),
placeholder = 'Selecione um funcionário', placeholder = "Selecione um funcionário",
disabled = false, disabled = false,
required = false required = false,
}: Props = $props(); }: Props = $props();
let busca = $state(''); let busca = $state("");
let mostrarDropdown = $state(false); let mostrarDropdown = $state(false);
// Buscar funcionários // Buscar funcionários
const funcionariosQuery = useQuery(api.funcionarios.getAll, {}); const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []); const funcionarios = $derived(
funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []
);
// Filtrar funcionários baseado na busca // Filtrar funcionários baseado na busca
let funcionariosFiltrados = $derived.by(() => { const funcionariosFiltrados = $derived.by(() => {
if (!busca.trim()) return funcionarios; if (!busca.trim()) return funcionarios;
const termo = busca.toLowerCase().trim(); const termo = busca.toLowerCase().trim();
return funcionarios.filter((f) => { return funcionarios.filter((f) => {
const nomeMatch = f.nome?.toLowerCase().includes(termo); const nomeMatch = f.nome?.toLowerCase().includes(termo);
const matriculaMatch = f.matricula?.toLowerCase().includes(termo); const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
const cpfMatch = f.cpf?.replace(/\D/g, '').includes(termo.replace(/\D/g, '')); const cpfMatch = f.cpf?.replace(/\D/g, "").includes(termo.replace(/\D/g, ""));
return nomeMatch || matriculaMatch || cpfMatch; return nomeMatch || matriculaMatch || cpfMatch;
}); });
}); });
// Funcionário selecionado // Funcionário selecionado
let funcionarioSelecionado = $derived.by(() => { const funcionarioSelecionado = $derived.by(() => {
if (!value) return null; if (!value) return null;
return funcionarios.find((f) => f._id === value); return funcionarios.find((f) => f._id === value);
}); });
function selecionarFuncionario(funcionarioId: string) { function selecionarFuncionario(funcionarioId: string) {
value = funcionarioId; value = funcionarioId;
const funcionario = funcionarios.find((f) => f._id === funcionarioId); const funcionario = funcionarios.find((f) => f._id === funcionarioId);
busca = funcionario?.nome || ''; busca = funcionario?.nome || "";
mostrarDropdown = false; mostrarDropdown = false;
} }
function limpar() { function limpar() {
value = undefined; value = undefined;
busca = ''; busca = "";
mostrarDropdown = false; mostrarDropdown = false;
} }
// Atualizar busca quando funcionário selecionado mudar externamente // Atualizar busca quando funcionário selecionado mudar externamente
$effect(() => { $effect(() => {
if (value && !busca) { if (value && !busca) {
const funcionario = funcionarios.find((f) => f._id === value); const funcionario = funcionarios.find((f) => f._id === value);
busca = funcionario?.nome || ''; busca = funcionario?.nome || "";
} }
}); });
function handleFocus() { function handleFocus() {
if (!disabled) { if (!disabled) {
mostrarDropdown = true; mostrarDropdown = true;
} }
} }
function handleBlur() { function handleBlur() {
// Delay para permitir click no dropdown // Delay para permitir click no dropdown
setTimeout(() => { setTimeout(() => {
mostrarDropdown = false; mostrarDropdown = false;
}, 200); }, 200);
} }
</script> </script>
<div class="form-control relative w-full"> <div class="form-control w-full relative">
<label class="label"> <label class="label">
<span class="label-text font-medium"> <span class="label-text font-medium">
Funcionário Funcionário
{#if required} {#if required}
<span class="text-error">*</span> <span class="text-error">*</span>
{/if} {/if}
</span> </span>
</label> </label>
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
bind:value={busca} bind:value={busca}
{placeholder} {placeholder}
{disabled} {disabled}
onfocus={handleFocus} onfocus={handleFocus}
onblur={handleBlur} onblur={handleBlur}
class="input input-bordered w-full pr-10" class="input input-bordered w-full pr-10"
autocomplete="off" autocomplete="off"
/> />
{#if value} {#if value}
<button <button
type="button" type="button"
onclick={limpar} onclick={limpar}
class="btn btn-xs btn-circle absolute top-1/2 right-2 -translate-y-1/2" class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
{disabled} disabled={disabled}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4" class="h-4 w-4"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M6 18L18 6M6 6l12 12" d="M6 18L18 6M6 6l12 12"
/> />
</svg> </svg>
</button> </button>
{:else} {:else}
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2"> <div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="text-base-content/40 h-5 w-5" class="h-5 w-5 text-base-content/40"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/> />
</svg> </svg>
</div> </div>
{/if} {/if}
{#if mostrarDropdown && funcionariosFiltrados.length > 0} {#if mostrarDropdown && funcionariosFiltrados.length > 0}
<div <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" class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto"
> >
{#each funcionariosFiltrados as funcionario} {#each funcionariosFiltrados as funcionario}
<button <button
type="button" type="button"
onclick={() => selecionarFuncionario(funcionario._id)} 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" class="w-full text-left px-4 py-3 hover:bg-base-200 transition-colors border-b border-base-200 last:border-b-0"
> >
<div class="font-medium">{funcionario.nome}</div> <div class="font-medium">{funcionario.nome}</div>
<div class="text-base-content/60 text-sm"> <div class="text-sm text-base-content/60">
{#if funcionario.matricula} {#if funcionario.matricula}
Matrícula: {funcionario.matricula} Matrícula: {funcionario.matricula}
{/if} {/if}
{#if funcionario.descricaoCargo} {#if funcionario.descricaoCargo}
{funcionario.matricula ? '' : ''} {funcionario.matricula ? "" : ""}
{funcionario.descricaoCargo} {funcionario.descricaoCargo}
{/if} {/if}
</div> </div>
</button> </button>
{/each} {/each}
</div> </div>
{/if} {/if}
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0} {#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
<div <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" class="absolute z-50 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg p-4 text-center text-base-content/60"
> >
Nenhum funcionário encontrado Nenhum funcionário encontrado
</div> </div>
{/if} {/if}
</div> </div>
{#if funcionarioSelecionado} {#if funcionarioSelecionado}
<div class="text-base-content/60 mt-1 text-xs"> <div class="text-xs text-base-content/60 mt-1">
Selecionado: {funcionarioSelecionado.nome} Selecionado: {funcionarioSelecionado.nome}
{#if funcionarioSelecionado.matricula} {#if funcionarioSelecionado.matricula}
- {funcionarioSelecionado.matricula} - {funcionarioSelecionado.matricula}
{/if} {/if}
</div> </div>
{/if} {/if}
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,136 +0,0 @@
<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 {
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 -->

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,385 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { SvelteDate } from 'svelte/reactivity';
import { Check, ChevronLeft, ChevronRight, Calendar, AlertTriangle, CheckCircle } from 'lucide-svelte';
import { parseLocalDate } from '$lib/utils/datas';
import type { toast } from 'svelte-sonner';
import ErrorModal from '../ErrorModal.svelte';
import CalendarioAusencias from './CalendarioAusencias.svelte';
interface Props {
funcionarioId: Id<'funcionarios'>;
onSucesso?: () => void;
onCancelar?: () => void;
}
const { 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)
let 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;
}
let totalDias = $derived(calcularDias(dataInicio, dataFim));
// Funções de navegação
function proximoPasso() {
if (passoAtual === 1) {
if (!dataInicio || !dataFim) {
toast.error('Selecione o período de ausência no calendário');
return;
}
const hoje = new SvelteDate();
hoje.setHours(0, 0, 0, 0);
const inicio = parseLocalDate(dataInicio);
if (inicio < hoje) {
toast.error('A data de início não pode ser no passado');
return;
}
if (parseLocalDate(dataFim) < parseLocalDate(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: ${parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até ${parseLocalDate(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
mostrarModalErro = true;
} 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}
<Check class="h-6 w-6" strokeWidth={2} />
{: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}
<Check class="h-6 w-6" strokeWidth={2} />
{: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">
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
<div>
<h4 class="font-bold">Período selecionado!</h4>
<p>
De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até
{parseLocalDate(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-base-content/20 border-2">
<div class="card-body">
<h4 class="card-title text-orange-700 dark:text-orange-400">
<Calendar class="h-5 w-5" strokeWidth={2} />
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">
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
</p>
</div>
<div>
<p class="text-base-content/70 text-sm">Data Fim</p>
<p class="font-bold">
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
</p>
</div>
<div>
<p class="text-base-content/70 text-sm">Total de Dias</p>
<p class="text-xl font-bold text-orange-600 dark:text-orange-400">
{totalDias} dias
</p>
</div>
</div>
</div>
</div>
{/if}
<!-- Campo de motivo -->
<div class="form-control">
<label class="label" for="motivo">
<span class="label-text font-bold">Motivo da Ausência</span>
<span class="label-text-alt">
{motivo.trim().length}/10 caracteres mínimos
</span>
</label>
<textarea
id="motivo"
class="textarea textarea-bordered h-32 text-lg"
placeholder="Descreva o motivo da sua solicitação de ausência..."
bind:value={motivo}
maxlength={500}
></textarea>
<label class="label" for="motivo">
<span class="label-text-alt text-base-content/70">
Mínimo 10 caracteres. Seja claro e objetivo.
</span>
</label>
</div>
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
<div class="alert alert-warning shadow-lg">
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
<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}
>
<ChevronLeft class="mr-2 h-5 w-5" strokeWidth={2} />
Voltar
</button>
{#if passoAtual < totalPassos}
<button
type="button"
class="btn btn-primary"
onclick={proximoPasso}
disabled={processando}
>
Próximo
<ChevronRight class="ml-2 h-5 w-5" strokeWidth={2} />
</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}
<Check class="mr-2 h-5 w-5" strokeWidth={2} />
Enviar Solicitação
{/if}
</button>
{/if}
</div>
<!-- Botão cancelar -->
<div class="mt-4 text-center">
<button
type="button"
class="btn btn-sm"
onclick={() => {
if (onCancelar) onCancelar();
}}
disabled={processando}
>
Cancelar
</button>
</div>
</div>
</div>
</div>
<!-- Modal de Erro -->
<ErrorModal
open={mostrarModalErro}
title="Período Indisponível"
message={mensagemErroModal || 'Já existe uma solicitação para este período.'}
details={detalhesErroModal}
onClose={fecharModalErro}
/>
<style>
.wizard-ausencia {
max-width: 1000px;
margin: 0 auto;
}
</style>

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,403 +1,227 @@
<script lang="ts"> <script lang="ts">
import { useQuery, useConvexClient } from 'convex-svelte'; import { useQuery, useConvexClient } from "convex-svelte";
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from "@sgse-app/backend/convex/_generated/api";
import { abrirConversa } from '$lib/stores/chatStore'; import { abrirConversa } from "$lib/stores/chatStore";
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from "date-fns";
import { ptBR } from 'date-fns/locale'; import { ptBR } from "date-fns/locale";
import UserStatusBadge from './UserStatusBadge.svelte'; import UserStatusBadge from "./UserStatusBadge.svelte";
import UserAvatar from './UserAvatar.svelte'; import UserAvatar from "./UserAvatar.svelte";
import NewConversationModal from './NewConversationModal.svelte';
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
const client = useConvexClient(); const client = useConvexClient();
// Buscar todos os usuários para o chat
const usuarios = useQuery(api.usuarios.listarParaChat, {});
// Buscar o perfil do usuário logado
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
// Buscar todos os usuários para o chat let searchQuery = $state("");
const usuarios = useQuery(api.usuarios.listarParaChat, {});
// Buscar o perfil do usuário logado // Debug: monitorar carregamento de dados
const meuPerfil = useQuery(api.usuarios.obterPerfil, {}); $effect(() => {
console.log("📊 [ChatList] Usuários carregados:", usuarios?.data?.length || 0);
console.log("👤 [ChatList] Meu perfil:", meuPerfil?.data?.nome || "Carregando...");
console.log("📋 [ChatList] Lista completa:", usuarios?.data);
});
// Buscar conversas (grupos e salas de reunião) const usuariosFiltrados = $derived.by(() => {
const conversas = useQuery(api.chat.listarConversas, {}); if (!usuarios?.data || !Array.isArray(usuarios.data) || !meuPerfil?.data) return [];
const meuId = meuPerfil.data._id;
// Filtrar o próprio usuário da lista
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
// Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
listaFiltrada = listaFiltrada.filter((u: any) =>
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
);
}
// 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;
if (statusA !== statusB) return statusA - statusB;
return a.nome.localeCompare(b.nome);
});
});
let searchQuery = $state(''); function formatarTempo(timestamp: number | undefined): string {
let activeTab = $state<'usuarios' | 'conversas'>('usuarios'); if (!timestamp) return "";
try {
return formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ptBR,
});
} catch {
return "";
}
}
// Debug: monitorar carregamento de dados let processando = $state(false);
$effect(() => {
console.log('📊 [ChatList] Usuários carregados:', usuarios?.data?.length || 0);
console.log('👤 [ChatList] Meu perfil:', meuPerfil?.data?.nome || 'Carregando...');
console.log('🆔 [ChatList] Meu ID:', meuPerfil?.data?._id || 'Não encontrado');
if (usuarios?.data) {
const meuId = meuPerfil?.data?._id;
const meusDadosNaLista = usuarios.data.find((u) => u._id === meuId);
if (meusDadosNaLista) {
console.warn(
'⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!',
meusDadosNaLista.nome
);
}
}
});
let usuariosFiltrados = $derived.by(() => { async function handleClickUsuario(usuario: any) {
if (!usuarios?.data || !Array.isArray(usuarios.data)) return []; if (processando) {
console.log("⏳ Já está processando uma ação, aguarde...");
return;
}
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos try {
if (!meuPerfil?.data) { processando = true;
console.log('⏳ [ChatList] Aguardando perfil do usuário...'); console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
return [];
} // Criar ou buscar conversa individual com este usuário
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
outroUsuarioId: usuario._id,
});
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);
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;
}
}
const meuId = meuPerfil.data._id; function getStatusLabel(status: string | undefined): string {
const labels: Record<string, string> = {
// Filtrar o próprio usuário da lista (filtro de segurança no frontend) online: "Online",
let listaFiltrada = usuarios.data.filter((u) => u._id !== meuId); offline: "Offline",
ausente: "Ausente",
// Log se ainda estiver na lista após filtro (não deveria acontecer) externo: "Externo",
const aindaNaLista = listaFiltrada.find((u) => u._id === meuId); em_reuniao: "Em Reunião",
if (aindaNaLista) { };
console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!'); return labels[status || "offline"] || "Offline";
} }
// Aplicar busca por nome/email/matrícula
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
listaFiltrada = listaFiltrada.filter(
(u) =>
u.nome?.toLowerCase().includes(query) ||
u.email?.toLowerCase().includes(query) ||
u.matricula?.toLowerCase().includes(query)
);
}
// Ordenar: Online primeiro, depois por nome
return listaFiltrada.sort((a, b) => {
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 formatarTempo(timestamp: number | undefined): string {
if (!timestamp) return '';
try {
return formatDistanceToNow(new Date(timestamp), {
addSuffix: true,
locale: ptBR
});
} catch {
return '';
}
}
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
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 Id<'conversas'>);
console.log('✅ Conversa aberta com sucesso!');
} catch (error) {
console.error('❌ Erro ao abrir conversa:', error);
console.error('Detalhes do erro:', {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
usuario: usuario
});
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
} finally {
processando = false;
}
}
function getStatusLabel(status: string | undefined): string {
const labels: Record<string, string> = {
online: 'Online',
offline: 'Offline',
ausente: 'Ausente',
externo: 'Externo',
em_reuniao: 'Em Reunião'
};
return labels[status || 'offline'] || 'Offline';
}
// Filtrar conversas por tipo e busca
let conversasFiltradas = $derived.by(() => {
if (!conversas?.data) return [];
let lista = conversas.data.filter(
(c: Doc<'conversas'>) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao'
);
// Aplicar busca
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
lista = lista.filter((c: Doc<'conversas'>) => c.nome?.toLowerCase().includes(query));
}
return lista;
});
interface Conversa {
_id: Id<'conversas'>;
[key: string]: unknown;
}
function handleClickConversa(conversa: Conversa) {
if (processando) return;
try {
processando = true;
abrirConversa(conversa._id);
} catch (error) {
console.error('Erro ao abrir conversa:', error);
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
} finally {
processando = false;
}
}
</script> </script>
<div class="flex h-full flex-col"> <div class="flex flex-col h-full">
<!-- Search bar --> <!-- Search bar -->
<div class="border-base-300 border-b p-4"> <div class="p-4 border-b border-base-300">
<div class="relative"> <div class="relative">
<input <input
type="text" type="text"
placeholder="Buscar usuários (nome, email, matrícula)..." placeholder="Buscar usuários (nome, email, matrícula)..."
class="input input-bordered w-full pl-10" class="input input-bordered w-full pl-10"
bind:value={searchQuery} bind:value={searchQuery}
aria-label="Buscar usuários ou conversas" />
aria-describedby="search-help" <svg
/> xmlns="http://www.w3.org/2000/svg"
<span id="search-help" class="sr-only" fill="none"
>Digite para buscar usuários por nome, email ou matrícula</span viewBox="0 0 24 24"
> stroke-width="1.5"
<Search stroke="currentColor"
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
strokeWidth={1.5} >
/> <path
</div> stroke-linecap="round"
</div> stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
</div>
</div>
<!-- Tabs e Título --> <!-- Título da Lista -->
<div class="border-base-300 bg-base-200 border-b"> <div class="p-4 border-b border-base-300 bg-base-200">
<!-- Tabs --> <h3 class="font-semibold text-sm text-base-content/70 uppercase tracking-wide">
<div class="tabs tabs-boxed p-2"> Usuários do Sistema ({usuariosFiltrados.length})
<button </h3>
type="button" </div>
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>
<!-- Botão Nova Conversa --> <!-- Lista de usuários -->
<div class="flex justify-end px-4 pb-2"> <div class="flex-1 overflow-y-auto">
<button {#if usuarios?.data && usuariosFiltrados.length > 0}
type="button" {#each usuariosFiltrados as usuario (usuario._id)}
class="btn btn-primary btn-sm" <button
onclick={() => (showNewConversationModal = true)} type="button"
title="Nova conversa (grupo ou sala de reunião)" 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'}"
aria-label="Nova conversa" onclick={() => handleClickUsuario(usuario)}
> disabled={processando}
<Plus class="mr-1 h-4 w-4" strokeWidth={2} /> >
Nova Conversa <!-- Avatar -->
</button> <div class="relative flex-shrink-0">
</div> <UserAvatar
</div> avatar={usuario.avatar}
fotoPerfilUrl={usuario.fotoPerfilUrl}
nome={usuario.nome}
size="md"
/>
<!-- Status badge -->
<div class="absolute bottom-0 right-0">
<UserStatusBadge status={usuario.statusPresenca || "offline"} size="sm" />
</div>
</div>
<!-- Lista de conteúdo --> <!-- Conteúdo -->
<div class="flex-1 overflow-y-auto"> <div class="flex-1 min-w-0">
{#if activeTab === 'usuarios'} <div class="flex items-center justify-between mb-1">
<!-- Lista de usuários --> <p class="font-semibold text-base-content truncate">
{#if usuarios?.data && usuariosFiltrados.length > 0} {usuario.nome}
{#each usuariosFiltrados as usuario (usuario._id)} </p>
<button <span class="text-xs px-2 py-0.5 rounded-full {
type="button" usuario.statusPresenca === 'online' ? 'bg-success/20 text-success' :
class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando usuario.statusPresenca === 'ausente' ? 'bg-warning/20 text-warning' :
? 'cursor-wait opacity-50' usuario.statusPresenca === 'em_reuniao' ? 'bg-error/20 text-error' :
: 'cursor-pointer'}" 'bg-base-300 text-base-content/50'
onclick={() => handleClickUsuario(usuario)} }">
disabled={processando} {getStatusLabel(usuario.statusPresenca)}
aria-label="Abrir conversa com {usuario.nome}" </span>
aria-describedby="usuario-status-{usuario._id}" </div>
> <div class="flex items-center gap-2">
<!-- Ícone de mensagem --> <p class="text-sm text-base-content/70 truncate">
<div {usuario.statusMensagem || usuario.email}
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110" </p>
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);" </div>
> </div>
<MessageSquare class="text-primary h-5 w-5" strokeWidth={2} /> </button>
</div> {/each}
{:else if !usuarios?.data}
<!-- Avatar --> <!-- Loading -->
<div class="relative shrink-0"> <div class="flex items-center justify-center h-full">
<UserAvatar <span class="loading loading-spinner loading-lg"></span>
fotoPerfilUrl={usuario.fotoPerfilUrl} </div>
nome={usuario.nome} {:else}
size="md" <!-- Nenhum usuário encontrado -->
userId={usuario._id} <div class="flex flex-col items-center justify-center h-full text-center px-4">
/> <svg
<!-- Status badge --> xmlns="http://www.w3.org/2000/svg"
<div class="absolute right-0 bottom-0"> fill="none"
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" /> viewBox="0 0 24 24"
</div> stroke-width="1.5"
</div> stroke="currentColor"
class="w-16 h-16 text-base-content/30 mb-4"
<!-- Conteúdo --> >
<div class="min-w-0 flex-1"> <path
<div class="mb-1 flex items-center justify-between"> stroke-linecap="round"
<p class="text-base-content truncate font-semibold"> stroke-linejoin="round"
{usuario.nome} 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"
</p> />
<span </svg>
class="rounded-full px-2 py-0.5 text-xs {usuario.statusPresenca === 'online' <p class="text-base-content/70">Nenhum usuário encontrado</p>
? 'bg-success/20 text-success' </div>
: usuario.statusPresenca === 'ausente' {/if}
? 'bg-warning/20 text-warning' </div>
: usuario.statusPresenca === 'em_reuniao'
? 'bg-error/20 text-error'
: 'bg-base-300 text-base-content/50'}"
>
{getStatusLabel(usuario.statusPresenca)}
</span>
</div>
<div class="flex items-center gap-2">
<p class="text-base-content/70 truncate text-sm">
{usuario.statusMensagem || usuario.email}
</p>
</div>
<span id="usuario-status-{usuario._id}" class="sr-only">
Status: {getStatusLabel(usuario.statusPresenca)}
</span>
</div>
</button>
{/each}
{:else if !usuarios?.data}
<!-- Loading -->
<div class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhum usuário encontrado -->
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
<UsersRound class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
<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="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
? 'cursor-wait opacity-50'
: 'cursor-pointer'}"
onclick={() => handleClickConversa(conversa)}
disabled={processando}
>
<!-- Ícone de grupo/sala -->
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
'sala_reuniao'
? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
: 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
>
{#if conversa.tipo === 'sala_reuniao'}
<UsersRound class="h-5 w-5 text-blue-500" strokeWidth={2} />
{:else}
<Users class="text-primary h-5 w-5" strokeWidth={2} />
{/if}
</div>
<!-- Conteúdo -->
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center justify-between">
<p class="text-base-content truncate font-semibold">
{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="rounded-full px-2 py-0.5 text-xs {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-base-content/50 text-xs">
{conversa.participantesInfo.length} participante{conversa.participantesInfo
.length !== 1
? 's'
: ''}
</span>
{/if}
</div>
</div>
</button>
{/each}
{:else if !conversas?.data}
<!-- Loading -->
<div class="flex h-full items-center justify-center">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<!-- Nenhuma conversa encontrada -->
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
<MessageSquare class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
<p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
<p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
</div>
{/if}
{/if}
</div>
</div> </div>
<!-- Modal de Nova Conversa -->
{#if showNewConversationModal}
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
{/if}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,529 +1,398 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { useQuery, useConvexClient } from "convex-svelte";
import { useQuery, useConvexClient } from 'convex-svelte'; import { api } from "@sgse-app/backend/convex/_generated/api";
import { api } from '@sgse-app/backend/convex/_generated/api'; import { notificacoesCount } from "$lib/stores/chatStore";
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { formatDistanceToNow } from "date-fns";
import { notificacoesCount } from '$lib/stores/chatStore'; import { ptBR } from "date-fns/locale";
import { formatDistanceToNow } from 'date-fns'; import { onMount } from "svelte";
import { ptBR } from 'date-fns/locale'; import { authStore } from "$lib/stores/auth.svelte";
import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff, Trash2, X } from 'lucide-svelte';
// Queries e Client // Queries e Client
const client = useConvexClient(); const client = useConvexClient();
// Query para contar apenas não lidas (para o badge) const notificacoesQuery = useQuery(api.chat.obterNotificacoes, {
const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {}); apenasPendentes: true,
// Query para obter TODAS as notificações (para o popup) });
const todasNotificacoesQuery = useQuery(api.chat.obterNotificacoes, { const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {});
apenasPendentes: false
});
// Usuário atual
const currentUser = useQuery(api.auth.getCurrentUser, {});
let modalOpen = $state(false); let dropdownOpen = $state(false);
let usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null); let notificacoesFerias = $state<any[]>([]);
let notificacoesFerias = $state<
Array<{
_id: Id<'notificacoesFerias'>;
mensagem: string;
tipo: string;
_creationTime: number;
}>
>([]);
let notificacoesAusencias = $state<
Array<{
_id: Id<'notificacoesAusencias'>;
mensagem: string;
tipo: string;
_creationTime: number;
}>
>([]);
let limpandoNotificacoes = $state(false);
// Helpers para obter valores das queries // Helpers para obter valores das queries
let count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0); const count = $derived(
let todasNotificacoes = $derived( (typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0
(Array.isArray(todasNotificacoesQuery) );
? todasNotificacoesQuery const notificacoes = $derived(
: todasNotificacoesQuery?.data) ?? [] (Array.isArray(notificacoesQuery)
); ? notificacoesQuery
: notificacoesQuery?.data) ?? []
);
// Separar notificações lidas e não lidas // Atualizar contador no store
let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida)); $effect(() => {
let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida)); const totalNotificacoes = count + (notificacoesFerias?.length || 0);
let totalCount = $derived(count + (notificacoesFerias?.length || 0)); notificacoesCount.set(totalNotificacoes);
});
// Atualizar contador no store // Buscar notificações de férias
$effect(() => { async function buscarNotificacoesFerias() {
const totalNotificacoes = try {
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0); const usuarioStore = authStore;
$notificacoesCount = totalNotificacoes;
});
// Buscar notificações de férias if (usuarioStore.usuario?._id) {
async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) { const notifsFerias = await client.query(
try { api.ferias.obterNotificacoesNaoLidas,
if (!id) return; {
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, { usuarioId: usuarioStore.usuario._id as any,
usuarioId: id }
}); );
notificacoesFerias = notifsFerias || []; notificacoesFerias = notifsFerias || [];
} catch (e) { }
console.error('Erro ao buscar notificações de férias:', e); } catch (e) {
} console.error("Erro ao buscar notificações de férias:", e);
} }
}
// Buscar notificações de ausências // Atualizar notificações de férias periodicamente
async function buscarNotificacoesAusencias(id: Id<'usuarios'> | null) { $effect(() => {
try { buscarNotificacoesFerias();
if (!id) return; const interval = setInterval(buscarNotificacoesFerias, 30000); // A cada 30s
try { return () => clearInterval(interval);
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, { });
usuarioId: id
});
notificacoesAusencias = notifsAusencias || [];
} catch (queryError: unknown) {
// Silenciar erros de timeout e função não encontrada
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
const isFunctionNotFound = errorMessage.includes('Could not find public function');
if (!isTimeout && !isFunctionNotFound) { function formatarTempo(timestamp: number): string {
console.error('Erro ao buscar notificações de ausências:', queryError); try {
} return formatDistanceToNow(new Date(timestamp), {
notificacoesAusencias = []; addSuffix: true,
} locale: ptBR,
} catch (e) { });
// Erro geral - silenciar se for sobre função não encontrada ou timeout } catch {
const errorMessage = e instanceof Error ? e.message : String(e); return "agora";
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout'); }
const isFunctionNotFound = errorMessage.includes('Could not find public function'); }
if (!isTimeout && !isFunctionNotFound) { async function handleMarcarTodasLidas() {
console.error('Erro ao buscar notificações de ausências:', e); await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
} // Marcar todas as notificações de férias como lidas
} for (const notif of notificacoesFerias) {
} await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notif._id,
});
}
dropdownOpen = false;
await buscarNotificacoesFerias();
}
// Atualizar notificações periodicamente async function handleClickNotificacao(notificacaoId: string) {
onMount(() => { await client.mutation(api.chat.marcarNotificacaoLida, {
void buscarNotificacoesFerias(usuarioId); notificacaoId: notificacaoId as any,
void buscarNotificacoesAusencias(usuarioId); });
dropdownOpen = false;
}
const interval = setInterval(() => { async function handleClickNotificacaoFerias(notificacaoId: string) {
void buscarNotificacoesFerias(usuarioId); await client.mutation(api.ferias.marcarComoLida, {
void buscarNotificacoesAusencias(usuarioId); notificacaoId: notificacaoId as any,
}, 30000); // A cada 30s });
await buscarNotificacoesFerias();
dropdownOpen = false;
// Redirecionar para a página de férias
window.location.href = "/recursos-humanos/ferias";
}
return () => clearInterval(interval); function toggleDropdown() {
}); dropdownOpen = !dropdownOpen;
}
function formatarTempo(timestamp: number): string { // Fechar dropdown ao clicar fora
try { onMount(() => {
return formatDistanceToNow(new Date(timestamp), { function handleClickOutside(event: MouseEvent) {
addSuffix: true, const target = event.target as HTMLElement;
locale: ptBR if (!target.closest(".notification-bell")) {
}); dropdownOpen = false;
} catch { }
return 'agora'; }
}
}
async function handleLimparTodasNotificacoes() { document.addEventListener("click", handleClickOutside);
limpandoNotificacoes = true; return () => document.removeEventListener("click", handleClickOutside);
try { });
await client.mutation(api.chat.limparTodasNotificacoes, {});
await buscarNotificacoesFerias(usuarioId);
await buscarNotificacoesAusencias(usuarioId);
} 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(usuarioId);
await buscarNotificacoesAusencias(usuarioId);
} 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 Id<'notificacoes'>
});
}
async function handleClickNotificacaoFerias(notificacaoId: Id<'notificacoesFerias'>) {
await client.mutation(api.ferias.marcarComoLida, {
notificacaoId: notificacaoId
});
await buscarNotificacoesFerias(usuarioId);
// Redirecionar para a página de férias
window.location.href = '/recursos-humanos/ferias';
}
async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
await client.mutation(api.ausencias.marcarComoLida, {
notificacaoId: notificacaoId
});
await buscarNotificacoesAusencias(usuarioId);
// 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
onMount(() => {
function handleClickOutside(event: MouseEvent) {
if (!modalOpen) return;
const target = event.target as HTMLElement;
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
closeModal();
}
}
function handleEscape(event: KeyboardEvent) {
if (!modalOpen) return;
if (event.key === 'Escape') {
closeModal();
}
}
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleEscape);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleEscape);
};
});
</script> </script>
<div class="notification-bell relative"> <div class="dropdown dropdown-end notification-bell">
<!-- Botão de Notificação (padrão do tema) --> <!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
<div class="indicator"> <button
{#if totalCount > 0} type="button"
<span class="indicator-item badge badge-error badge-sm"> tabindex="0"
{totalCount > 9 ? '9+' : totalCount} class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden group transition-all duration-300 hover:scale-105"
</span> style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
{/if} onclick={toggleDropdown}
<button aria-label="Notificações"
type="button" >
tabindex="0" <!-- Efeito de brilho no hover -->
class="btn ring-base-200 hover:ring-primary/50 size-10 p-0 ring-2 ring-offset-2 transition-all" <div
onclick={openModal} class="absolute inset-0 bg-gradient-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-label="Notificações" ></div>
aria-expanded={modalOpen}
>
<Bell
class="size-6 transition-colors {totalCount > 0 ? 'text-primary' : 'text-base-content/70'}"
style="animation: {totalCount > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
/>
</button>
</div>
<!-- Popup Flutuante de Notificações --> <!-- Anel de pulso sutil -->
{#if modalOpen} <div
<div class="absolute inset-0 rounded-2xl"
class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-100 flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm" style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
style="animation: slideDown 0.2s ease-out;" ></div>
>
<!-- Header -->
<div
class="border-base-300 from-primary/5 to-primary/10 flex items-center justify-between border-b bg-linear-to-r px-6 py-4"
>
<h3 class="text-primary text-2xl font-bold">Notificações</h3>
<div class="flex items-center gap-2">
{#if notificacoesNaoLidas.length > 0}
<button
type="button"
class="btn btn-sm btn-ghost"
onclick={handleLimparNotificacoesNaoLidas}
disabled={limpandoNotificacoes}
>
<Trash2 class="h-4 w-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="h-4 w-4" />
Limpar todas
</button>
{/if}
<button type="button" class="btn btn-sm btn-circle" onclick={closeModal}>
<X class="h-5 w-5" />
</button>
</div>
</div>
<!-- Lista de notificações --> <!-- Glow effect quando tem notificações -->
<div class="flex-1 overflow-y-auto px-2 py-4"> {#if count && count > 0}
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0} <div
<!-- Notificações não lidas --> class="absolute inset-0 rounded-2xl bg-error/30 blur-lg animate-pulse"
{#if notificacoesNaoLidas.length > 0} ></div>
<div class="mb-4"> {/if}
<h4 class="text-primary mb-2 px-2 text-sm font-semibold">Não lidas</h4>
{#each notificacoesNaoLidas as notificacao (notificacao._id)}
<button
type="button"
class="hover:bg-base-200 border-primary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
onclick={() => handleClickNotificacao(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="mt-1 shrink-0">
{#if notificacao.tipo === 'nova_mensagem'}
<Mail class="text-primary h-5 w-5" strokeWidth={1.5} />
{:else if notificacao.tipo === 'mencao'}
<AtSign class="text-warning h-5 w-5" strokeWidth={1.5} />
{:else}
<Users class="text-info h-5 w-5" strokeWidth={1.5} />
{/if}
</div>
<!-- Conteúdo --> <!-- Ícone do sino PREENCHIDO moderno -->
<div class="min-w-0 flex-1"> <svg
{#if notificacao.tipo === 'nova_mensagem' && notificacao.remetente} xmlns="http://www.w3.org/2000/svg"
<p class="text-primary text-sm font-semibold"> viewBox="0 0 24 24"
{notificacao.remetente.nome} fill="currentColor"
</p> class="w-7 h-7 text-white relative z-10 transition-all duration-300 group-hover:scale-110"
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs"> style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count &&
{notificacao.descricao} count > 0
</p> ? 'bell-ring 2s ease-in-out infinite'
{:else if notificacao.tipo === 'mencao' && notificacao.remetente} : 'none'};"
<p class="text-warning text-sm font-semibold"> >
{notificacao.remetente.nome} mencionou você <path
</p> fill-rule="evenodd"
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs"> d="M5.25 9a6.75 6.75 0 0113.5 0v.75c0 2.123.8 4.057 2.118 5.52a.75.75 0 01-.297 1.206c-1.544.57-3.16.99-4.831 1.243a3.75 3.75 0 11-7.48 0 24.585 24.585 0 01-4.831-1.244.75.75 0 01-.298-1.205A8.217 8.217 0 005.25 9.75V9zm4.502 8.9a2.25 2.25 0 104.496 0 25.057 25.057 0 01-4.496 0z"
{notificacao.descricao} clip-rule="evenodd"
</p> />
{:else} </svg>
<p class="text-base-content text-sm font-semibold">
{notificacao.titulo}
</p>
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
{notificacao.descricao}
</p>
{/if}
<p class="text-base-content/50 mt-1 text-xs">
{formatarTempo(notificacao.criadaEm)}
</p>
</div>
<!-- Indicador de não lida --> <!-- Badge premium MODERNO com gradiente -->
<div class="shrink-0"> {#if count + (notificacoesFerias?.length || 0) > 0}
<div class="bg-primary h-2 w-2 rounded-full"></div> {@const totalCount = count + (notificacoesFerias?.length || 0)}
</div> <span
</div> 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"
</button> 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;"
{/each} >
</div> {totalCount > 9 ? "9+" : totalCount}
{/if} </span>
{/if}
</button>
<!-- Notificações lidas --> {#if dropdownOpen}
{#if notificacoesLidas.length > 0} <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div class="mb-4"> <div
<h4 class="text-base-content/60 mb-2 px-2 text-sm font-semibold">Lidas</h4> tabindex="0"
{#each notificacoesLidas as notificacao (notificacao._id)} 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"
<button >
type="button" <!-- Header -->
class="hover:bg-base-200 mb-2 w-full rounded-lg px-4 py-3 text-left opacity-75 transition-colors" <div
onclick={() => handleClickNotificacao(notificacao._id)} class="flex items-center justify-between px-4 py-2 border-b border-base-300"
> >
<div class="flex items-start gap-3"> <h3 class="text-lg font-semibold">Notificações</h3>
<!-- Ícone --> {#if count > 0}
<div class="mt-1 shrink-0"> <button
{#if notificacao.tipo === 'nova_mensagem'} type="button"
<Mail class="text-primary/60 h-5 w-5" strokeWidth={1.5} /> class="btn btn-ghost btn-xs"
{:else if notificacao.tipo === 'mencao'} onclick={handleMarcarTodasLidas}
<AtSign class="text-warning/60 h-5 w-5" strokeWidth={1.5} /> >
{:else} Marcar todas como lidas
<Users class="text-info/60 h-5 w-5" strokeWidth={1.5} /> </button>
{/if} {/if}
</div> </div>
<!-- Conteúdo --> <!-- Lista de notificações -->
<div class="min-w-0 flex-1"> <div class="py-2">
{#if notificacao.tipo === 'nova_mensagem' && notificacao.remetente} {#if notificacoes.length > 0}
<p class="text-primary/70 text-sm font-medium"> {#each notificacoes.slice(0, 10) as notificacao (notificacao._id)}
{notificacao.remetente.nome} <button
</p> type="button"
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs"> class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
{notificacao.descricao} onclick={() => handleClickNotificacao(notificacao._id)}
</p> >
{:else if notificacao.tipo === 'mencao' && notificacao.remetente} <div class="flex items-start gap-3">
<p class="text-warning/70 text-sm font-medium"> <!-- Ícone -->
{notificacao.remetente.nome} mencionou você <div class="flex-shrink-0 mt-1">
</p> {#if notificacao.tipo === "nova_mensagem"}
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs"> <svg
{notificacao.descricao} xmlns="http://www.w3.org/2000/svg"
</p> fill="none"
{:else} viewBox="0 0 24 24"
<p class="text-base-content/70 text-sm font-medium"> stroke-width="1.5"
{notificacao.titulo} stroke="currentColor"
</p> class="w-5 h-5 text-primary"
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs"> >
{notificacao.descricao} <path
</p> stroke-linecap="round"
{/if} stroke-linejoin="round"
<p class="text-base-content/50 mt-1 text-xs"> 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"
{formatarTempo(notificacao.criadaEm)} />
</p> </svg>
</div> {:else if notificacao.tipo === "mencao"}
</div> <svg
</button> xmlns="http://www.w3.org/2000/svg"
{/each} fill="none"
</div> viewBox="0 0 24 24"
{/if} stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-warning"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.5 12a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0Zm0 0c0 1.657 1.007 3 2.25 3S21 13.657 21 12a9 9 0 1 0-2.636 6.364M16.5 12V8.25"
/>
</svg>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-info"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
/>
</svg>
{/if}
</div>
<!-- Notificações de Férias --> <!-- Conteúdo -->
{#if notificacoesFerias.length > 0} <div class="flex-1 min-w-0">
<div class="mb-4"> <p class="text-sm font-medium text-base-content">
<h4 class="text-secondary mb-2 px-2 text-sm font-semibold">Férias</h4> {notificacao.titulo}
{#each notificacoesFerias as notificacao (notificacao._id)} </p>
<button <p class="text-xs text-base-content/70 truncate">
type="button" {notificacao.descricao}
class="hover:bg-base-200 border-secondary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors" </p>
onclick={() => handleClickNotificacaoFerias(notificacao._id)} <p class="text-xs text-base-content/50 mt-1">
> {formatarTempo(notificacao.criadaEm)}
<div class="flex items-start gap-3"> </p>
<!-- Ícone --> </div>
<div class="mt-1 shrink-0">
<Calendar class="text-secondary h-5 w-5" strokeWidth={2} />
</div>
<!-- Conteúdo --> <!-- Indicador de não lida -->
<div class="min-w-0 flex-1"> {#if !notificacao.lida}
<p class="text-base-content text-sm font-medium"> <div class="flex-shrink-0">
{notificacao.mensagem} <div class="w-2 h-2 rounded-full bg-primary"></div>
</p> </div>
<p class="text-base-content/50 mt-1 text-xs"> {/if}
{formatarTempo(notificacao._creationTime)} </div>
</p> </button>
</div> {/each}
{/if}
<!-- Badge --> <!-- Notificações de Férias -->
<div class="shrink-0"> {#if notificacoesFerias.length > 0}
<div class="badge badge-secondary badge-xs"></div> {#if notificacoes.length > 0}
</div> <div class="divider my-2 text-xs">Férias</div>
</div> {/if}
</button> {#each notificacoesFerias.slice(0, 5) as notificacao (notificacao._id)}
{/each} <button
</div> type="button"
{/if} class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors"
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="flex-shrink-0 mt-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5 text-purple-600"
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>
<!-- Notificações de Ausências --> <!-- Conteúdo -->
{#if notificacoesAusencias.length > 0} <div class="flex-1 min-w-0">
<div class="mb-4"> <p class="text-sm font-medium text-base-content">
<h4 class="text-warning mb-2 px-2 text-sm font-semibold">Ausências</h4> {notificacao.mensagem}
{#each notificacoesAusencias as notificacao (notificacao._id)} </p>
<button <p class="text-xs text-base-content/50 mt-1">
type="button" {formatarTempo(notificacao._creationTime)}
class="hover:bg-base-200 border-warning mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors" </p>
onclick={() => handleClickNotificacaoAusencias(notificacao._id)} </div>
>
<div class="flex items-start gap-3">
<!-- Ícone -->
<div class="mt-1 shrink-0">
<Clock class="text-warning h-5 w-5" strokeWidth={2} />
</div>
<!-- Conteúdo --> <!-- Badge -->
<div class="min-w-0 flex-1"> <div class="flex-shrink-0">
<p class="text-base-content text-sm font-medium"> <div class="badge badge-primary badge-xs"></div>
{notificacao.mensagem} </div>
</p> </div>
<p class="text-base-content/50 mt-1 text-xs"> </button>
{formatarTempo(notificacao._creationTime)} {/each}
</p> {/if}
</div>
<!-- Badge --> <!-- Sem notificações -->
<div class="shrink-0"> {#if notificacoes.length === 0 && notificacoesFerias.length === 0}
<div class="badge badge-warning badge-xs"></div> <div class="px-4 py-8 text-center text-base-content/50">
</div> <svg
</div> xmlns="http://www.w3.org/2000/svg"
</button> fill="none"
{/each} viewBox="0 0 24 24"
</div> stroke-width="1.5"
{/if} stroke="currentColor"
{:else} class="w-12 h-12 mx-auto mb-2 opacity-50"
<!-- Sem notificações --> >
<div class="text-base-content/50 px-4 py-12 text-center"> <path
<BellOff class="mx-auto mb-4 h-16 w-16 opacity-50" strokeWidth={1.5} /> stroke-linecap="round"
<p class="text-base font-medium">Nenhuma notificação</p> stroke-linejoin="round"
<p class="mt-1 text-sm">Você está em dia!</p> 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> />
{/if} </svg>
</div> <p class="text-sm">Nenhuma notificação</p>
</div>
<!-- Footer com estatísticas --> {/if}
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0} </div>
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4"> </div>
<div class="text-base-content/60 flex items-center justify-between text-xs"> {/if}
<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> </div>
<style> <style>
@keyframes bell-ring { @keyframes badge-bounce {
0%, 0%,
100% { 100% {
transform: rotate(0deg); transform: scale(1);
} }
10%, 50% {
30% { transform: scale(1.1);
transform: rotate(-10deg); }
} }
20%,
40% {
transform: rotate(10deg);
}
50% {
transform: rotate(0deg);
}
}
@keyframes slideDown { @keyframes pulse-ring-subtle {
from { 0%,
opacity: 0; 100% {
transform: translateY(-10px); opacity: 0.1;
} transform: scale(1);
to { }
opacity: 1; 50% {
transform: translateY(0); 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);
}
}
</style> </style>

View File

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

View File

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

View File

@@ -1,269 +1,381 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { useQuery, useConvexClient } from "convex-svelte";
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import { api } from "@sgse-app/backend/convex/_generated/api";
import { useConvexClient, useQuery } from 'convex-svelte'; import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { format } from 'date-fns'; import { format } from "date-fns";
import { ptBR } from 'date-fns/locale'; import { ptBR } from "date-fns/locale";
import { Clock, Trash2, X } from 'lucide-svelte';
interface Props { interface Props {
conversaId: Id<'conversas'>; conversaId: Id<"conversas">;
onClose: () => void; onClose: () => void;
} }
const { conversaId, onClose }: Props = $props(); let { conversaId, onClose }: Props = $props();
const client = useConvexClient(); const client = useConvexClient();
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, { conversaId });
conversaId
});
let mensagem = $state(''); let mensagem = $state("");
let data = $state(''); let data = $state("");
let hora = $state(''); let hora = $state("");
let loading = $state(false); let loading = $state(false);
// Rastrear mudanças nas mensagens agendadas
$effect(() => {
console.log("📅 [ScheduleModal] Mensagens agendadas atualizadas:", mensagensAgendadas?.data);
});
// Rastrear mudanças nas mensagens agendadas // Definir data/hora mínima (agora)
$effect(() => { const now = new Date();
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data); const minDate = format(now, "yyyy-MM-dd");
}); const minTime = format(now, "HH:mm");
// Definir data/hora mínima (agora) function getPreviewText(): string {
const now = new Date(); if (!data || !hora) return "";
const minDate = format(now, 'yyyy-MM-dd');
const minTime = format(now, 'HH:mm'); try {
const dataHora = new Date(`${data}T${hora}`);
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
} catch {
return "";
}
}
function getPreviewText(): string { async function handleAgendar() {
if (!data || !hora) return ''; if (!mensagem.trim() || !data || !hora) {
alert("Preencha todos os campos");
return;
}
try { try {
const dataHora = new Date(`${data}T${hora}`); loading = true;
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`; const dataHora = new Date(`${data}T${hora}`);
} catch {
return ''; // Validar data futura
} if (dataHora.getTime() <= Date.now()) {
} alert("A data e hora devem ser futuras");
return;
}
async function handleAgendar() { await client.mutation(api.chat.agendarMensagem, {
if (!mensagem.trim() || !data || !hora) { conversaId,
alert('Preencha todos os campos'); conteudo: mensagem.trim(),
return; agendadaPara: dataHora.getTime(),
} });
try { mensagem = "";
loading = true; data = "";
const dataHora = new Date(`${data}T${hora}`); hora = "";
// Dar tempo para o Convex processar e recarregar a lista
setTimeout(() => {
alert("Mensagem agendada com sucesso!");
}, 500);
} catch (error) {
console.error("Erro ao agendar mensagem:", error);
alert("Erro ao agendar mensagem");
} finally {
loading = false;
}
}
// Validar data futura async function handleCancelar(mensagemId: string) {
if (dataHora.getTime() <= Date.now()) { if (!confirm("Deseja cancelar esta mensagem agendada?")) return;
alert('A data e hora devem ser futuras');
return;
}
await client.mutation(api.chat.agendarMensagem, { try {
conversaId, await client.mutation(api.chat.cancelarMensagemAgendada, { mensagemId: mensagemId as any });
conteudo: mensagem.trim(), } catch (error) {
agendadaPara: dataHora.getTime() console.error("Erro ao cancelar mensagem:", error);
}); alert("Erro ao cancelar mensagem");
}
}
mensagem = ''; function formatarDataHora(timestamp: number): string {
data = ''; try {
hora = ''; return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
} catch {
// Dar tempo para o Convex processar e recarregar a lista return "Data inválida";
setTimeout(() => { }
alert('Mensagem agendada com sucesso!'); }
}, 500);
} catch (error) {
console.error('Erro ao agendar mensagem:', error);
alert('Erro ao agendar mensagem');
} finally {
loading = false;
}
}
async function handleCancelar(mensagemId: string) {
if (!confirm('Deseja cancelar esta mensagem agendada?')) return;
try {
await client.mutation(api.chat.cancelarMensagemAgendada, {
mensagemId: mensagemId as any
});
} catch (error) {
console.error('Erro ao cancelar mensagem:', error);
alert('Erro ao cancelar mensagem');
}
}
function formatarDataHora(timestamp: number): string {
try {
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", {
locale: ptBR
});
} catch {
return 'Data inválida';
}
}
</script> </script>
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}> <!-- svelte-ignore a11y_click_events_have_key_events -->
<div <!-- svelte-ignore a11y_no_static_element_interactions -->
class="modal-box flex max-h-[90vh] max-w-2xl flex-col p-0" <div
onclick={(e) => e.stopPropagation()} class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50"
> onclick={onClose}
<!-- Header --> onkeydown={(e) => e.key === 'Escape' && onClose()}
<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"> <!-- svelte-ignore a11y_no_static_element_interactions -->
<Clock class="text-primary h-5 w-5" /> <div
Agendar Mensagem class="bg-base-100 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] flex flex-col m-4"
</h2> onclick={(e) => e.stopPropagation()}
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar"> role="dialog"
<X class="h-5 w-5" /> aria-modal="true"
</button> aria-labelledby="modal-title"
</div> tabindex="-1"
>
<!-- Header ULTRA MODERNO -->
<div class="flex items-center justify-between px-6 py-5 relative overflow-hidden" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);">
<!-- Efeitos de fundo -->
<div class="absolute inset-0 opacity-20" style="background: linear-gradient(45deg, transparent 30%, rgba(255,255,255,0.1) 50%, transparent 70%); animation: shimmer 3s infinite;"></div>
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-3 text-white relative z-10">
<!-- Ícone moderno de relógio -->
<div class="relative flex items-center justify-center w-10 h-10 rounded-xl" style="background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); box-shadow: 0 4px 12px rgba(0,0,0,0.1);">
<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"
>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
</div>
<span style="text-shadow: 0 2px 8px rgba(0,0,0,0.3);">Agendar Mensagem</span>
</h2>
<!-- Botão fechar moderno -->
<button
type="button"
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden z-10"
style="background: rgba(255,255,255,0.15); backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.2);"
onclick={onClose}
aria-label="Fechar"
>
<div class="absolute inset-0 bg-red-500/0 group-hover:bg-red-500/30 transition-colors duration-300"></div>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:rotate-90 transition-all duration-300"
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
>
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<!-- Content --> <!-- Content -->
<div class="flex-1 space-y-6 overflow-y-auto p-6"> <div class="flex-1 overflow-y-auto p-6 space-y-6">
<!-- Formulário de Agendamento --> <!-- Formulário de Agendamento -->
<div class="card bg-base-200"> <div class="card bg-base-200">
<div class="card-body"> <div class="card-body">
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3> <h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
<div class="form-control">
<label class="label" for="mensagem-input">
<span class="label-text">Mensagem</span>
</label>
<textarea
id="mensagem-input"
class="textarea textarea-bordered h-24"
placeholder="Digite a mensagem..."
bind:value={mensagem}
maxlength="500"
aria-describedby="char-count"
></textarea>
<div class="label">
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
</div>
</div>
<div class="form-control"> <div class="grid md:grid-cols-2 gap-4">
<label class="label" for="mensagem-input"> <div class="form-control">
<span class="label-text">Mensagem</span> <label class="label" for="data-input">
</label> <span class="label-text">Data</span>
<textarea </label>
id="mensagem-input" <input
class="textarea textarea-bordered h-24" id="data-input"
placeholder="Digite a mensagem..." type="date"
bind:value={mensagem} class="input input-bordered"
maxlength="500" bind:value={data}
aria-describedby="char-count" min={minDate}
></textarea> />
<div class="label"> </div>
<span id="char-count" class="label-text-alt">{mensagem.length}/500</span>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2"> <div class="form-control">
<div class="form-control"> <label class="label" for="hora-input">
<label class="label" for="data-input"> <span class="label-text">Hora</span>
<span class="label-text">Data</span> </label>
</label> <input
<input id="hora-input"
id="data-input" type="time"
type="date" class="input input-bordered"
class="input input-bordered" bind:value={hora}
bind:value={data} min={data === minDate ? minTime : undefined}
min={minDate} />
/> </div>
</div> </div>
<div class="form-control"> {#if getPreviewText()}
<label class="label" for="hora-input"> <div class="alert alert-info">
<span class="label-text">Hora</span> <svg
</label> xmlns="http://www.w3.org/2000/svg"
<input fill="none"
id="hora-input" viewBox="0 0 24 24"
type="time" stroke-width="1.5"
class="input input-bordered" stroke="currentColor"
bind:value={hora} class="w-6 h-6"
min={data === minDate ? minTime : undefined} >
/> <path
</div> stroke-linecap="round"
</div> stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<span>{getPreviewText()}</span>
</div>
{/if}
{#if getPreviewText()} <div class="card-actions justify-end">
<div class="alert alert-info"> <!-- Botão AGENDAR ultra moderno -->
<Clock class="h-6 w-6" /> <button
<span>{getPreviewText()}</span> type="button"
</div> class="relative px-6 py-3 rounded-xl font-bold text-white overflow-hidden transition-all duration-300 group disabled:opacity-50 disabled:cursor-not-allowed"
{/if} 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 group-hover:bg-white/10 transition-colors duration-300"></div>
<div class="relative z-10 flex items-center gap-2">
{#if loading}
<span class="loading loading-spinner loading-sm"></span>
<span>Agendando...</span>
{:else}
<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 group-hover:scale-110 transition-transform"
>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
<span class="group-hover:scale-105 transition-transform">Agendar</span>
{/if}
</div>
</button>
</div>
</div>
</div>
<div class="card-actions justify-end"> <!-- Lista de Mensagens Agendadas -->
<!-- Botão AGENDAR ultra moderno --> <div class="card bg-base-200">
<button <div class="card-body">
type="button" <h3 class="card-title text-lg">Mensagens Agendadas</h3>
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);" {#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
onclick={handleAgendar} <div class="space-y-3">
disabled={loading || !mensagem.trim() || !data || !hora} {#each mensagensAgendadas.data as msg (msg._id)}
> <div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
<!-- Efeito de brilho no hover --> <div class="flex-shrink-0 mt-1">
<div <svg
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10" xmlns="http://www.w3.org/2000/svg"
></div> fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5 text-primary"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-base-content/80">
{formatarDataHora(msg.agendadaPara || 0)}
</p>
<p class="text-sm text-base-content mt-1 line-clamp-2">
{msg.conteudo}
</p>
</div>
<div class="relative z-10 flex items-center gap-2"> <!-- Botão cancelar moderno -->
{#if loading} <button
<span class="loading loading-spinner loading-sm"></span> type="button"
<span>Agendando...</span> class="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
{:else} style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
<Clock class="h-5 w-5 transition-transform group-hover:scale-110" /> onclick={() => handleCancelar(msg._id)}
<span class="transition-transform group-hover:scale-105">Agendar</span> aria-label="Cancelar"
{/if} >
</div> <div class="absolute inset-0 bg-error/0 group-hover:bg-error/20 transition-colors duration-300"></div>
</button> <svg
</div> xmlns="http://www.w3.org/2000/svg"
</div> viewBox="0 0 24 24"
</div> fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform"
>
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas?.data}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-center py-8 text-base-content/50">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-12 h-12 mx-auto mb-2 opacity-50"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
<p class="text-sm">Nenhuma mensagem agendada</p>
</div>
{/if}
</div>
</div>
</div>
</div>
</div>
<!-- Lista de Mensagens Agendadas --> <style>
<div class="card bg-base-200"> /* Efeito shimmer para o header */
<div class="card-body"> @keyframes shimmer {
<h3 class="card-title text-lg">Mensagens Agendadas</h3> 0% {
transform: translateX(-100%);
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0} }
<div class="space-y-3"> 100% {
{#each mensagensAgendadas.data as msg (msg._id)} transform: translateX(100%);
<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" /> </style>
</div>
<div class="min-w-0 flex-1">
<p class="text-base-content/80 text-sm font-medium">
{formatarDataHora(msg.agendadaPara || 0)}
</p>
<p class="text-base-content mt-1 line-clamp-2 text-sm">
{msg.conteudo}
</p>
</div>
<!-- Botão cancelar moderno -->
<button
type="button"
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
style="background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2);"
onclick={() => handleCancelar(msg._id)}
aria-label="Cancelar"
>
<div
class="bg-error/0 group-hover:bg-error/20 absolute inset-0 transition-colors duration-300"
></div>
<Trash2
class="text-error relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
/>
</button>
</div>
{/each}
</div>
{:else if !mensagensAgendadas?.data}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else}
<div class="text-base-content/50 py-8 text-center">
<Clock class="mx-auto mb-2 h-12 w-12 opacity-50" />
<p class="text-sm">Nenhuma mensagem agendada</p>
</div>
{/if}
</div>
</div>
</div>
</div>
<form method="dialog" class="modal-backdrop">
<button type="button" onclick={onClose}>fechar</button>
</form>
</dialog>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,157 +0,0 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import { useConvexClient } from 'convex-svelte';
import { AlertCircle, CheckCircle2, Clock } from 'lucide-svelte';
import { onDestroy, onMount } from 'svelte';
import { obterTempoPC, obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
const client = useConvexClient();
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, {});
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
// Se não estiver configurado, usar null e tratar como 0
const gmtOffset = config.gmtOffset ?? 0;
let timestampBase: number;
if (config.usarServidorExterno) {
try {
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
if (resultado.sucesso && resultado.timestamp) {
timestampBase = resultado.timestamp;
sincronizado = true;
usandoServidorExterno = resultado.usandoServidorExterno || false;
offsetSegundos = resultado.offsetSegundos || 0;
erro = null;
} else {
throw new Error('Falha ao sincronizar');
}
} catch (error) {
console.warn('Erro ao sincronizar:', error);
if (config.fallbackParaPC) {
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando relógio do PC (falha na sincronização)';
} else {
throw error;
}
}
} else {
// Usar relógio do PC (sem sincronização com servidor)
timestampBase = obterTempoPC();
sincronizado = false;
usandoServidorExterno = false;
erro = 'Usando relógio do PC';
}
// Aplicar GMT offset ao timestamp UTC
// O offset é aplicado manualmente, então usamos UTC como base para evitar conversão dupla
let timestampAjustado: number;
if (gmtOffset !== 0) {
// Aplicar offset configurado ao timestamp UTC
timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000;
} else {
// Quando GMT = 0, manter timestamp UTC puro
timestampAjustado = timestampBase;
}
// Armazenar o timestamp ajustado (não o Date, para evitar problemas de timezone)
tempoAtual = new Date(timestampAjustado);
} catch (error) {
console.error('Erro ao obter tempo:', error);
tempoAtual = new Date(obterTempoPC());
sincronizado = false;
erro = 'Erro ao obter tempo do servidor';
}
}
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(() => {
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
// Isso evita conversão dupla pelo navegador
return tempoAtual.toLocaleTimeString('pt-BR', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
});
});
const dataFormatada = $derived.by(() => {
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
// Isso evita conversão dupla pelo navegador
return tempoAtual.toLocaleDateString('pt-BR', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
});
});
</script>
<div class="flex w-full flex-col items-center gap-4">
<!-- Hora -->
<div class="text-primary font-mono text-5xl font-black tracking-tight drop-shadow-sm">
{horaFormatada}
</div>
<!-- Data -->
<div class="text-base-content/80 text-base font-semibold capitalize">
{dataFormatada}
</div>
<!-- Status de Sincronização -->
<div
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizado
? 'bg-success/20 text-success border-success/30 border'
: erro
? 'bg-warning/20 text-warning border-warning/30 border'
: 'bg-base-300/50 text-base-content/60 border-base-300 border'}"
>
{#if sincronizado}
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">
{#if usandoServidorExterno}
Sincronizado com servidor NTP
{:else}
Sincronizado com servidor
{/if}
</span>
{:else if erro}
<AlertCircle class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">{erro}</span>
{:else}
<Clock class="h-4 w-4" strokeWidth={2.5} />
<span class="text-sm font-semibold">Usando relógio do PC</span>
{/if}
</div>
</div>

View File

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

View File

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

View File

@@ -1,163 +0,0 @@
<script lang="ts">
import { ChevronUp, ChevronDown } from 'lucide-svelte';
interface Props {
hours: number;
minutes: number;
onChange: (hours: number, minutes: number) => void;
label?: string;
disabled?: boolean;
}
let { hours, minutes, onChange, label, disabled = false }: Props = $props();
function incrementHours() {
if (disabled) return;
const newHours = hours + 1;
onChange(newHours, minutes);
}
function decrementHours() {
if (disabled) return;
const newHours = Math.max(0, hours - 1);
onChange(newHours, minutes);
}
function incrementMinutes() {
if (disabled) return;
const newMinutes = minutes + 15;
if (newMinutes >= 60) {
const extraHours = Math.floor(newMinutes / 60);
const remainingMinutes = newMinutes % 60;
onChange(hours + extraHours, remainingMinutes);
} else {
onChange(hours, newMinutes);
}
}
function decrementMinutes() {
if (disabled) return;
const newMinutes = minutes - 15;
if (newMinutes < 0) {
if (hours > 0) {
onChange(hours - 1, 60 + newMinutes);
} else {
onChange(0, 0);
}
} else {
onChange(hours, newMinutes);
}
}
function handleHoursInput(e: Event) {
if (disabled) return;
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 0;
onChange(Math.max(0, value), minutes);
}
function handleMinutesInput(e: Event) {
if (disabled) return;
const target = e.target as HTMLInputElement;
const value = parseInt(target.value) || 0;
const clampedValue = Math.max(0, Math.min(59, value));
onChange(hours, clampedValue);
}
const totalMinutes = $derived(hours * 60 + minutes);
const displayText = $derived.by(() => {
if (totalMinutes === 0) return '0h 0min';
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
return `${h}h ${m}min`;
});
</script>
<div class="time-picker">
{#if label}
<div class="mb-2 block text-sm font-medium text-gray-700">{label}</div>
{/if}
<div class="flex items-center gap-3">
<!-- Horas -->
<div class="flex flex-col items-center">
<button
type="button"
onclick={incrementHours}
disabled={disabled}
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp class="h-4 w-4 text-gray-600" />
</button>
<input
type="number"
min="0"
value={hours}
oninput={handleHoursInput}
disabled={disabled}
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<button
type="button"
onclick={decrementHours}
disabled={disabled || hours === 0}
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown class="h-4 w-4 text-gray-600" />
</button>
<span class="mt-1 text-xs text-gray-500">horas</span>
</div>
<!-- Separador -->
<div class="text-2xl font-bold text-gray-400">:</div>
<!-- Minutos -->
<div class="flex flex-col items-center">
<button
type="button"
onclick={incrementMinutes}
disabled={disabled}
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronUp class="h-4 w-4 text-gray-600" />
</button>
<input
type="number"
min="0"
max="59"
value={minutes}
oninput={handleMinutesInput}
disabled={disabled}
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
/>
<button
type="button"
onclick={decrementMinutes}
disabled={disabled || (hours === 0 && minutes === 0)}
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
>
<ChevronDown class="h-4 w-4 text-gray-600" />
</button>
<span class="mt-1 text-xs text-gray-500">min</span>
</div>
<!-- Total -->
<div class="ml-4 flex flex-col items-center justify-center rounded-lg bg-primary/10 px-4 py-2">
<span class="text-xs text-gray-600">Total</span>
<span class="text-lg font-bold text-primary">{displayText}</span>
</div>
</div>
</div>
<style>
.time-picker input[type='number']::-webkit-inner-spin-button,
.time-picker input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.time-picker input[type='number'] {
-moz-appearance: textfield;
}
</style>

View File

@@ -1,722 +0,0 @@
<script lang="ts">
import { AlertCircle, Camera, Check, X } from 'lucide-svelte';
import { onDestroy, onMount } from 'svelte';
import { capturarWebcamComPreview, validarWebcamDisponivel } 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);
// Flag para evitar múltiplas chamadas de play() simultâneas
let playEmAndamento = $state(false);
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
$effect(() => {
if (stream && videoElement && !playEmAndamento) {
// Sempre atualizar srcObject quando o stream mudar
if (videoElement.srcObject !== stream) {
videoElement.srcObject = stream;
}
// Tentar reproduzir se ainda não estiver pronto e não houver outra chamada em andamento
if (!videoReady && videoElement.readyState < 2) {
// Verificar se já não está reproduzindo
if (!videoElement.paused && videoElement.readyState >= 2) {
videoReady = true;
return;
}
playEmAndamento = true;
videoElement
.play()
.then(() => {
playEmAndamento = false;
// 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) => {
playEmAndamento = false;
// Ignorar AbortError - é esperado quando há uma nova requisição de load
if (err.name !== 'AbortError') {
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));
}
}
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 apenas se não estiver já reproduzindo
if (videoElement.paused) {
playEmAndamento = true;
videoElement
.play()
.then(() => {
playEmAndamento = false;
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) => {
playEmAndamento = false;
// Ignorar AbortError - é esperado quando há uma nova requisição de load
if (err.name !== 'AbortError') {
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);
}
});
} else {
// Já está reproduzindo, apenas verificar se está pronto
if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
setTimeout(() => {
onLoadedMetadata();
}, 300);
}
}
});
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 w-full flex-col items-center gap-4 p-4">
{#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-base-content/70 text-center text-sm">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 w-full flex-col items-center gap-4">
{#if autoCapture}
<!-- Modo automático: mostrar apenas preview sem botões -->
<div class="text-base-content/70 mb-2 text-center text-sm">
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 flex-wrap justify-center gap-2">
<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 w-full flex-col items-center gap-4">
{#if autoCapture}
<div class="text-base-content/70 mb-2 text-center text-sm">
Capturando foto automaticamente...
</div>
{:else}
<div class="text-base-content/70 mb-2 text-center text-sm">
Posicione-se na frente da câmera e clique em "Capturar Foto"
</div>
{/if}
<div class="relative flex w-full justify-center">
<video
bind:this={videoElement}
autoplay
playsinline
muted
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 bg-black object-contain {!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 gap-2 rounded-lg bg-black/70"
>
<span class="loading loading-spinner loading-lg text-white"></span>
<span class="text-sm text-white">Carregando câmera...</span>
</div>
{:else if videoReady && webcamDisponivel}
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 transform">
<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 flex-wrap justify-center gap-2">
<button
class="btn btn-primary btn-lg"
onclick={capturar}
disabled={capturando || !videoReady || !webcamDisponivel}
>
{#if capturando}
<span class="loading loading-spinner loading-sm"></span>
Capturando...
{:else}
<Camera class="h-5 w-5" />
Capturar Foto
{/if}
</button>
<button class="btn btn-outline" onclick={cancelar}>
<X class="h-5 w-5" />
Cancelar
</button>
</div>
{/if}
</div>
{/if}
</div>

View File

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

View File

@@ -1,104 +0,0 @@
<script lang="ts">
import { BarChart3, CheckCircle2, XCircle, Users } from 'lucide-svelte';
interface Estatisticas {
totalRegistros: number;
dentroDoPrazo: number;
foraDoPrazo: number;
totalFuncionarios: number;
funcionariosDentroPrazo: number;
funcionariosForaPrazo: number;
}
interface Props {
estatisticas?: Estatisticas;
}
let { estatisticas = undefined }: Props = $props();
</script>
{#if estatisticas}
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
<!-- Total de Registros -->
<div
class="card transform border border-blue-500/20 bg-gradient-to-br from-blue-500/10 to-blue-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Total de Registros</p>
<p class="text-base-content text-3xl font-bold">{estatisticas.totalRegistros}</p>
</div>
<div class="rounded-xl bg-blue-500/20 p-3">
<BarChart3 class="h-8 w-8 text-blue-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Dentro do Prazo -->
<div
class="card transform border border-green-500/20 bg-gradient-to-br from-green-500/10 to-green-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Dentro do Prazo</p>
<p class="text-3xl font-bold text-green-600">{estatisticas.dentroDoPrazo}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.totalRegistros > 0
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="rounded-xl bg-green-500/20 p-3">
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Fora do Prazo -->
<div
class="card transform border border-red-500/20 bg-gradient-to-br from-red-500/10 to-red-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Fora do Prazo</p>
<p class="text-3xl font-bold text-red-600">{estatisticas.foraDoPrazo}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.totalRegistros > 0
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
: 0}% do total
</p>
</div>
<div class="rounded-xl bg-red-500/20 p-3">
<XCircle class="h-8 w-8 text-red-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
<!-- Funcionários -->
<div
class="card transform border border-purple-500/20 bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
>
<div class="card-body">
<div class="flex items-center justify-between">
<div class="flex-1">
<p class="text-base-content/70 mb-1 text-sm font-semibold">Funcionários</p>
<p class="text-3xl font-bold text-purple-600">{estatisticas.totalFuncionarios}</p>
<p class="text-base-content/60 mt-1 text-xs">
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
</p>
</div>
<div class="rounded-xl bg-purple-500/20 p-3">
<Users class="h-8 w-8 text-purple-600" strokeWidth={2.5} />
</div>
</div>
</div>
</div>
</div>
{/if}

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