Compare commits
155 Commits
revert-12-
...
refinament
| Author | SHA1 | Date | |
|---|---|---|---|
| 409872352c | |||
| d4a3214451 | |||
| 649b9b145c | |||
| 1089a4fdab | |||
| a3eab60fcd | |||
| ae4fc1c4d5 | |||
| 51096e7aff | |||
| 00e18e79ec | |||
| 1ad0ee91cb | |||
| 35e7c10ed0 | |||
| db2daacdad | |||
| e0b01cff0a | |||
| dfc975cb8f | |||
| ac8e8f56b8 | |||
| 095f041891 | |||
| 467e04b605 | |||
| 2d7761ee94 | |||
| 90e81e4667 | |||
| 5b41d35b6f | |||
| aeaa3c903f | |||
| 031552c836 | |||
| 37d7318d5a | |||
| 58ac3a4f1b | |||
| dc799504f6 | |||
| 80fc8bc82c | |||
| f818756efc | |||
| fc4b5c5ba5 | |||
| 9dc816977d | |||
| c056506ce5 | |||
| 3cc35d3a1e | |||
|
|
7871b87bb9 | ||
| b8a2e67f3a | |||
| 54089f5eca | |||
| 52823a9fac | |||
| 41f7942dd1 | |||
| f167996a9f | |||
| 36dcdf76ce | |||
| 21783de25f | |||
| a0fcb1571c | |||
| d2959fc163 | |||
| 5122eacddd | |||
| 1f48247493 | |||
| 9d2f6e7c79 | |||
| 8fc3cf08c4 | |||
| ce94eb53b3 | |||
| c5e83464ba | |||
| 2792424454 | |||
| 54535af9f7 | |||
| fd158f164d | |||
| b2c15cf967 | |||
| d6aaa15cf4 | |||
|
|
74049c25ae | ||
| aa8dab6fd5 | |||
| 3da364fb02 | |||
| 0af8daa901 | |||
| d50760f0db | |||
|
|
209a3e088d | ||
| 51e2efa07e | |||
| e029cd1d6b | |||
| 8ea5c0316b | |||
| 9451e69d68 | |||
| bc1e08914b | |||
| 57c37fedef | |||
| db61df1fb4 | |||
|
|
7fede4a992 | ||
| 1c06519108 | |||
| eb95448604 | |||
| dac559d9fd | |||
| c7fd824138 | |||
| 3cbe02fd1e | |||
|
|
dcbc494d87 | ||
| 263d561301 | |||
| 372feed819 | |||
| ed5695cf28 | |||
| 7cdc726781 | |||
| f465bd973e | |||
| b660d123d4 | |||
| d16f76daeb | |||
| b8506b6d45 | |||
| 67d6b3ec72 | |||
| b01d2d6786 | |||
| b844260399 | |||
| f0c6e4468f | |||
| 801a39d221 | |||
| 029cd9c637 | |||
| 52123a33b3 | |||
| 031c151967 | |||
| af6353fa40 | |||
| 22e77d8890 | |||
| db098ceea9 | |||
| 422dc6f022 | |||
| 3420872a37 | |||
| 71550874ce | |||
| 7c8be8a818 | |||
| 0e5a26b5fd | |||
| f021e96eb4 | |||
| 2c3d231d20 | |||
| 2b94b56f6e | |||
| d4e70b5e52 | |||
| 8a613128a5 | |||
| 99258d620f | |||
| 917a1d03ca | |||
| 3d9e8ae1f8 | |||
| d173e2a255 | |||
| 7e3c100fb9 | |||
| 05d3a394da | |||
| 29118d22ce | |||
| 55847e2a77 | |||
| 5ef6ef8550 | |||
| fb784d6f7e | |||
| 24b8eb6a14 | |||
| 60e0bfa69e | |||
| 70d405d98d | |||
| 88983ea297 | |||
| ea01e2401a | |||
| 118051ad56 | |||
| 9b3b095c01 | |||
|
|
2420aafdba | ||
| d8da7e2a05 | |||
| b503045b41 | |||
| 3c371bc35c | |||
| 731f95d0b5 | |||
| 5b2c682870 | |||
|
|
33a9c9e81d | ||
| aa0b03ed3f | |||
| 73d550aa96 | |||
| c058865817 | |||
|
|
fe68dd9d11 | ||
| 1fea5f1f26 | |||
|
|
b65cf5b4a6 | ||
| 4ae5baffcc | |||
| fd7d3729c1 | |||
| ebde59c6d2 | |||
| 0b7f1ad621 | |||
| 4ffa403c46 | |||
| bd574aedc0 | |||
| a2451baafc | |||
| 34e4835c81 | |||
|
|
9822343561 | ||
| 11eef4aa2a | |||
| 94f4b23a39 | |||
| 3a783727dc | |||
| 553fc578a6 | |||
| 87b59af8da | |||
| 6087990eaf | |||
|
|
67ea8bd695 | ||
| da26a21f7e | |||
| 90bc5771ae | |||
| 1c56d71d43 | |||
| 9bb13b486e | |||
| 349a7bb1e4 | |||
|
|
c6e6ec4823 | ||
|
|
11543db953 | ||
| 2fb934ba18 | |||
|
|
81d96b8d88 |
275
.agent/rules/convex-svelte-guidelines.md
Normal file
275
.agent/rules/convex-svelte-guidelines.md
Normal file
@@ -0,0 +1,275 @@
|
||||
---
|
||||
trigger: glob
|
||||
globs: **/*.svelte, **/*.ts, **/*.svelte.ts
|
||||
---
|
||||
|
||||
# Convex + Svelte Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
These guidelines describe how to write **Convex** backend code **and** consume it from a **Svelte** (SvelteKit) frontend. The syntax for Convex functions stays exactly the same, but the way you import and call them from the client differs from a React/Next.js project. Below you will find the adapted sections from the original Convex style guide with Svelte‑specific 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** (Node‑only) 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. Svelte‑Specific Tips
|
||||
|
||||
| Topic | Recommendation |
|
||||
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Store‑based 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 client‑side 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._
|
||||
28
.agent/rules/svelte-rules.md
Normal file
28
.agent/rules/svelte-rules.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
trigger: glob
|
||||
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 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.
|
||||
189
.cursor/commands/svelte-task.md
Normal file
189
.cursor/commands/svelte-task.md
Normal file
@@ -0,0 +1,189 @@
|
||||
You are a Svelte expert tasked to build components and utilities for Svelte developers. If you need documentation for anything related to Svelte you can invoke the tool `get_documentation` with one of the following paths:
|
||||
<available-docs>
|
||||
|
||||
- title: Overview, use_cases: project setup, creating new svelte apps, scaffolding, cli tools, initializing projects, path: cli/overview
|
||||
- title: Frequently asked questions, use_cases: project setup, initializing new svelte projects, troubleshooting cli installation, package manager configuration, path: cli/faq
|
||||
- title: sv create, use_cases: project setup, starting new sveltekit app, initializing project, creating from playground, choosing project template, path: cli/sv-create
|
||||
- title: sv add, use_cases: project setup, adding features to existing projects, integrating tools, testing setup, styling setup, authentication, database setup, deployment adapters, path: cli/sv-add
|
||||
- title: sv check, use_cases: code quality, ci/cd pipelines, error checking, typescript projects, pre-commit hooks, finding unused css, accessibility auditing, production builds, path: cli/sv-check
|
||||
- title: sv migrate, use_cases: migration, upgrading svelte versions, upgrading sveltekit versions, modernizing codebase, svelte 3 to 4, svelte 4 to 5, sveltekit 1 to 2, adopting runes, refactoring deprecated apis, path: cli/sv-migrate
|
||||
- title: devtools-json, use_cases: development setup, chrome devtools integration, browser-based editing, local development workflow, debugging setup, path: cli/devtools-json
|
||||
- title: drizzle, use_cases: database setup, sql queries, orm integration, data modeling, postgresql, mysql, sqlite, server-side data access, database migrations, type-safe queries, path: cli/drizzle
|
||||
- title: eslint, use_cases: code quality, linting, error detection, project setup, code standards, team collaboration, typescript projects, path: cli/eslint
|
||||
- title: lucia, use_cases: authentication, login systems, user management, registration pages, session handling, auth setup, path: cli/lucia
|
||||
- title: mcp, use_cases: use title and path to estimate use case, path: cli/mcp
|
||||
- title: mdsvex, use_cases: blog, content sites, markdown rendering, documentation sites, technical writing, cms integration, article pages, path: cli/mdsvex
|
||||
- title: paraglide, use_cases: internationalization, multi-language sites, i18n, translation, localization, language switching, global apps, multilingual content, path: cli/paraglide
|
||||
- title: playwright, use_cases: browser testing, e2e testing, integration testing, test automation, quality assurance, ci/cd pipelines, testing user flows, path: cli/playwright
|
||||
- title: prettier, use_cases: code formatting, project setup, code style consistency, team collaboration, linting configuration, path: cli/prettier
|
||||
- title: storybook, use_cases: component development, design systems, ui library, isolated component testing, documentation, visual testing, component showcase, path: cli/storybook
|
||||
- title: sveltekit-adapter, use_cases: deployment, production builds, hosting setup, choosing deployment platform, configuring adapters, static site generation, node server, vercel, cloudflare, netlify, path: cli/sveltekit-adapter
|
||||
- title: tailwindcss, use_cases: project setup, styling, css framework, rapid prototyping, utility-first css, design systems, responsive design, adding tailwind to svelte, path: cli/tailwind
|
||||
- title: vitest, use_cases: testing, unit tests, component testing, test setup, quality assurance, ci/cd pipelines, test-driven development, path: cli/vitest
|
||||
- title: Introduction, use_cases: learning sveltekit, project setup, understanding framework basics, choosing between svelte and sveltekit, getting started with full-stack apps, path: kit/introduction
|
||||
- title: Creating a project, use_cases: project setup, starting new sveltekit app, initial development environment, first-time sveltekit users, scaffolding projects, path: kit/creating-a-project
|
||||
- title: Project types, use_cases: deployment, project setup, choosing adapters, ssg, spa, ssr, serverless, mobile apps, desktop apps, pwa, offline apps, browser extensions, separate backend, docker containers, path: kit/project-types
|
||||
- title: Project structure, use_cases: project setup, understanding file structure, organizing code, starting new project, learning sveltekit basics, path: kit/project-structure
|
||||
- title: Web standards, use_cases: always, any sveltekit project, data fetching, forms, api routes, server-side rendering, deployment to various platforms, path: kit/web-standards
|
||||
- title: Routing, use_cases: routing, navigation, multi-page apps, project setup, file structure, api endpoints, data loading, layouts, error pages, always, path: kit/routing
|
||||
- title: Loading data, use_cases: data fetching, api calls, database queries, dynamic routes, page initialization, loading states, authentication checks, ssr data, form data, content rendering, path: kit/load
|
||||
- title: Form actions, use_cases: forms, user input, data submission, authentication, login systems, user registration, progressive enhancement, validation errors, path: kit/form-actions
|
||||
- title: Page options, use_cases: prerendering static sites, ssr configuration, spa setup, client-side rendering control, url trailing slash handling, adapter deployment config, build optimization, path: kit/page-options
|
||||
- title: State management, use_cases: sveltekit, server-side rendering, ssr, state management, authentication, data persistence, load functions, context api, navigation, component lifecycle, path: kit/state-management
|
||||
- title: Remote functions, use_cases: data fetching, server-side logic, database queries, type-safe client-server communication, forms, user input, mutations, authentication, crud operations, optimistic updates, path: kit/remote-functions
|
||||
- title: Building your app, use_cases: production builds, deployment preparation, build process optimization, adapter configuration, preview before deployment, path: kit/building-your-app
|
||||
- title: Adapters, use_cases: deployment, production builds, hosting setup, choosing deployment platform, configuring adapters, path: kit/adapters
|
||||
- title: Zero-config deployments, use_cases: deployment, production builds, hosting setup, choosing deployment platform, ci/cd configuration, path: kit/adapter-auto
|
||||
- title: Node servers, use_cases: deployment, production builds, node.js hosting, custom server setup, environment configuration, reverse proxy setup, docker deployment, systemd services, path: kit/adapter-node
|
||||
- title: Static site generation, use_cases: static site generation, ssg, prerendering, deployment, github pages, spa mode, blogs, documentation sites, marketing sites, path: kit/adapter-static
|
||||
- title: Single-page apps, use_cases: spa mode, single-page apps, client-only rendering, static hosting, mobile app wrappers, no server-side logic, adapter-static setup, fallback pages, path: kit/single-page-apps
|
||||
- title: Cloudflare, use_cases: deployment, cloudflare workers, cloudflare pages, hosting setup, production builds, serverless deployment, edge computing, path: kit/adapter-cloudflare
|
||||
- title: Cloudflare Workers, use_cases: deploying to cloudflare workers, cloudflare workers sites deployment, legacy cloudflare adapter, wrangler configuration, cloudflare platform bindings, path: kit/adapter-cloudflare-workers
|
||||
- title: Netlify, use_cases: deployment, netlify hosting, production builds, serverless functions, edge functions, static site hosting, path: kit/adapter-netlify
|
||||
- title: Vercel, use_cases: deployment, vercel hosting, production builds, serverless functions, edge functions, isr, image optimization, environment variables, path: kit/adapter-vercel
|
||||
- title: Writing adapters, use_cases: custom deployment, building adapters, unsupported platforms, adapter development, custom hosting environments, path: kit/writing-adapters
|
||||
- title: Advanced routing, use_cases: advanced routing, dynamic routes, file viewers, nested paths, custom 404 pages, url validation, route parameters, multi-level navigation, path: kit/advanced-routing
|
||||
- title: Hooks, use_cases: authentication, logging, error tracking, request interception, api proxying, custom routing, internationalization, database initialization, middleware logic, session management, path: kit/hooks
|
||||
- title: Errors, use_cases: error handling, custom error pages, 404 pages, api error responses, production error logging, error tracking, type-safe errors, path: kit/errors
|
||||
- title: Link options, use_cases: routing, navigation, multi-page apps, performance optimization, link preloading, forms with get method, search functionality, focus management, scroll behavior, path: kit/link-options
|
||||
- title: Service workers, use_cases: offline support, pwa, caching strategies, performance optimization, precaching assets, network resilience, progressive web apps, path: kit/service-workers
|
||||
- title: Server-only modules, use_cases: api keys, environment variables, sensitive data protection, backend security, preventing data leaks, server-side code isolation, path: kit/server-only-modules
|
||||
- title: Snapshots, use_cases: forms, user input, preserving form data, multi-step forms, navigation state, preventing data loss, textarea content, input fields, comment systems, surveys, path: kit/snapshots
|
||||
- title: Shallow routing, use_cases: modals, dialogs, image galleries, overlays, history-driven ui, mobile-friendly navigation, photo viewers, lightboxes, drawer menus, path: kit/shallow-routing
|
||||
- title: Observability, use_cases: performance monitoring, debugging, observability, tracing requests, production diagnostics, analyzing slow requests, finding bottlenecks, monitoring server-side operations, path: kit/observability
|
||||
- title: Packaging, use_cases: building component libraries, publishing npm packages, creating reusable svelte components, library development, package distribution, path: kit/packaging
|
||||
- title: Auth, use_cases: authentication, login systems, user management, session handling, jwt tokens, protected routes, user credentials, authorization checks, path: kit/auth
|
||||
- title: Performance, use_cases: performance optimization, slow loading pages, production deployment, debugging performance issues, reducing bundle size, improving load times, path: kit/performance
|
||||
- title: Icons, use_cases: icons, ui components, styling, css frameworks, tailwind, unocss, performance optimization, dependency management, path: kit/icons
|
||||
- title: Images, use_cases: image optimization, responsive images, performance, hero images, product photos, galleries, cms integration, cdn setup, asset management, path: kit/images
|
||||
- title: Accessibility, use_cases: always, any sveltekit project, screen reader support, keyboard navigation, multi-page apps, client-side routing, internationalization, multilingual sites, path: kit/accessibility
|
||||
- title: SEO, use_cases: seo optimization, search engine ranking, content sites, blogs, marketing sites, public-facing apps, sitemaps, amp pages, meta tags, performance optimization, path: kit/seo
|
||||
- title: Frequently asked questions, use_cases: troubleshooting package imports, library compatibility issues, client-side code execution, external api integration, middleware setup, database configuration, view transitions, yarn configuration, path: kit/faq
|
||||
- title: Integrations, use_cases: project setup, css preprocessors, postcss, scss, sass, less, stylus, typescript setup, adding integrations, tailwind, testing, auth, linting, formatting, path: kit/integrations
|
||||
- title: Breakpoint Debugging, use_cases: debugging, breakpoints, development workflow, troubleshooting issues, vscode setup, ide configuration, inspecting code execution, path: kit/debugging
|
||||
- title: Migrating to SvelteKit v2, use_cases: migration, upgrading from sveltekit 1 to 2, breaking changes, version updates, path: kit/migrating-to-sveltekit-2
|
||||
- title: Migrating from Sapper, use_cases: migrating from sapper, upgrading legacy projects, sapper to sveltekit conversion, project modernization, path: kit/migrating
|
||||
- title: Additional resources, use_cases: troubleshooting, getting help, finding examples, learning sveltekit, project templates, common issues, community support, path: kit/additional-resources
|
||||
- title: Glossary, use_cases: rendering strategies, performance optimization, deployment configuration, seo requirements, static sites, spas, server-side rendering, prerendering, edge deployment, pwa development, path: kit/glossary
|
||||
- title: @sveltejs/kit, use_cases: forms, form actions, server-side validation, form submission, error handling, redirects, json responses, http errors, server utilities, path: kit/@sveltejs-kit
|
||||
- title: @sveltejs/kit/hooks, use_cases: middleware, request processing, authentication chains, logging, multiple hooks, request/response transformation, path: kit/@sveltejs-kit-hooks
|
||||
- title: @sveltejs/kit/node/polyfills, use_cases: node.js environments, custom servers, non-standard runtimes, ssr setup, web api compatibility, polyfill requirements, path: kit/@sveltejs-kit-node-polyfills
|
||||
- title: @sveltejs/kit/node, use_cases: node.js adapter, custom server setup, http integration, streaming files, node deployment, server-side rendering with node, path: kit/@sveltejs-kit-node
|
||||
- title: @sveltejs/kit/vite, use_cases: project setup, vite configuration, initial sveltekit setup, build tooling, path: kit/@sveltejs-kit-vite
|
||||
- title: $app/environment, use_cases: always, conditional logic, client-side code, server-side code, build-time logic, prerendering, development vs production, environment detection, path: kit/$app-environment
|
||||
- title: $app/forms, use_cases: forms, user input, data submission, progressive enhancement, custom form handling, form validation, path: kit/$app-forms
|
||||
- title: $app/navigation, use_cases: routing, navigation, multi-page apps, programmatic navigation, data reloading, preloading, shallow routing, navigation lifecycle, scroll handling, view transitions, path: kit/$app-navigation
|
||||
- title: $app/paths, use_cases: static assets, images, fonts, public files, base path configuration, subdirectory deployment, cdn setup, asset urls, links, navigation, path: kit/$app-paths
|
||||
- title: $app/server, use_cases: remote functions, server-side logic, data fetching, form handling, api endpoints, client-server communication, prerendering, file reading, batch queries, path: kit/$app-server
|
||||
- title: $app/state, use_cases: routing, navigation, multi-page apps, loading states, url parameters, form handling, error states, version updates, page metadata, shallow routing, path: kit/$app-state
|
||||
- title: $app/stores, use_cases: legacy projects, sveltekit pre-2.12, migration from stores to runes, maintaining older codebases, accessing page data, navigation state, app version updates, path: kit/$app-stores
|
||||
- title: $app/types, use_cases: routing, navigation, type safety, route parameters, dynamic routes, link generation, pathname validation, multi-page apps, path: kit/$app-types
|
||||
- title: $env/dynamic/private, use_cases: api keys, secrets management, server-side config, environment variables, backend logic, deployment-specific settings, private data handling, path: kit/$env-dynamic-private
|
||||
- title: $env/dynamic/public, use_cases: environment variables, client-side config, runtime configuration, public api keys, deployment-specific settings, multi-environment apps, path: kit/$env-dynamic-public
|
||||
- title: $env/static/private, use_cases: server-side api keys, backend secrets, database credentials, private configuration, build-time optimization, server endpoints, authentication tokens, path: kit/$env-static-private
|
||||
- title: $env/static/public, use_cases: environment variables, public config, client-side data, api endpoints, build-time configuration, public constants, path: kit/$env-static-public
|
||||
- title: $lib, use_cases: project setup, component organization, importing shared components, reusable ui elements, code structure, path: kit/$lib
|
||||
- title: $service-worker, use_cases: offline support, pwa, service workers, caching strategies, progressive web apps, offline-first apps, path: kit/$service-worker
|
||||
- title: Configuration, use_cases: project setup, configuration, adapters, deployment, build settings, environment variables, routing customization, prerendering, csp security, csrf protection, path configuration, typescript setup, path: kit/configuration
|
||||
- title: Command Line Interface, use_cases: project setup, typescript configuration, generated types, ./$types imports, initial project configuration, path: kit/cli
|
||||
- title: Types, use_cases: typescript, type safety, route parameters, api endpoints, load functions, form actions, generated types, jsconfig setup, path: kit/types
|
||||
- title: Overview, use_cases: use title and path to estimate use case, path: mcp/overview
|
||||
- title: Local setup, use_cases: use title and path to estimate use case, path: mcp/local-setup
|
||||
- title: Remote setup, use_cases: use title and path to estimate use case, path: mcp/remote-setup
|
||||
- title: Tools, use_cases: use title and path to estimate use case, path: mcp/tools
|
||||
- title: Resources, use_cases: use title and path to estimate use case, path: mcp/resources
|
||||
- title: Prompts, use_cases: use title and path to estimate use case, path: mcp/prompts
|
||||
- title: Overview, use_cases: always, any svelte project, getting started, learning svelte, introduction, project setup, understanding framework basics, path: svelte/overview
|
||||
- title: Getting started, use_cases: project setup, starting new svelte project, initial installation, choosing between sveltekit and vite, editor configuration, path: svelte/getting-started
|
||||
- title: .svelte files, use_cases: always, any svelte project, component creation, project setup, learning svelte basics, path: svelte/svelte-files
|
||||
- title: .svelte.js and .svelte.ts files, use_cases: shared reactive state, reusable reactive logic, state management across components, global stores, custom reactive utilities, path: svelte/svelte-js-files
|
||||
- title: What are runes?, use_cases: always, any svelte 5 project, understanding core syntax, learning svelte 5, migration from svelte 4, path: svelte/what-are-runes
|
||||
- title: $state, use_cases: always, any svelte project, core reactivity, state management, counters, forms, todo apps, interactive ui, data updates, class-based components, path: svelte/$state
|
||||
- title: $derived, use_cases: always, any svelte project, computed values, reactive calculations, derived data, transforming state, dependent values, path: svelte/$derived
|
||||
- title: $effect, use_cases: canvas drawing, third-party library integration, dom manipulation, side effects, intervals, timers, network requests, analytics tracking, path: svelte/$effect
|
||||
- title: $props, use_cases: always, any svelte project, passing data to components, component communication, reusable components, component props, path: svelte/$props
|
||||
- title: $bindable, use_cases: forms, user input, two-way data binding, custom input components, parent-child communication, reusable form fields, path: svelte/$bindable
|
||||
- title: $inspect, use_cases: debugging, development, tracking state changes, reactive state monitoring, troubleshooting reactivity issues, path: svelte/$inspect
|
||||
- title: $host, use_cases: custom elements, web components, dispatching custom events, component library, framework-agnostic components, path: svelte/$host
|
||||
- title: Basic markup, use_cases: always, any svelte project, basic markup, html templating, component structure, attributes, events, props, text rendering, path: svelte/basic-markup
|
||||
- title: {#if ...}, use_cases: always, conditional rendering, showing/hiding content, dynamic ui, user permissions, loading states, error handling, form validation, path: svelte/if
|
||||
- title: {#each ...}, use_cases: always, lists, arrays, iteration, product listings, todos, tables, grids, dynamic content, shopping carts, user lists, comments, feeds, path: svelte/each
|
||||
- title: {#key ...}, use_cases: animations, transitions, component reinitialization, forcing component remount, value-based ui updates, resetting component state, path: svelte/key
|
||||
- title: {#await ...}, use_cases: async data fetching, api calls, loading states, promises, error handling, lazy loading components, dynamic imports, path: svelte/await
|
||||
- title: {#snippet ...}, use_cases: reusable markup, component composition, passing content to components, table rows, list items, conditional rendering, reducing duplication, path: svelte/snippet
|
||||
- title: {@render ...}, use_cases: reusable ui patterns, component composition, conditional rendering, fallback content, layout components, slot alternatives, template reuse, path: svelte/@render
|
||||
- title: {@html ...}, use_cases: rendering html strings, cms content, rich text editors, markdown to html, blog posts, wysiwyg output, sanitized html injection, dynamic html content, path: svelte/@html
|
||||
- title: {@attach ...}, use_cases: tooltips, popovers, dom manipulation, third-party libraries, canvas drawing, element lifecycle, interactive ui, custom directives, wrapper components, path: svelte/@attach
|
||||
- title: {@const ...}, use_cases: computed values in loops, derived calculations in blocks, local variables in each iterations, complex list rendering, path: svelte/@const
|
||||
- title: {@debug ...}, use_cases: debugging, development, troubleshooting, tracking state changes, monitoring variables, reactive data inspection, path: svelte/@debug
|
||||
- title: bind:, use_cases: forms, user input, two-way data binding, interactive ui, media players, file uploads, checkboxes, radio buttons, select dropdowns, contenteditable, dimension tracking, path: svelte/bind
|
||||
- title: use:, use_cases: custom directives, dom manipulation, third-party library integration, tooltips, click outside, gestures, focus management, element lifecycle hooks, path: svelte/use
|
||||
- title: transition:, use_cases: animations, interactive ui, modals, dropdowns, notifications, conditional content, show/hide elements, smooth state changes, path: svelte/transition
|
||||
- title: in: and out:, use_cases: animation, transitions, interactive ui, conditional rendering, independent enter/exit effects, modals, tooltips, notifications, path: svelte/in-and-out
|
||||
- title: animate:, use_cases: sortable lists, drag and drop, reorderable items, todo lists, kanban boards, playlist editors, priority queues, animated list reordering, path: svelte/animate
|
||||
- title: style:, use_cases: dynamic styling, conditional styles, theming, dark mode, responsive design, interactive ui, component styling, path: svelte/style
|
||||
- title: class, use_cases: always, conditional styling, dynamic classes, tailwind css, component styling, reusable components, responsive design, path: svelte/class
|
||||
- title: await, use_cases: async data fetching, loading states, server-side rendering, awaiting promises in components, async validation, concurrent data loading, path: svelte/await-expressions
|
||||
- title: Scoped styles, use_cases: always, styling components, scoped css, component-specific styles, preventing style conflicts, animations, keyframes, path: svelte/scoped-styles
|
||||
- title: Global styles, use_cases: global styles, third-party libraries, css resets, animations, styling body/html, overriding component styles, shared keyframes, base styles, path: svelte/global-styles
|
||||
- title: Custom properties, use_cases: theming, custom styling, reusable components, design systems, dynamic colors, component libraries, ui customization, path: svelte/custom-properties
|
||||
- title: Nested <style> elements, use_cases: component styling, scoped styles, dynamic styles, conditional styling, nested style tags, custom styling logic, path: svelte/nested-style-elements
|
||||
- title: <svelte:boundary>, use_cases: error handling, async data loading, loading states, error recovery, flaky components, error reporting, resilient ui, path: svelte/svelte-boundary
|
||||
- title: <svelte:window>, use_cases: keyboard shortcuts, scroll tracking, window resize handling, responsive layouts, online/offline detection, viewport dimensions, global event listeners, path: svelte/svelte-window
|
||||
- title: <svelte:document>, use_cases: document events, visibility tracking, fullscreen detection, pointer lock, focus management, document-level interactions, path: svelte/svelte-document
|
||||
- title: <svelte:body>, use_cases: mouse tracking, hover effects, cursor interactions, global body events, drag and drop, custom cursors, interactive backgrounds, body-level actions, path: svelte/svelte-body
|
||||
- title: <svelte:head>, use_cases: seo optimization, page titles, meta tags, social media sharing, dynamic head content, multi-page apps, blog posts, product pages, path: svelte/svelte-head
|
||||
- title: <svelte:element>, use_cases: dynamic content, cms integration, user-generated content, configurable ui, runtime element selection, flexible components, path: svelte/svelte-element
|
||||
- title: <svelte:options>, use_cases: migration, custom elements, web components, legacy mode compatibility, runes mode setup, svg components, mathml components, css injection control, path: svelte/svelte-options
|
||||
- title: Stores, use_cases: shared state, cross-component data, reactive values, async data streams, manual control over updates, rxjs integration, extracting logic, path: svelte/stores
|
||||
- title: Context, use_cases: shared state, avoiding prop drilling, component communication, theme providers, user context, authentication state, configuration sharing, deeply nested components, path: svelte/context
|
||||
- title: Lifecycle hooks, use_cases: component initialization, cleanup tasks, timers, subscriptions, dom measurements, chat windows, autoscroll features, migration from svelte 4, path: svelte/lifecycle-hooks
|
||||
- title: Imperative component API, use_cases: project setup, client-side rendering, server-side rendering, ssr, hydration, testing, programmatic component creation, tooltips, dynamic mounting, path: svelte/imperative-component-api
|
||||
- title: Testing, use_cases: testing, quality assurance, unit tests, integration tests, component tests, e2e tests, vitest setup, playwright setup, test automation, path: svelte/testing
|
||||
- title: TypeScript, use_cases: typescript setup, type safety, component props typing, generic components, wrapper components, dom type augmentation, project configuration, path: svelte/typescript
|
||||
- title: Custom elements, use_cases: web components, custom elements, component library, design system, framework-agnostic components, embedding svelte in non-svelte apps, shadow dom, path: svelte/custom-elements
|
||||
- title: Svelte 4 migration guide, use_cases: upgrading svelte 3 to 4, version migration, updating dependencies, breaking changes, legacy project maintenance, path: svelte/v4-migration-guide
|
||||
- title: Svelte 5 migration guide, use_cases: migrating from svelte 4 to 5, upgrading projects, learning svelte 5 syntax changes, runes migration, event handler updates, path: svelte/v5-migration-guide
|
||||
- title: Frequently asked questions, use_cases: getting started, learning svelte, beginner setup, project initialization, vs code setup, formatting, testing, routing, mobile apps, troubleshooting, community support, path: svelte/faq
|
||||
- title: svelte, use_cases: migration from svelte 4 to 5, upgrading legacy code, component lifecycle hooks, context api, mounting components, event dispatchers, typescript component types, path: svelte/svelte
|
||||
- title: svelte/action, use_cases: typescript types, actions, use directive, dom manipulation, element lifecycle, custom behaviors, third-party library integration, path: svelte/svelte-action
|
||||
- title: svelte/animate, use_cases: animated lists, sortable items, drag and drop, reordering elements, todo lists, kanban boards, playlist management, smooth position transitions, path: svelte/svelte-animate
|
||||
- title: svelte/attachments, use_cases: library development, component libraries, programmatic element manipulation, migrating from actions to attachments, spreading props onto elements, path: svelte/svelte-attachments
|
||||
- title: svelte/compiler, use_cases: build tools, custom compilers, ast manipulation, preprocessors, code transformation, migration scripts, syntax analysis, bundler plugins, dev tools, path: svelte/svelte-compiler
|
||||
- title: svelte/easing, use_cases: animations, transitions, custom easing, smooth motion, interactive ui, modals, dropdowns, carousels, page transitions, scroll effects, path: svelte/svelte-easing
|
||||
- title: svelte/events, use_cases: window events, document events, global event listeners, event delegation, programmatic event handling, cleanup functions, media queries, path: svelte/svelte-events
|
||||
- title: svelte/legacy, use_cases: migration from svelte 4 to svelte 5, upgrading legacy code, event modifiers, class components, imperative component instantiation, path: svelte/svelte-legacy
|
||||
- title: svelte/motion, use_cases: animation, smooth transitions, interactive ui, sliders, counters, physics-based motion, drag gestures, accessibility, reduced motion, path: svelte/svelte-motion
|
||||
- title: svelte/reactivity/window, use_cases: responsive design, viewport tracking, scroll effects, window resize handling, online/offline detection, zoom level tracking, path: svelte/svelte-reactivity-window
|
||||
- title: svelte/reactivity, use_cases: reactive data structures, state management with maps/sets, game boards, selection tracking, url manipulation, query params, real-time clocks, media queries, responsive design, path: svelte/svelte-reactivity
|
||||
- title: svelte/server, use_cases: server-side rendering, ssr, static site generation, seo optimization, initial page load, pre-rendering, node.js server, custom server setup, path: svelte/svelte-server
|
||||
- title: svelte/store, use_cases: state management, shared data, reactive stores, cross-component communication, global state, computed values, data synchronization, legacy svelte projects, path: svelte/svelte-store
|
||||
- title: svelte/transition, use_cases: animations, transitions, interactive ui, modals, dropdowns, tooltips, notifications, svg animations, list animations, page transitions, path: svelte/svelte-transition
|
||||
- title: Compiler errors, use_cases: animation, transitions, keyed each blocks, list animations, path: svelte/compiler-errors
|
||||
- title: Compiler warnings, use_cases: accessibility, a11y compliance, wcag standards, screen readers, keyboard navigation, aria attributes, semantic html, interactive elements, path: svelte/compiler-warnings
|
||||
- title: Runtime errors, use_cases: debugging errors, error handling, troubleshooting runtime issues, migration to svelte 5, component binding, effects and reactivity, path: svelte/runtime-errors
|
||||
- title: Runtime warnings, use_cases: debugging state proxies, console logging reactive values, inspecting state changes, development troubleshooting, path: svelte/runtime-warnings
|
||||
- title: Overview, use_cases: migrating from svelte 3/4 to svelte 5, maintaining legacy components, understanding deprecated features, gradual upgrade process, path: svelte/legacy-overview
|
||||
- title: Reactive let/var declarations, use_cases: migration, legacy svelte projects, upgrading from svelte 4, understanding old reactivity, maintaining existing code, learning runes differences, path: svelte/legacy-let
|
||||
- title: Reactive $: statements, use_cases: legacy mode, migration from svelte 4, reactive statements, computed values, derived state, side effects, path: svelte/legacy-reactive-assignments
|
||||
- title: export let, use_cases: legacy mode, migration from svelte 4, maintaining older projects, component props without runes, exporting component methods, renaming reserved word props, path: svelte/legacy-export-let
|
||||
- title: $$props and $$restProps, use_cases: legacy mode migration, component wrappers, prop forwarding, button components, reusable ui components, spreading props to child elements, path: svelte/legacy-$$props-and-$$restProps
|
||||
- title: on:, use_cases: legacy mode, event handling, button clicks, forms, user interactions, component communication, event forwarding, event modifiers, path: svelte/legacy-on
|
||||
- title: <slot>, use_cases: legacy mode, migrating from svelte 4, component composition, reusable components, passing content to components, modals, layouts, wrappers, path: svelte/legacy-slots
|
||||
- title: $$slots, use_cases: legacy mode, conditional slot rendering, optional content sections, checking if slots provided, migrating from legacy to runes, path: svelte/legacy-$$slots
|
||||
- title: <svelte:fragment>, use_cases: named slots, component composition, layout systems, avoiding wrapper divs, legacy svelte projects, slot content organization, path: svelte/legacy-svelte-fragment
|
||||
- title: <svelte:component>, use_cases: dynamic components, component switching, conditional rendering, legacy mode migration, tabbed interfaces, multi-step forms, path: svelte/legacy-svelte-component
|
||||
- title: <svelte:self>, use_cases: recursive components, tree structures, nested menus, file explorers, comment threads, hierarchical data, path: svelte/legacy-svelte-self
|
||||
- title: Imperative component API, use_cases: migration from svelte 3/4 to 5, legacy component api, maintaining old projects, understanding deprecated patterns, path: svelte/legacy-component-api
|
||||
|
||||
</available-docs>
|
||||
|
||||
Every time you write a Svelte component or a Svelte module you MUST invoke the `svelte-autofixer` tool providing the code. The tool will return a list of issues or suggestions. If there are any issues or suggestions you MUST fix them and call the tool again with the updated code. You MUST keep doing this until the tool returns no issues or suggestions. Only then you can return the code to the user.
|
||||
|
||||
This is the task you will work on:
|
||||
|
||||
<task>
|
||||
[YOUR TASK HERE]
|
||||
</task>
|
||||
|
||||
If you are not writing the code into a file, once you have the final version of the code ask the user if it wants to generate a playground link to quickly check the code in it and if it answer yes call the `playground-link` tool and return the url to the user nicely formatted. The playground link MUST be generated only once you have the final version of the code and you are ready to share it, it MUST include an entry point file called `App.svelte` where the main component should live. If you have multiple files to include in the playground link you can include them all at the root.
|
||||
19
.cursor/mcp.json
Normal file
19
.cursor/mcp.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"svelte": {
|
||||
"url": "https://mcp.svelte.dev/mcp"
|
||||
},
|
||||
"context7": {
|
||||
"url": "https://mcp.context7.com/mcp"
|
||||
},
|
||||
"convex": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"convex@latest",
|
||||
"mcp",
|
||||
"start"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
27
.cursor/rules/svelte_rules.mdc
Normal file
27
.cursor/rules/svelte_rules.mdc
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
description: Guidelines for TypeScript usage, including type safety rules and Convex query typing
|
||||
globs: **/*.ts,**/*.tsx,**/*.svelte
|
||||
globs: **/*.ts,**/*.svelte
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# TypeScript Guidelines
|
||||
@@ -8,6 +9,7 @@ globs: **/*.ts,**/*.tsx,**/*.svelte
|
||||
## Type Safety Rules
|
||||
|
||||
### Avoid `any` Type
|
||||
|
||||
- **NEVER** use the `any` type in production code
|
||||
- The only exception is in test files (files matching `*.test.ts`, `*.test.tsx`, `*.spec.ts`, `*.spec.tsx`)
|
||||
- Instead of `any`, use:
|
||||
@@ -20,44 +22,48 @@ globs: **/*.ts,**/*.tsx,**/*.svelte
|
||||
### Examples
|
||||
|
||||
**❌ Bad:**
|
||||
|
||||
```typescript
|
||||
function processData(data: any) {
|
||||
return data.value;
|
||||
return data.value;
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Good:**
|
||||
|
||||
```typescript
|
||||
function processData(data: { value: string }) {
|
||||
return data.value;
|
||||
return data.value;
|
||||
}
|
||||
|
||||
// Or with generics
|
||||
function processData<T extends { value: unknown }>(data: T) {
|
||||
return data.value;
|
||||
return data.value;
|
||||
}
|
||||
|
||||
// Or with unknown and type guards
|
||||
function processData(data: unknown) {
|
||||
if (typeof data === 'object' && data !== null && 'value' in data) {
|
||||
return (data as { value: string }).value;
|
||||
}
|
||||
throw new Error('Invalid data');
|
||||
if (typeof data === 'object' && data !== null && 'value' in data) {
|
||||
return (data as { value: string }).value;
|
||||
}
|
||||
throw new Error('Invalid data');
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Exception (tests only):**
|
||||
|
||||
```typescript
|
||||
// test.ts or *.spec.ts
|
||||
it('should handle any input', () => {
|
||||
const input: any = getMockData();
|
||||
expect(process(input)).toBeDefined();
|
||||
const input: any = getMockData();
|
||||
expect(process(input)).toBeDefined();
|
||||
});
|
||||
```
|
||||
|
||||
## Convex Query Typing
|
||||
|
||||
### Frontend Query Usage
|
||||
|
||||
- **DO NOT** create manual type definitions for Convex query results in the frontend
|
||||
- Convex queries already return properly typed results based on their `returns` validator
|
||||
- The TypeScript types are automatically inferred from the query's return validator
|
||||
@@ -66,17 +72,19 @@ it('should handle any input', () => {
|
||||
### Examples
|
||||
|
||||
**❌ Bad:**
|
||||
|
||||
```typescript
|
||||
// Don't manually type the result
|
||||
type UserListResult = Array<{
|
||||
_id: Id<"users">;
|
||||
name: string;
|
||||
_id: Id<'users'>;
|
||||
name: string;
|
||||
}>;
|
||||
|
||||
const users: UserListResult = useQuery(api.users.list);
|
||||
```
|
||||
|
||||
**✅ Good:**
|
||||
|
||||
```typescript
|
||||
// Let TypeScript infer the type from the query
|
||||
const users = useQuery(api.users.list);
|
||||
@@ -84,24 +92,26 @@ const users = useQuery(api.users.list);
|
||||
|
||||
// You can still use it with type inference
|
||||
if (users !== undefined) {
|
||||
users.forEach(user => {
|
||||
// TypeScript knows user._id is Id<"users"> and user.name is string
|
||||
console.log(user.name);
|
||||
});
|
||||
users.forEach((user) => {
|
||||
// TypeScript knows user._id is Id<"users"> and user.name is string
|
||||
console.log(user.name);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**✅ Good (with explicit type if needed for clarity):**
|
||||
|
||||
```typescript
|
||||
// Only if you need to export or explicitly annotate for documentation
|
||||
import type { FunctionReturnType } from "convex/server";
|
||||
import type { api } from "./convex/_generated/api";
|
||||
import type { FunctionReturnType } from 'convex/server';
|
||||
import type { api } from './convex/_generated/api';
|
||||
|
||||
type UserListResult = FunctionReturnType<typeof api.users.list>;
|
||||
const users = useQuery(api.users.list);
|
||||
```
|
||||
|
||||
### Best Practices
|
||||
|
||||
- Trust Convex's type inference - it's based on your schema and validators
|
||||
- If you need type annotations, use `FunctionReturnType` from Convex's type utilities
|
||||
- Only create manual types if you're doing complex transformations that need intermediate types
|
||||
|
||||
12
.editorconfig
Normal file
12
.editorconfig
Normal file
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = true
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -47,4 +47,5 @@ coverage
|
||||
*.tgz
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
temp
|
||||
.eslintcache
|
||||
|
||||
20
.vscode/settings.json
vendored
20
.vscode/settings.json
vendored
@@ -9,5 +9,21 @@
|
||||
// },
|
||||
// "[svelte]": {
|
||||
// "editor.defaultFormatter": "biomejs.biome"
|
||||
// }
|
||||
}
|
||||
// },
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "apps/*" },
|
||||
{ "pattern": "packages/*" }
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"svelte"
|
||||
],
|
||||
"eslint.options": {
|
||||
"cache": true,
|
||||
"cacheLocation": ".eslintcache"
|
||||
}
|
||||
}
|
||||
|
||||
23
AGENTS.md
Normal file
23
AGENTS.md
Normal file
@@ -0,0 +1,23 @@
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns issues and suggestions.
|
||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
296
CORRECOES_JITSI.md
Normal file
296
CORRECOES_JITSI.md
Normal file
@@ -0,0 +1,296 @@
|
||||
# Correções Implementadas para Integração Jitsi
|
||||
|
||||
## Resumo das Alterações
|
||||
|
||||
Este documento descreve todas as correções implementadas para integrar o servidor Jitsi ao projeto SGSE e fazer as chamadas de áudio e vídeo funcionarem corretamente.
|
||||
|
||||
---
|
||||
|
||||
## 1. Configuração do JitsiConnection
|
||||
|
||||
### Problema Identificado
|
||||
- A configuração do `serviceUrl` e `muc` estava incorreta para Docker Jitsi local
|
||||
- O domínio incluía a porta, causando problemas na conexão
|
||||
|
||||
### Correção Implementada
|
||||
```typescript
|
||||
// Separar host e porta corretamente
|
||||
const { host, porta } = obterHostEPorta(config.domain);
|
||||
const protocol = config.useHttps ? 'https' : 'http';
|
||||
|
||||
const options = {
|
||||
hosts: {
|
||||
domain: host, // Apenas o host (sem porta)
|
||||
muc: `conference.${host}` // MUC no mesmo domínio
|
||||
},
|
||||
serviceUrl: `${protocol}://${host}:${porta}/http-bind`, // BOSH com porta
|
||||
bosh: `${protocol}://${host}:${porta}/http-bind`, // BOSH alternativo
|
||||
clientNode: config.appId
|
||||
};
|
||||
```
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
**Arquivo criado/atualizado:**
|
||||
- `apps/web/src/lib/utils/jitsi.ts` - Adicionada função `obterHostEPorta()`
|
||||
|
||||
---
|
||||
|
||||
## 2. Criação de Tracks Locais
|
||||
|
||||
### Problema Identificado
|
||||
- Os tracks locais não estavam sendo criados após entrar na conferência
|
||||
- Faltava o evento `CONFERENCE_JOINED` para criar tracks locais
|
||||
|
||||
### Correção Implementada
|
||||
```typescript
|
||||
conference.on(JitsiMeetJS.constants.events.conference.CONFERENCE_JOINED, async () => {
|
||||
// Criar tracks locais com constraints apropriadas
|
||||
const constraints = {
|
||||
audio: estadoAtual.audioHabilitado ? {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true
|
||||
} : false,
|
||||
video: estadoAtual.videoHabilitado ? {
|
||||
facingMode: 'user',
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
} : false
|
||||
};
|
||||
|
||||
const tracks = await JitsiMeetJS.createLocalTracks(constraints, {
|
||||
devices: [],
|
||||
cameraDeviceId: estadoChamada.dispositivos.cameraId || undefined,
|
||||
micDeviceId: estadoChamada.dispositivos.microphoneId || undefined
|
||||
});
|
||||
|
||||
// Adicionar tracks à conferência e anexar ao vídeo local
|
||||
for (const track of tracks) {
|
||||
await conference.addTrack(track);
|
||||
if (track.getType() === 'video' && localVideo) {
|
||||
track.attach(localVideo);
|
||||
}
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 3. Gerenciamento de Tracks
|
||||
|
||||
### Problema Identificado
|
||||
- Tracks locais não eram armazenados corretamente
|
||||
- Falta de limpeza adequada ao finalizar chamada
|
||||
|
||||
### Correção Implementada
|
||||
- Adicionada variável de estado `localTracks: JitsiTrack[]` para rastrear todos os tracks locais
|
||||
- Implementada limpeza adequada no método `finalizar()`:
|
||||
- Desconectar tracks antes de liberar
|
||||
- Dispor de todos os tracks locais
|
||||
- Limpar referências
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 4. Attach/Detach de Tracks Remotos
|
||||
|
||||
### Problema Identificado
|
||||
- Tracks remotos não eram anexados corretamente aos elementos de vídeo/áudio
|
||||
- Não havia tratamento específico para áudio vs vídeo
|
||||
|
||||
### Correção Implementada
|
||||
```typescript
|
||||
function adicionarTrackRemoto(track: JitsiTrack): void {
|
||||
const participantId = track.getParticipantId();
|
||||
const trackType = track.getType();
|
||||
|
||||
if (trackType === 'audio') {
|
||||
// Criar elemento de áudio invisível
|
||||
const audioElement = document.createElement('audio');
|
||||
audioElement.id = `remote-audio-${participantId}`;
|
||||
audioElement.autoplay = true;
|
||||
track.attach(audioElement);
|
||||
videoContainer.appendChild(audioElement);
|
||||
} else if (trackType === 'video') {
|
||||
// Criar elemento de vídeo
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.id = `remote-video-${participantId}`;
|
||||
videoElement.autoplay = true;
|
||||
track.attach(videoElement);
|
||||
videoContainer.appendChild(videoElement);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 5. Controles de Áudio e Vídeo
|
||||
|
||||
### Problema Identificado
|
||||
- Os métodos `handleToggleAudio` e `handleToggleVideo` não criavam novos tracks quando necessário
|
||||
- Não atualizavam corretamente o estado dos tracks locais
|
||||
|
||||
### Correção Implementada
|
||||
- Implementada lógica para criar tracks se não existirem
|
||||
- Atualização correta do estado dos tracks (mute/unmute)
|
||||
- Sincronização com o backend quando anfitrião
|
||||
- Anexar/desanexar tracks ao vídeo local corretamente
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 6. Tratamento de Erros
|
||||
|
||||
### Problema Identificado
|
||||
- Uso de `alert()` para erros (não amigável)
|
||||
- Falta de mensagens de erro claras
|
||||
|
||||
### Correção Implementada
|
||||
- Implementado sistema de tratamento de erros com `ErrorModal`
|
||||
- Integrado com `traduzirErro()` para mensagens amigáveis
|
||||
- Adicionado estado de erro no componente:
|
||||
```typescript
|
||||
let showErrorModal = $state(false);
|
||||
let errorTitle = $state('Erro na Chamada');
|
||||
let errorMessage = $state('');
|
||||
let errorDetails = $state<string | undefined>(undefined);
|
||||
```
|
||||
|
||||
**Arquivos modificados:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
- Integração com `apps/web/src/lib/utils/erroHelpers.ts`
|
||||
|
||||
---
|
||||
|
||||
## 7. Inicialização do Jitsi Meet JS
|
||||
|
||||
### Problema Identificado
|
||||
- Configuração básica do Jitsi pode estar incompleta
|
||||
- Nível de log muito restritivo
|
||||
|
||||
### Correção Implementada
|
||||
```typescript
|
||||
JitsiMeetJS.init({
|
||||
disableAudioLevels: false, // Habilitado para melhor qualidade
|
||||
disableSimulcast: false,
|
||||
enableWindowOnErrorHandler: true,
|
||||
enableRemb: true, // REMB para controle de bitrate
|
||||
enableTcc: true, // TCC para controle de congestionamento
|
||||
disableThirdPartyRequests: false
|
||||
});
|
||||
|
||||
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO); // Mais verboso para debug
|
||||
```
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 8. UI/UX Melhorias
|
||||
|
||||
### Implementado
|
||||
- Indicador de conexão durante estabelecimento da chamada
|
||||
- Mensagem de "Conectando..." enquanto não há conexão estabelecida
|
||||
- Tratamento visual adequado de estados de conexão
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 9. Eventos da Conferência
|
||||
|
||||
### Adicionado
|
||||
- `CONFERENCE_JOINED`: Criar tracks locais após entrar
|
||||
- `CONFERENCE_LEFT`: Limpar tracks ao sair
|
||||
- Melhor tratamento de `TRACK_ADDED` e `TRACK_REMOVED`
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## 10. Correção de Interfaces TypeScript
|
||||
|
||||
### Adicionado
|
||||
- Método `addTrack()` na interface `JitsiConference`
|
||||
- Melhor tipagem de `JitsiTrack` com propriedade `track: MediaStreamTrack`
|
||||
|
||||
**Arquivo modificado:**
|
||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
---
|
||||
|
||||
## Configuração Necessária
|
||||
|
||||
### Variáveis de Ambiente (.env)
|
||||
```env
|
||||
# Jitsi Meet Configuration (Docker Local)
|
||||
VITE_JITSI_DOMAIN=localhost:8443
|
||||
VITE_JITSI_APP_ID=sgse-app
|
||||
VITE_JITSI_ROOM_PREFIX=sgse
|
||||
VITE_JITSI_USE_HTTPS=true
|
||||
```
|
||||
|
||||
**Nota:** Para Docker Jitsi local, geralmente usa-se HTTPS na porta 8443.
|
||||
|
||||
---
|
||||
|
||||
## Verificações Necessárias
|
||||
|
||||
### 1. Docker Jitsi Rodando
|
||||
```bash
|
||||
docker ps | grep jitsi
|
||||
```
|
||||
|
||||
### 2. Porta 8443 Acessível
|
||||
```bash
|
||||
curl -k https://localhost:8443
|
||||
```
|
||||
|
||||
### 3. Permissões do Navegador
|
||||
- Microfone deve estar permitido
|
||||
- Câmera deve estar permitida (para chamadas de vídeo)
|
||||
|
||||
### 4. Logs do Navegador
|
||||
- Abrir DevTools (F12)
|
||||
- Verificar Console para erros de conexão
|
||||
- Verificar Network para erros de rede
|
||||
|
||||
---
|
||||
|
||||
## Próximos Passos (Se Necessário)
|
||||
|
||||
1. **Testar conectividade** - Verificar se o servidor Jitsi responde corretamente
|
||||
2. **Ajustar configuração de rede** - Se houver problemas de firewall ou CORS
|
||||
3. **Configurar STUN/TURN** - Para conexões através de NAT (se necessário)
|
||||
4. **Otimizar qualidade** - Ajustar bitrates e resoluções conforme necessário
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
✅ **Todas as correções foram implementadas**
|
||||
✅ **Código sem erros de lint**
|
||||
✅ **Tratamento de erros adequado**
|
||||
✅ **Interfaces TypeScript corretas**
|
||||
✅ **Gerenciamento de recursos adequado**
|
||||
|
||||
---
|
||||
|
||||
**Data:** $(date)
|
||||
**Versão:** 1.0.0
|
||||
|
||||
701
PLANO_IMPLEMENTACAO_JITSI.md
Normal file
701
PLANO_IMPLEMENTACAO_JITSI.md
Normal file
@@ -0,0 +1,701 @@
|
||||
# Plano de Implementação - Chamadas de Áudio e Vídeo com Jitsi Meet
|
||||
|
||||
## Opção Escolhida: Docker Local (Desenvolvimento)
|
||||
|
||||
---
|
||||
|
||||
## 📋 Etapas Fora do Código - Configuração Docker
|
||||
|
||||
### Etapa 1: Preparar Ambiente Docker
|
||||
|
||||
**Requisitos:**
|
||||
|
||||
- Docker Desktop instalado e rodando
|
||||
- Mínimo 4GB RAM disponível
|
||||
- Portas livres: 8000, 8443, 10000-20000/udp
|
||||
|
||||
**Passos:**
|
||||
|
||||
1. **Criar diretório para configuração Docker Jitsi:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/jitsi-docker
|
||||
cd ~/jitsi-docker
|
||||
```
|
||||
|
||||
2. **Clonar repositório oficial:**
|
||||
|
||||
```bash
|
||||
git clone https://github.com/jitsi/docker-jitsi-meet.git
|
||||
cd docker-jitsi-meet
|
||||
```
|
||||
|
||||
3. **Configurar variáveis de ambiente:**
|
||||
|
||||
```bash
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
4. **Editar arquivo `.env` com as seguintes configurações:**
|
||||
|
||||
```env
|
||||
# Configuração básica para desenvolvimento local
|
||||
CONFIG=~/.jitsi-meet-cfg
|
||||
TZ=America/Recife
|
||||
|
||||
# Desabilitar Let's Encrypt (não necessário para localhost)
|
||||
ENABLE_LETSENCRYPT=0
|
||||
|
||||
# Portas HTTP/HTTPS
|
||||
HTTP_PORT=8000
|
||||
HTTPS_PORT=8443
|
||||
|
||||
# Domínio local
|
||||
PUBLIC_URL=http://localhost:8000
|
||||
DOMAIN=localhost
|
||||
|
||||
# Desabilitar autenticação para facilitar testes
|
||||
ENABLE_AUTH=0
|
||||
ENABLE_GUESTS=1
|
||||
|
||||
# Desabilitar transcrissão (não necessário para desenvolvimento)
|
||||
ENABLE_TRANSCRIPTION=0
|
||||
|
||||
# Desabilitar gravação no servidor (usaremos gravação local)
|
||||
ENABLE_RECORDING=0
|
||||
|
||||
# Configurações de vídeo (ajustar conforme necessidade)
|
||||
ENABLE_PREJOIN_PAGE=0
|
||||
START_AUDIO_MUTED=0
|
||||
START_VIDEO_MUTED=0
|
||||
|
||||
# Configurações de segurança
|
||||
ENABLE_XMPP_WEBSOCKET=0
|
||||
ENABLE_P2P=1
|
||||
|
||||
# Limites
|
||||
MAX_NUMBER_OF_PARTICIPANTS=10
|
||||
RESOLUTION_WIDTH=1280
|
||||
RESOLUTION_HEIGHT=720
|
||||
```
|
||||
|
||||
5. **Criar diretórios necessários:**
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.jitsi-meet-cfg/{web/letsencrypt,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb}
|
||||
```
|
||||
|
||||
6. **Iniciar containers:**
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
7. **Verificar status:**
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
8. **Ver logs se necessário:**
|
||||
|
||||
```bash
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
9. **Testar acesso:**
|
||||
|
||||
- Acessar: http://localhost:8000
|
||||
- Criar uma sala de teste e verificar se funciona
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
- Se houver erro de permissão nos diretórios: `sudo chown -R $USER:$USER ~/.jitsi-meet-cfg`
|
||||
- Se portas estiverem em uso, alterar HTTP_PORT e HTTPS_PORT no .env
|
||||
- Para parar: `docker-compose down`
|
||||
- Para reiniciar: `docker-compose restart`
|
||||
|
||||
---
|
||||
|
||||
## 📦 Etapas no Código - Backend Convex
|
||||
|
||||
### Etapa 2: Atualizar Schema
|
||||
|
||||
**Arquivo:** `packages/backend/convex/schema.ts`
|
||||
|
||||
**Adicionar nova tabela `chamadas`:**
|
||||
|
||||
```typescript
|
||||
chamadas: defineTable({
|
||||
conversaId: v.id('conversas'),
|
||||
tipo: v.union(v.literal('audio'), v.literal('video')),
|
||||
roomName: v.string(), // Nome único da sala Jitsi
|
||||
criadoPor: v.id('usuarios'), // Anfitrião/criador
|
||||
participantes: v.array(v.id('usuarios')),
|
||||
status: v.union(
|
||||
v.literal('aguardando'),
|
||||
v.literal('em_andamento'),
|
||||
v.literal('finalizada'),
|
||||
v.literal('cancelada')
|
||||
),
|
||||
iniciadaEm: v.optional(v.number()),
|
||||
finalizadaEm: v.optional(v.number()),
|
||||
duracaoSegundos: v.optional(v.number()),
|
||||
gravando: v.boolean(),
|
||||
gravacaoIniciadaPor: v.optional(v.id('usuarios')),
|
||||
gravacaoIniciadaEm: v.optional(v.number()),
|
||||
gravacaoFinalizadaEm: v.optional(v.number()),
|
||||
configuracoes: v.optional(
|
||||
v.object({
|
||||
audioHabilitado: v.boolean(),
|
||||
videoHabilitado: v.boolean(),
|
||||
participantesConfig: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
usuarioId: v.id('usuarios'),
|
||||
audioHabilitado: v.boolean(),
|
||||
videoHabilitado: v.boolean(),
|
||||
forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
),
|
||||
criadoEm: v.number()
|
||||
})
|
||||
.index('by_conversa', ['conversaId', 'status'])
|
||||
.index('by_conversa_ativa', ['conversaId', 'status'])
|
||||
.index('by_criado_por', ['criadoPor'])
|
||||
.index('by_status', ['status'])
|
||||
.index('by_room_name', ['roomName']);
|
||||
```
|
||||
|
||||
### Etapa 3: Criar Backend de Chamadas
|
||||
|
||||
**Arquivo:** `packages/backend/convex/chamadas.ts`
|
||||
|
||||
**Funções a implementar:**
|
||||
|
||||
#### Mutations:
|
||||
|
||||
1. `criarChamada` - Criar nova chamada
|
||||
2. `iniciarChamada` - Marcar como em andamento
|
||||
3. `finalizarChamada` - Finalizar e calcular duração
|
||||
4. `adicionarParticipante` - Adicionar participante
|
||||
5. `removerParticipante` - Remover participante
|
||||
6. `toggleAudioVideo` - Anfitrião controla áudio/vídeo de participante
|
||||
7. `atualizarConfiguracaoParticipante` - Atualizar configuração individual
|
||||
8. `iniciarGravacao` - Marcar início de gravação
|
||||
9. `finalizarGravacao` - Marcar fim de gravação
|
||||
|
||||
#### Queries:
|
||||
|
||||
1. `obterChamadaAtiva` - Buscar chamada ativa de uma conversa
|
||||
2. `listarChamadas` - Listar histórico
|
||||
3. `verificarAnfitriao` - Verificar se usuário é anfitrião
|
||||
4. `obterParticipantesChamada` - Listar participantes
|
||||
|
||||
**Tipos TypeScript (sem usar `any`):**
|
||||
|
||||
```typescript
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { QueryCtx, MutationCtx } from './_generated/server';
|
||||
|
||||
type ChamadaTipo = 'audio' | 'video';
|
||||
type ChamadaStatus = 'aguardando' | 'em_andamento' | 'finalizada' | 'cancelada';
|
||||
|
||||
interface ParticipanteConfig {
|
||||
usuarioId: Id<'usuarios'>;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
forcadoPeloAnfitriao?: boolean;
|
||||
}
|
||||
|
||||
interface ConfiguracoesChamada {
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
participantesConfig?: ParticipanteConfig[];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Etapas no Código - Frontend Svelte
|
||||
|
||||
### Etapa 4: Instalar Dependências
|
||||
|
||||
**Arquivo:** `apps/web/package.json`
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
bun add lib-jitsi-meet
|
||||
```
|
||||
|
||||
**Dependências adicionais necessárias:**
|
||||
|
||||
- `lib-jitsi-meet` - Biblioteca oficial Jitsi
|
||||
- (Possivelmente tipos) `@types/lib-jitsi-meet` se disponível
|
||||
|
||||
### Etapa 5: Configurar Variáveis de Ambiente
|
||||
|
||||
**Arquivo:** `apps/web/.env`
|
||||
|
||||
```env
|
||||
# Jitsi Meet Configuration (Docker Local)
|
||||
VITE_JITSI_DOMAIN=localhost:8443
|
||||
VITE_JITSI_APP_ID=sgse-app
|
||||
VITE_JITSI_ROOM_PREFIX=sgse
|
||||
VITE_JITSI_USE_HTTPS=false
|
||||
```
|
||||
|
||||
### Etapa 6: Criar Utilitários Jitsi
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/utils/jitsi.ts`
|
||||
|
||||
**Funções:**
|
||||
|
||||
- `gerarRoomName(conversaId: string, tipo: "audio" | "video"): string` - Gerar nome único da sala
|
||||
- `obterConfiguracaoJitsi()` - Retornar configuração do Jitsi baseada em .env
|
||||
- `validarDispositivos()` - Validar disponibilidade de microfone/webcam
|
||||
- `obterDispositivosDisponiveis()` - Listar dispositivos de mídia
|
||||
|
||||
**Tipos (sem `any`):**
|
||||
|
||||
```typescript
|
||||
interface ConfiguracaoJitsi {
|
||||
domain: string;
|
||||
appId: string;
|
||||
roomPrefix: string;
|
||||
useHttps: boolean;
|
||||
}
|
||||
|
||||
interface DispositivoMedia {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
kind: 'audioinput' | 'audiooutput' | 'videoinput';
|
||||
}
|
||||
|
||||
interface DispositivosDisponiveis {
|
||||
microphones: DispositivoMedia[];
|
||||
speakers: DispositivoMedia[];
|
||||
cameras: DispositivoMedia[];
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 7: Criar Store de Chamadas
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/stores/callStore.ts`
|
||||
|
||||
**Estado gerenciado:**
|
||||
|
||||
- Chamada ativa (se houver)
|
||||
- Estado de mídia (áudio/vídeo ligado/desligado)
|
||||
- Dispositivos selecionados
|
||||
- Status de gravação
|
||||
- Lista de participantes
|
||||
- Duração da chamada
|
||||
- É anfitrião ou não
|
||||
|
||||
**Tipos:**
|
||||
|
||||
```typescript
|
||||
interface EstadoChamada {
|
||||
chamadaId: Id<'chamadas'> | null;
|
||||
conversaId: Id<'conversas'> | null;
|
||||
tipo: 'audio' | 'video' | null;
|
||||
roomName: string | null;
|
||||
estaConectado: boolean;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
gravando: boolean;
|
||||
ehAnfitriao: boolean;
|
||||
participantes: Array<{
|
||||
usuarioId: Id<'usuarios'>;
|
||||
nome: string;
|
||||
avatar?: string;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
}>;
|
||||
duracaoSegundos: number;
|
||||
dispositivos: {
|
||||
microphoneId: string | null;
|
||||
cameraId: string | null;
|
||||
speakerId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface EventosChamada {
|
||||
'participant-joined': (participant: ParticipanteJitsi) => void;
|
||||
'participant-left': (participantId: string) => void;
|
||||
'audio-mute-status-changed': (isMuted: boolean) => void;
|
||||
'video-mute-status-changed': (isMuted: boolean) => void;
|
||||
'connection-failed': (error: Error) => void;
|
||||
'connection-disconnected': () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Métodos principais:**
|
||||
|
||||
- `iniciarChamada(conversaId, tipo)`
|
||||
- `finalizarChamada()`
|
||||
- `toggleAudio()`
|
||||
- `toggleVideo()`
|
||||
- `iniciarGravacao()`
|
||||
- `finalizarGravacao()`
|
||||
- `atualizarDispositivos()`
|
||||
|
||||
### Etapa 8: Criar Utilitários de Gravação
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/utils/mediaRecorder.ts`
|
||||
|
||||
**Funções:**
|
||||
|
||||
- `iniciarGravacaoAudio(stream: MediaStream): MediaRecorder` - Gravar apenas áudio
|
||||
- `iniciarGravacaoVideo(stream: MediaStream): MediaRecorder` - Gravar áudio + vídeo
|
||||
- `pararGravacao(recorder: MediaRecorder): Promise<Blob>` - Parar e retornar blob
|
||||
- `salvarGravacao(blob: Blob, nomeArquivo: string): void` - Salvar localmente
|
||||
- `obterDuracaoGravacao(recorder: MediaRecorder): number` - Obter duração
|
||||
|
||||
**Tipos:**
|
||||
|
||||
```typescript
|
||||
interface OpcoesGravacao {
|
||||
audioBitsPerSecond?: number;
|
||||
videoBitsPerSecond?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
interface ResultadoGravacao {
|
||||
blob: Blob;
|
||||
duracaoSegundos: number;
|
||||
nomeArquivo: string;
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 9: Criar Componente CallWindow
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/components/call/CallWindow.svelte`
|
||||
|
||||
**Características:**
|
||||
|
||||
- Janela flutuante redimensionável e arrastável
|
||||
- Integração com lib-jitsi-meet
|
||||
- Container para vídeo dos participantes
|
||||
- Barra de controles
|
||||
- Indicador de gravação
|
||||
- Contador de duração
|
||||
|
||||
**Props (TypeScript estrito):**
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
chamadaId: Id<'chamadas'>;
|
||||
conversaId: Id<'conversas'>;
|
||||
tipo: 'audio' | 'video';
|
||||
roomName: string;
|
||||
ehAnfitriao: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**Estrutura:**
|
||||
|
||||
- `<script lang="ts">` com tipos explícitos
|
||||
- Uso de `$state`, `$derived`, `$effect` (Svelte 5)
|
||||
- Integração com `callStore`
|
||||
- Eventos do Jitsi tratados tipados
|
||||
|
||||
**Bibliotecas para janela flutuante:**
|
||||
|
||||
- Usar eventos nativos de mouse/touch para drag
|
||||
- CSS para redimensionamento com handles
|
||||
- localStorage para persistir posição/tamanho
|
||||
|
||||
### Etapa 10: Criar Componente CallControls
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/components/call/CallControls.svelte`
|
||||
|
||||
**Controles:**
|
||||
|
||||
- Botão toggle áudio
|
||||
- Botão toggle vídeo
|
||||
- Botão gravação (se anfitrião)
|
||||
- Botão configurações
|
||||
- Botão encerrar chamada
|
||||
- Contador de duração (HH:MM:SS)
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
gravando: boolean;
|
||||
ehAnfitriao: boolean;
|
||||
duracaoSegundos: number;
|
||||
onToggleAudio: () => void;
|
||||
onToggleVideo: () => void;
|
||||
onIniciarGravacao: () => void;
|
||||
onPararGravacao: () => void;
|
||||
onAbrirConfiguracoes: () => void;
|
||||
onEncerrar: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 11: Criar Componente CallSettings
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/components/call/CallSettings.svelte`
|
||||
|
||||
**Funcionalidades:**
|
||||
|
||||
- Listar microfones disponíveis
|
||||
- Listar webcams disponíveis
|
||||
- Listar alto-falantes disponíveis
|
||||
- Preview de vídeo antes de aplicar
|
||||
- Teste de áudio
|
||||
- Botões aplicar/cancelar
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 12: Criar Componente HostControls
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/components/call/HostControls.svelte`
|
||||
|
||||
**Funcionalidades (apenas para anfitrião):**
|
||||
|
||||
- Lista de participantes
|
||||
- Toggle áudio por participante
|
||||
- Toggle vídeo por participante
|
||||
- Indicador visual de quem está gravando
|
||||
- Status de cada participante
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
participantes: Array<{
|
||||
usuarioId: Id<'usuarios'>;
|
||||
nome: string;
|
||||
avatar?: string;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
forcadoPeloAnfitriao?: boolean;
|
||||
}>;
|
||||
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
|
||||
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 13: Criar Componente RecordingIndicator
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/components/call/RecordingIndicator.svelte`
|
||||
|
||||
**Características:**
|
||||
|
||||
- Banner visível no topo da janela
|
||||
- Ícone animado de gravação
|
||||
- Mensagem clara de que está gravando
|
||||
- Informação de quem iniciou (se disponível)
|
||||
|
||||
**Props:**
|
||||
|
||||
```typescript
|
||||
interface Props {
|
||||
gravando: boolean;
|
||||
iniciadoPor?: string; // Nome do usuário que iniciou
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 14: Criar Utilitário de Janela Flutuante
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/utils/floatingWindow.ts`
|
||||
|
||||
**Funções:**
|
||||
|
||||
- `criarDragHandler(element: HTMLElement, handle: HTMLElement): () => void` - Criar handler de arrastar
|
||||
- `criarResizeHandler(element: HTMLElement, handles: HTMLElement[]): () => void` - Criar handler de redimensionar
|
||||
- `salvarPosicaoJanela(id: string, posicao: { x: number; y: number; width: number; height: number }): void` - Salvar no localStorage
|
||||
- `restaurarPosicaoJanela(id: string): { x: number; y: number; width: number; height: number } | null` - Restaurar do localStorage
|
||||
|
||||
**Tipos:**
|
||||
|
||||
```typescript
|
||||
interface PosicaoJanela {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LimitesJanela {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### Etapa 15: Integrar com ChatWindow
|
||||
|
||||
**Arquivo:** `apps/web/src/lib/components/chat/ChatWindow.svelte`
|
||||
|
||||
**Modificações:**
|
||||
|
||||
- Adicionar botão de chamada de áudio
|
||||
- Adicionar botão de chamada de vídeo
|
||||
- Mostrar indicador quando há chamada ativa
|
||||
- Importar e usar CallWindow quando houver chamada
|
||||
|
||||
**Adicionar no topo (junto com outros botões):**
|
||||
|
||||
```svelte
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle"
|
||||
onclick={() => iniciarChamada('audio')}
|
||||
title="Ligação de áudio"
|
||||
>
|
||||
<Phone class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle"
|
||||
onclick={() => iniciarChamada('video')}
|
||||
title="Ligação de vídeo"
|
||||
>
|
||||
<Video class="h-4 w-4" />
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Ordem de Implementação Recomendada
|
||||
|
||||
1. ✅ **Etapa 1:** Configurar Docker Jitsi (fora do código)
|
||||
2. ✅ **Etapa 2:** Atualizar schema com tabela chamadas
|
||||
3. ✅ **Etapa 3:** Criar backend chamadas.ts com todas as funções
|
||||
4. ✅ **Etapa 4:** Instalar dependências frontend
|
||||
5. ✅ **Etapa 5:** Configurar variáveis de ambiente
|
||||
6. ✅ **Etapa 6:** Criar utilitários Jitsi (jitsi.ts)
|
||||
7. ✅ **Etapa 7:** Criar store de chamadas (callStore.ts)
|
||||
8. ✅ **Etapa 8:** Criar utilitários de gravação (mediaRecorder.ts)
|
||||
9. ✅ **Etapa 9:** Criar CallWindow básico (apenas estrutura)
|
||||
10. ✅ **Etapa 10:** Integrar lib-jitsi-meet no CallWindow
|
||||
11. ✅ **Etapa 11:** Criar CallControls e integrar
|
||||
12. ✅ **Etapa 12:** Implementar contador de duração
|
||||
13. ✅ **Etapa 13:** Implementar janela flutuante (drag & resize)
|
||||
14. ✅ **Etapa 14:** Criar CallSettings e integração de dispositivos
|
||||
15. ✅ **Etapa 15:** Criar HostControls e lógica de anfitrião
|
||||
16. ✅ **Etapa 16:** Implementar gravação local
|
||||
17. ✅ **Etapa 17:** Criar RecordingIndicator
|
||||
18. ✅ **Etapa 18:** Integrar botões no ChatWindow
|
||||
19. ✅ **Etapa 19:** Testes completos
|
||||
20. ✅ **Etapa 20:** Ajustes finais e tratamento de erros
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Segurança e Boas Práticas
|
||||
|
||||
### TypeScript
|
||||
|
||||
- ❌ **NUNCA** usar `any`
|
||||
- ✅ Usar tipos explícitos em todas as funções
|
||||
- ✅ Usar tipos inferidos do Convex quando possível
|
||||
- ✅ Criar interfaces para objetos complexos
|
||||
|
||||
### Svelte 5
|
||||
|
||||
- ✅ Usar `$props()` para props
|
||||
- ✅ Usar `$state()` para estado reativo
|
||||
- ✅ Usar `$derived()` para valores derivados
|
||||
- ✅ Usar `$effect()` para side effects
|
||||
|
||||
### Validação
|
||||
|
||||
- ✅ Validar permissões no backend antes de mutações
|
||||
- ✅ Validar entrada de dados
|
||||
- ✅ Tratar erros adequadamente
|
||||
- ✅ Logs de segurança (criação/finalização de chamadas)
|
||||
|
||||
### Performance
|
||||
|
||||
- ✅ Cleanup adequado de event listeners
|
||||
- ✅ Desconectar Jitsi ao fechar janela
|
||||
- ✅ Parar gravação ao finalizar chamada
|
||||
- ✅ Liberar streams de mídia
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notas Importantes
|
||||
|
||||
1. **Room Names:** Gerar room names únicos usando conversaId + timestamp + hash
|
||||
2. **Persistência:** Salvar posição/tamanho da janela no localStorage
|
||||
3. **Notificações:** Notificar participantes quando chamada é criada/finalizada
|
||||
4. **Limpeza:** Sempre limpar recursos ao finalizar chamada
|
||||
5. **Erros:** Tratar erros de conexão, permissões de mídia, etc.
|
||||
6. **Acessibilidade:** Adicionar labels, ARIA attributes, suporte a teclado
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testes
|
||||
|
||||
### Testes Funcionais
|
||||
|
||||
- [ ] Criar chamada de áudio individual
|
||||
- [ ] Criar chamada de vídeo individual
|
||||
- [ ] Criar chamada em grupo
|
||||
- [ ] Toggle áudio/vídeo
|
||||
- [ ] Anfitrião controlar participantes
|
||||
- [ ] Iniciar/parar gravação
|
||||
- [ ] Contador de duração
|
||||
- [ ] Configuração de dispositivos
|
||||
- [ ] Janela flutuante drag/resize
|
||||
|
||||
### Testes de Segurança
|
||||
|
||||
- [ ] Não anfitrião não pode controlar outros
|
||||
- [ ] Não anfitrião não pode iniciar gravação
|
||||
- [ ] Validação de participantes
|
||||
- [ ] Rate limiting de criação de chamadas
|
||||
|
||||
### Testes de Erros
|
||||
|
||||
- [ ] Conexão perdida
|
||||
- [ ] Sem permissão de mídia
|
||||
- [ ] Dispositivos não disponíveis
|
||||
- [ ] Servidor Jitsi offline
|
||||
|
||||
---
|
||||
|
||||
## 📚 Referências
|
||||
|
||||
- [Jitsi Meet Docker](https://github.com/jitsi/docker-jitsi-meet)
|
||||
- [lib-jitsi-meet Documentation](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api)
|
||||
- [Svelte 5 Documentation](https://svelte.dev/docs)
|
||||
- [Convex Documentation](https://docs.convex.dev)
|
||||
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
|
||||
- [MediaRecorder API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)
|
||||
|
||||
---
|
||||
|
||||
**Data de Criação:** 2025-01-XX
|
||||
**Versão:** 1.0
|
||||
**Opção:** Docker Local (Desenvolvimento)
|
||||
117
RELATORIO_TESTES_TEMAS.md
Normal file
117
RELATORIO_TESTES_TEMAS.md
Normal file
@@ -0,0 +1,117 @@
|
||||
# Relatório de Testes - Sistema de Temas Personalizados
|
||||
|
||||
## Data: 2025-01-27
|
||||
|
||||
## Resumo Executivo
|
||||
Foram testados todos os 10 temas disponíveis no sistema SGSE através da aba "Aparência" na página de perfil. Cada tema foi selecionado e validado visualmente através de screenshots.
|
||||
|
||||
## Temas Testados
|
||||
|
||||
### 1. ✅ Tema Roxo (Purple)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema padrão com cores roxa e azul
|
||||
- **Screenshot**: `tema-roxo.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe cores roxas/azuis
|
||||
|
||||
### 2. ✅ Tema Azul (Blue)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema azul clássico e profissional
|
||||
- **Screenshot**: `tema-azul.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de azul
|
||||
|
||||
### 3. ✅ Tema Verde (Green)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema verde natural e harmonioso
|
||||
- **Screenshot**: `tema-verde.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de verde
|
||||
|
||||
### 4. ✅ Tema Laranja (Orange)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema laranja vibrante e energético
|
||||
- **Screenshot**: `tema-laranja.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de laranja
|
||||
|
||||
### 5. ✅ Tema Vermelho (Red)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema vermelho intenso e impactante
|
||||
- **Screenshot**: `tema-vermelho.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de vermelho
|
||||
|
||||
### 6. ✅ Tema Rosa (Pink)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema rosa suave e elegante
|
||||
- **Screenshot**: `tema-rosa.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de rosa
|
||||
|
||||
### 7. ✅ Tema Verde-água (Teal)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema verde-água refrescante
|
||||
- **Screenshot**: `tema-verde-agua.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de verde-água
|
||||
|
||||
### 8. ✅ Tema Escuro (Dark)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema escuro para uso noturno
|
||||
- **Screenshot**: `tema-escuro.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe fundo escuro
|
||||
|
||||
### 9. ✅ Tema Claro (Light)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema claro e minimalista
|
||||
- **Screenshot**: `tema-claro.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe fundo claro
|
||||
|
||||
### 10. ✅ Tema Corporativo (Corporate)
|
||||
- **Status**: Funcionando
|
||||
- **Descrição**: Tema corporativo azul escuro
|
||||
- **Screenshot**: `tema-corporativo.png`
|
||||
- **Observações**: Tema aplicado corretamente, interface exibe tons corporativos
|
||||
|
||||
## Funcionalidades Testadas
|
||||
|
||||
### ✅ Seleção de Temas
|
||||
- Todos os 10 temas podem ser selecionados através dos botões na interface
|
||||
- A seleção é visualmente indicada com "Tema Ativo"
|
||||
- A mudança de tema é aplicada imediatamente na interface
|
||||
|
||||
### ✅ Interface de Seleção
|
||||
- A aba "Aparência" está acessível na página de perfil
|
||||
- Todos os 10 temas são exibidos em cards com preview visual
|
||||
- Cada card mostra o nome, descrição e um gradiente de cores representativo
|
||||
|
||||
### ✅ Aplicação de Temas
|
||||
- Os temas são aplicados dinamicamente ao elemento `<html>` via atributo `data-theme`
|
||||
- As cores são alteradas em toda a interface (sidebar, header, botões, etc.)
|
||||
- A mudança é instantânea, sem necessidade de recarregar a página
|
||||
|
||||
## Screenshots Capturados
|
||||
|
||||
Todos os screenshots foram salvos com os seguintes nomes:
|
||||
- `tema-verde-agua-atual.png` - Estado inicial (tema verde-água)
|
||||
- `tema-roxo.png`
|
||||
- `tema-azul.png`
|
||||
- `tema-verde.png`
|
||||
- `tema-laranja.png`
|
||||
- `tema-vermelho.png`
|
||||
- `tema-rosa.png`
|
||||
- `tema-verde-agua.png`
|
||||
- `tema-escuro.png`
|
||||
- `tema-claro.png`
|
||||
- `tema-corporativo.png`
|
||||
|
||||
## Conclusão
|
||||
|
||||
✅ **Todos os 10 temas estão funcionando corretamente!**
|
||||
|
||||
- Cada tema altera a aparência da interface conforme esperado
|
||||
- As cores são aplicadas consistentemente em todos os componentes
|
||||
- A seleção de temas funciona de forma intuitiva e responsiva
|
||||
- O sistema está pronto para uso em produção
|
||||
|
||||
## Próximos Passos Recomendados
|
||||
|
||||
1. Testar a persistência do tema salvo no banco de dados
|
||||
2. Validar que o tema é aplicado automaticamente ao fazer login
|
||||
3. Verificar que o tema padrão (roxo) é aplicado ao fazer logout
|
||||
4. Testar com diferentes usuários para garantir isolamento de preferências
|
||||
|
||||
89
VALIDACAO_TEMAS.md
Normal file
89
VALIDACAO_TEMAS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Validação e Correções do Sistema de Temas
|
||||
|
||||
## Correções Implementadas
|
||||
|
||||
### 1. Temas Customizados Melhorados
|
||||
- Adicionadas todas as variáveis CSS necessárias do DaisyUI para cada tema customizado
|
||||
- Incluídas variáveis de arredondamento, animação e bordas
|
||||
- Adicionado `color-scheme` para temas claros/escuros
|
||||
|
||||
### 2. Estrutura Padronizada
|
||||
- Todos os temas customizados seguem o mesmo padrão de variáveis CSS
|
||||
- Temas nativos do DaisyUI (purple/aqua, dark, light) mantidos
|
||||
- Temas customizados (sgse-blue, sgse-green, etc.) com variáveis completas
|
||||
|
||||
### 3. Aplicação de Temas
|
||||
- Função `aplicarTema()` atualizada para aplicar corretamente no elemento HTML
|
||||
- Removido localStorage - tema salvo apenas no banco de dados
|
||||
- Tema padrão aplicado ao fazer logout
|
||||
|
||||
## Como Testar Manualmente
|
||||
|
||||
1. **Fazer Login:**
|
||||
- Email: `dfw@poli.br` / Senha: `Admin@2025`
|
||||
- OU Email: `kilder@kilder.com.br` / Senha: `Mudar@123`
|
||||
|
||||
2. **Acessar Página de Perfil:**
|
||||
- Clique no avatar do usuário no canto superior direito
|
||||
- Selecione "Meu Perfil"
|
||||
- OU acesse diretamente: `/perfil`
|
||||
|
||||
3. **Testar Cada Tema:**
|
||||
- Clique na aba "Aparência"
|
||||
- Teste cada um dos 10 temas:
|
||||
- **Roxo** (purple/aqua) - Padrão
|
||||
- **Azul** (sgse-blue)
|
||||
- **Verde** (sgse-green)
|
||||
- **Laranja** (sgse-orange)
|
||||
- **Vermelho** (sgse-red)
|
||||
- **Rosa** (sgse-pink)
|
||||
- **Verde-água** (sgse-teal)
|
||||
- **Escuro** (dark)
|
||||
- **Claro** (light)
|
||||
- **Corporativo** (sgse-corporate)
|
||||
|
||||
4. **Validar Mudanças:**
|
||||
- Ao clicar em um tema, a interface deve mudar imediatamente
|
||||
- Verificar cores em:
|
||||
- Sidebar
|
||||
- Botões
|
||||
- Cards
|
||||
- Badges
|
||||
- Links
|
||||
- Backgrounds
|
||||
|
||||
5. **Salvar Tema:**
|
||||
- Clique em "Salvar Tema" após selecionar
|
||||
- Faça logout e login novamente
|
||||
- O tema salvo deve ser aplicado automaticamente
|
||||
|
||||
6. **Testar Logout:**
|
||||
- Ao fazer logout, o tema deve voltar ao padrão (roxo)
|
||||
|
||||
## Problemas Identificados e Corrigidos
|
||||
|
||||
1. ✅ Variáveis CSS incompletas nos temas customizados
|
||||
2. ✅ Falta de `color-scheme` nos temas
|
||||
3. ✅ localStorage removido (tema apenas no banco)
|
||||
4. ✅ Tema padrão aplicado ao logout
|
||||
5. ✅ Estrutura padronizada de todos os temas
|
||||
|
||||
## Próximos Passos para Validação
|
||||
|
||||
Se algum tema não estiver funcionando:
|
||||
|
||||
1. Verificar no console do navegador (F12) se há erros
|
||||
2. Verificar o atributo `data-theme` no elemento `<html>` (deve mudar ao selecionar tema)
|
||||
3. Verificar se as variáveis CSS estão sendo aplicadas (DevTools > Elements > Computed)
|
||||
4. Testar em modo anônimo para garantir que não há cache
|
||||
|
||||
## Arquivos Modificados
|
||||
|
||||
- `apps/web/src/app.css` - Temas customizados melhorados
|
||||
- `apps/web/src/lib/utils/temas.ts` - Funções de aplicação de temas
|
||||
- `apps/web/src/routes/+layout.svelte` - Aplicação automática do tema
|
||||
- `apps/web/src/routes/(dashboard)/perfil/+page.svelte` - Interface de seleção
|
||||
- `apps/web/src/lib/components/Sidebar.svelte` - Reset de tema no logout
|
||||
- `packages/backend/convex/schema.ts` - Campo temaPreferido
|
||||
- `packages/backend/convex/usuarios.ts` - Função atualizarTema
|
||||
|
||||
29
apps/web/CONFIGURACAO_ENV.md
Normal file
29
apps/web/CONFIGURACAO_ENV.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# ⚙️ Configuração de Variáveis de Ambiente
|
||||
|
||||
## 📁 Arquivo .env
|
||||
|
||||
Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis:
|
||||
|
||||
```env
|
||||
# Google Maps API Key (opcional)
|
||||
# Obtenha sua chave em: https://console.cloud.google.com/
|
||||
# Ative a "Geocoding API" para buscar coordenadas por endereço
|
||||
# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave)
|
||||
VITE_GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# VAPID Public Key para Push Notifications (opcional)
|
||||
VITE_VAPID_PUBLIC_KEY=
|
||||
```
|
||||
|
||||
## 📖 Documentação Completa
|
||||
|
||||
Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte:
|
||||
|
||||
📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)**
|
||||
|
||||
## ⚠️ Importante
|
||||
|
||||
- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore)
|
||||
- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend
|
||||
- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env`
|
||||
|
||||
174
apps/web/GOOGLE_MAPS_SETUP.md
Normal file
174
apps/web/GOOGLE_MAPS_SETUP.md
Normal file
@@ -0,0 +1,174 @@
|
||||
# 📍 Configuração do Google Maps API para Busca de Coordenadas
|
||||
|
||||
Este guia explica como configurar a API do Google Maps para obter coordenadas GPS de forma automática e precisa no sistema de Endereços de Marcação.
|
||||
|
||||
## 🎯 Por que usar Google Maps?
|
||||
|
||||
- ✅ **Maior Precisão**: Resultados mais exatos para endereços brasileiros
|
||||
- ✅ **Melhor Cobertura**: Banco de dados mais completo e atualizado
|
||||
- ✅ **Geocoding Avançado**: Entende melhor endereços incompletos ou parciais
|
||||
|
||||
> **Nota**: O sistema funciona perfeitamente sem a API key do Google Maps, usando OpenStreetMap (gratuito). A configuração do Google Maps é opcional.
|
||||
|
||||
---
|
||||
|
||||
## 📋 Passo a Passo
|
||||
|
||||
### 1. Criar Projeto no Google Cloud Platform
|
||||
|
||||
1. Acesse [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. Clique em **"Criar Projeto"** ou selecione um projeto existente
|
||||
3. Preencha o nome do projeto (ex: "SGSE-App")
|
||||
4. Clique em **"Criar"**
|
||||
|
||||
### 2. Ativar a Geocoding API
|
||||
|
||||
1. No menu lateral, vá em **"APIs e Serviços"** > **"Biblioteca"**
|
||||
2. Procure por **"Geocoding API"**
|
||||
3. Clique no resultado e depois em **"Ativar"**
|
||||
4. Aguarde alguns segundos para a ativação
|
||||
|
||||
### 3. Criar Chave de API
|
||||
|
||||
1. Ainda em **"APIs e Serviços"**, vá em **"Credenciais"**
|
||||
2. Clique em **"Criar Credenciais"** > **"Chave de API"**
|
||||
3. Copie a chave gerada (você precisará dela depois)
|
||||
|
||||
### 4. Configurar Restrições de Segurança (Recomendado)
|
||||
|
||||
Para proteger sua chave de API:
|
||||
|
||||
1. Clique na chave criada para editá-la
|
||||
2. Em **"Restrições de API"**:
|
||||
- Selecione **"Restringir chave"**
|
||||
- Escolha **"Geocoding API"**
|
||||
3. Em **"Restrições de aplicativo"**:
|
||||
- Para desenvolvimento local: escolha **"Referenciadores de sites HTTP"**
|
||||
- Adicione: `http://localhost:*` e `http://127.0.0.1:*`
|
||||
- Para produção: adicione o domínio do seu site
|
||||
4. Clique em **"Salvar"**
|
||||
|
||||
### 5. Configurar no Projeto
|
||||
|
||||
1. No diretório `apps/web/`, copie o arquivo de exemplo:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. Abra o arquivo `.env` e adicione sua chave:
|
||||
```env
|
||||
VITE_GOOGLE_MAPS_API_KEY=sua_chave_aqui
|
||||
```
|
||||
|
||||
3. Reinicie o servidor de desenvolvimento:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 6. Verificar se está funcionando
|
||||
|
||||
1. Acesse a página de **Endereços de Marcação** (`/ti/configuracoes-ponto/enderecos`)
|
||||
2. Clique em **"Novo Endereço"**
|
||||
3. Preencha um endereço e clique em **"Buscar GPS"**
|
||||
4. Se configurado corretamente, verá a mensagem: *"Coordenadas encontradas via Google Maps!"*
|
||||
|
||||
---
|
||||
|
||||
## 💰 Custos
|
||||
|
||||
### Google Maps Geocoding API
|
||||
|
||||
- **$5.00 por 1.000 requisições** (primeiros 40.000 são gratuitos por mês)
|
||||
- **$0.005 por requisição** após os 40.000 gratuitos
|
||||
|
||||
> 💡 Para a maioria dos casos de uso, os 40.000 gratuitos são suficientes!
|
||||
|
||||
### OpenStreetMap (Fallback)
|
||||
|
||||
- **100% Gratuito** e ilimitado
|
||||
- Sem necessidade de configuração
|
||||
- Precisão levemente menor, mas ainda muito boa
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Como funciona o sistema
|
||||
|
||||
O sistema foi projetado para usar uma estratégia de **fallback inteligente**:
|
||||
|
||||
1. **Primeiro**: Tenta buscar via Google Maps (se API key configurada)
|
||||
2. **Se falhar ou não tiver API key**: Usa automaticamente OpenStreetMap
|
||||
3. **Feedback**: Informa qual serviço foi usado na mensagem de sucesso
|
||||
|
||||
Isso garante que o sistema sempre funcione, mesmo sem a API key do Google Maps.
|
||||
|
||||
---
|
||||
|
||||
## 🔒 Segurança
|
||||
|
||||
### ⚠️ Importante
|
||||
|
||||
- **Nunca** commite o arquivo `.env` no Git (já está no .gitignore)
|
||||
- **Nunca** compartilhe sua chave de API publicamente
|
||||
- Configure **restrições de API** no Google Cloud Console
|
||||
- Para produção, use variáveis de ambiente seguras no seu provedor de hospedagem
|
||||
|
||||
### Configuração em Produção
|
||||
|
||||
Para ambientes de produção (Vercel, Netlify, etc.):
|
||||
|
||||
1. Acesse as configurações do projeto no seu provedor
|
||||
2. Vá em **"Environment Variables"** ou **"Variáveis de Ambiente"**
|
||||
3. Adicione: `VITE_GOOGLE_MAPS_API_KEY` com o valor da sua chave
|
||||
4. Faça o deploy novamente
|
||||
|
||||
---
|
||||
|
||||
## ❓ Solução de Problemas
|
||||
|
||||
### A busca não está usando Google Maps
|
||||
|
||||
- Verifique se a variável `VITE_GOOGLE_MAPS_API_KEY` está no arquivo `.env`
|
||||
- Reinicie o servidor de desenvolvimento
|
||||
- Verifique no console do navegador se há erros
|
||||
|
||||
### Erro: "This API project is not authorized to use this API"
|
||||
|
||||
- Verifique se a **Geocoding API** está ativada no projeto
|
||||
- Aguarde alguns minutos após a ativação (pode levar até 5 minutos)
|
||||
|
||||
### Erro: "API key not valid"
|
||||
|
||||
- Verifique se copiou a chave corretamente
|
||||
- Verifique se as restrições de API permitem o uso da Geocoding API
|
||||
- Verifique se as restrições de aplicativo permitem seu domínio/endereço
|
||||
|
||||
### Mensagem: "Coordenadas encontradas via OpenStreetMap"
|
||||
|
||||
- Isso é normal se:
|
||||
- Não há API key configurada
|
||||
- A API key não é válida
|
||||
- O Google Maps falhou na busca
|
||||
- O sistema continua funcionando normalmente com OpenStreetMap
|
||||
|
||||
---
|
||||
|
||||
## 📚 Recursos Úteis
|
||||
|
||||
- [Google Cloud Console](https://console.cloud.google.com/)
|
||||
- [Documentação Geocoding API](https://developers.google.com/maps/documentation/geocoding)
|
||||
- [Preços Google Maps](https://developers.google.com/maps/billing-and-pricing/pricing)
|
||||
- [OpenStreetMap Nominatim](https://nominatim.org/)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Resumo
|
||||
|
||||
1. ✅ Crie projeto no Google Cloud
|
||||
2. ✅ Ative Geocoding API
|
||||
3. ✅ Crie chave de API
|
||||
4. ✅ Configure restrições (recomendado)
|
||||
5. ✅ Adicione `VITE_GOOGLE_MAPS_API_KEY` no `.env`
|
||||
6. ✅ Reinicie o servidor
|
||||
|
||||
**Pronto!** O sistema agora usará Google Maps para busca de coordenadas com maior precisão.
|
||||
|
||||
@@ -7,14 +7,22 @@ import { defineConfig } from "eslint/config";
|
||||
export default defineConfig([
|
||||
...svelteConfigBase,
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
files: ['**/*.svelte'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'**/node_modules/**',
|
||||
'**/.svelte-kit/**',
|
||||
'**/build/**',
|
||||
'**/dist/**',
|
||||
'**/.turbo/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
])
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
"vite": "^7.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "catalog:",
|
||||
"@convex-dev/better-auth": "^0.9.7",
|
||||
"@dicebear/collection": "^9.2.4",
|
||||
"@dicebear/core": "^9.2.4",
|
||||
@@ -44,12 +43,14 @@
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"better-auth": "catalog:",
|
||||
"convex": "catalog:",
|
||||
"convex-svelte": "^0.0.11",
|
||||
"convex-svelte": "^0.0.12",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-picker-element": "^1.27.0",
|
||||
"eslint": "catalog:",
|
||||
"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",
|
||||
"papaparse": "^5.4.1",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
inset: -2px;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(15, 23, 42, 0.04),
|
||||
0 14px 32px -22px rgba(15, 23, 42, 0.45),
|
||||
0 6px 18px -16px rgba(102, 126, 234, 0.35);
|
||||
0 0 0 1px hsl(var(--bc) / 0.04),
|
||||
0 14px 32px -22px hsl(var(--bc) / 0.45),
|
||||
0 6px 18px -16px hsl(var(--p) / 0.35);
|
||||
opacity: 0.55;
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
pointer-events: none;
|
||||
@@ -48,7 +48,7 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.12), rgba(118, 75, 162, 0.12));
|
||||
background: linear-gradient(135deg, hsl(var(--p) / 0.12), hsl(var(--s) / 0.12));
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
@@ -58,7 +58,7 @@
|
||||
|
||||
:where(.card, .card-hover):hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.35);
|
||||
box-shadow: 0 20px 45px -20px hsl(var(--bc) / 0.35);
|
||||
}
|
||||
|
||||
:where(.card, .card-hover):hover::before {
|
||||
@@ -74,4 +74,407 @@
|
||||
:where(.card, .card-hover) > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Tema Aqua (padrão roxo/azul) - customizado para garantir funcionamento */
|
||||
html[data-theme="aqua"],
|
||||
html[data-theme="aqua"] body,
|
||||
[data-theme="aqua"] {
|
||||
color-scheme: light;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 20% 17%;
|
||||
--nf: 217 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 217 20% 95%;
|
||||
--b3: 217 20% 90%;
|
||||
--bc: 217 20% 17%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Temas customizados para SGSE - Azul */
|
||||
html[data-theme="sgse-blue"],
|
||||
html[data-theme="sgse-blue"] body,
|
||||
[data-theme="sgse-blue"] {
|
||||
color-scheme: light;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 20% 17%;
|
||||
--nf: 217 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 217 20% 95%;
|
||||
--b3: 217 20% 90%;
|
||||
--bc: 217 20% 17%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Garantir que todas as variáveis CSS sejam aplicadas em todos os elementos */
|
||||
html[data-theme] {
|
||||
color-scheme: var(--color-scheme, light);
|
||||
}
|
||||
|
||||
html[data-theme] * {
|
||||
color-scheme: inherit;
|
||||
}
|
||||
|
||||
html[data-theme="sgse-green"],
|
||||
html[data-theme="sgse-green"] body,
|
||||
[data-theme="sgse-green"] {
|
||||
color-scheme: light;
|
||||
--p: 142 76% 36%;
|
||||
--pf: 142 76% 26%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 142 76% 36%;
|
||||
--sf: 142 76% 26%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 142 76% 36%;
|
||||
--af: 142 76% 26%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 142 20% 17%;
|
||||
--nf: 142 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 142 20% 95%;
|
||||
--b3: 142 20% 90%;
|
||||
--bc: 142 20% 17%;
|
||||
--in: 142 76% 36%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-theme="sgse-orange"],
|
||||
html[data-theme="sgse-orange"] body,
|
||||
[data-theme="sgse-orange"] {
|
||||
color-scheme: light;
|
||||
--p: 25 95% 53%;
|
||||
--pf: 25 95% 43%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 25 95% 53%;
|
||||
--sf: 25 95% 43%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 25 95% 53%;
|
||||
--af: 25 95% 43%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 25 20% 17%;
|
||||
--nf: 25 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 25 20% 95%;
|
||||
--b3: 25 20% 90%;
|
||||
--bc: 25 20% 17%;
|
||||
--in: 25 95% 53%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-theme="sgse-red"],
|
||||
html[data-theme="sgse-red"] body,
|
||||
[data-theme="sgse-red"] {
|
||||
color-scheme: light;
|
||||
--p: 0 84% 60%;
|
||||
--pf: 0 84% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 0 84% 60%;
|
||||
--sf: 0 84% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 0 84% 60%;
|
||||
--af: 0 84% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 0 20% 17%;
|
||||
--nf: 0 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 0 20% 95%;
|
||||
--b3: 0 20% 90%;
|
||||
--bc: 0 20% 17%;
|
||||
--in: 0 84% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-theme="sgse-pink"],
|
||||
html[data-theme="sgse-pink"] body,
|
||||
[data-theme="sgse-pink"] {
|
||||
color-scheme: light;
|
||||
--p: 330 81% 60%;
|
||||
--pf: 330 81% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 330 81% 60%;
|
||||
--sf: 330 81% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 330 81% 60%;
|
||||
--af: 330 81% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 330 20% 17%;
|
||||
--nf: 330 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 330 20% 95%;
|
||||
--b3: 330 20% 90%;
|
||||
--bc: 330 20% 17%;
|
||||
--in: 330 81% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-theme="sgse-teal"],
|
||||
html[data-theme="sgse-teal"] body,
|
||||
[data-theme="sgse-teal"] {
|
||||
color-scheme: light;
|
||||
--p: 173 80% 40%;
|
||||
--pf: 173 80% 30%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 173 80% 40%;
|
||||
--sf: 173 80% 30%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 173 80% 40%;
|
||||
--af: 173 80% 30%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 173 20% 17%;
|
||||
--nf: 173 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 173 20% 95%;
|
||||
--b3: 173 20% 90%;
|
||||
--bc: 173 20% 17%;
|
||||
--in: 173 80% 40%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html[data-theme="sgse-corporate"],
|
||||
html[data-theme="sgse-corporate"] body,
|
||||
[data-theme="sgse-corporate"] {
|
||||
color-scheme: dark;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 30% 15%;
|
||||
--nf: 217 30% 8%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 217 30% 10%;
|
||||
--b2: 217 30% 15%;
|
||||
--b3: 217 30% 20%;
|
||||
--bc: 217 10% 90%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tema Light customizado para garantir funcionamento completo */
|
||||
html[data-theme="light"],
|
||||
html[data-theme="light"] body,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 20% 17%;
|
||||
--nf: 217 20% 10%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 0 0% 100%;
|
||||
--b2: 217 20% 95%;
|
||||
--b3: 217 20% 90%;
|
||||
--bc: 217 20% 17%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Tema Dark customizado para garantir funcionamento completo */
|
||||
html[data-theme="dark"],
|
||||
html[data-theme="dark"] body,
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--p: 217 91% 60%;
|
||||
--pf: 217 91% 50%;
|
||||
--pc: 0 0% 100%;
|
||||
--s: 217 91% 60%;
|
||||
--sf: 217 91% 50%;
|
||||
--sc: 0 0% 100%;
|
||||
--a: 217 91% 60%;
|
||||
--af: 217 91% 50%;
|
||||
--ac: 0 0% 100%;
|
||||
--n: 217 30% 15%;
|
||||
--nf: 217 30% 8%;
|
||||
--nc: 0 0% 100%;
|
||||
--b1: 217 30% 10%;
|
||||
--b2: 217 30% 15%;
|
||||
--b3: 217 30% 20%;
|
||||
--bc: 217 10% 90%;
|
||||
--in: 217 91% 60%;
|
||||
--inc: 0 0% 100%;
|
||||
--su: 142 76% 36%;
|
||||
--suc: 0 0% 100%;
|
||||
--wa: 38 92% 50%;
|
||||
--wac: 0 0% 100%;
|
||||
--er: 0 84% 60%;
|
||||
--erc: 0 0% 100%;
|
||||
--rounded-box: 1rem;
|
||||
--rounded-btn: 0.5rem;
|
||||
--rounded-badge: 1.9rem;
|
||||
--animation-btn: 0.25s;
|
||||
--animation-input: 0.2s;
|
||||
--btn-focus-scale: 0.95;
|
||||
--border-btn: 1px;
|
||||
--tab-border: 1px;
|
||||
--tab-radius: 0.5rem;
|
||||
}
|
||||
@@ -1,10 +1,122 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="aqua">
|
||||
<html lang="en" id="html-theme">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%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.getAttribute('data-theme')) {
|
||||
html.setAttribute('data-theme', 'aqua');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
@@ -8,6 +8,11 @@
|
||||
import { createAuthClient } from "better-auth/svelte";
|
||||
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
||||
|
||||
// O baseURL deve apontar para o frontend (SvelteKit), não para o Convex diretamente
|
||||
// O Better Auth usa as rotas HTTP do Convex que são acessadas via proxy do SvelteKit
|
||||
// ou diretamente se configurado. Com o plugin convexClient, o token é gerenciado automaticamente.
|
||||
export const authClient = createAuthClient({
|
||||
// baseURL padrão é window.location.origin, que é o correto para SvelteKit
|
||||
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts
|
||||
plugins: [convexClient()],
|
||||
});
|
||||
|
||||
276
apps/web/src/lib/components/AlterarStatusFerias.svelte
Normal file
276
apps/web/src/lib/components/AlterarStatusFerias.svelte
Normal file
@@ -0,0 +1,276 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
type PeriodoFerias = Doc<'ferias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
gestor?: Doc<'usuarios'> | null;
|
||||
time?: Doc<'times'> | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
solicitacao: PeriodoFerias;
|
||||
usuarioId: Id<'usuarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
aguardando_aprovacao: 'badge-warning',
|
||||
aprovado: 'badge-success',
|
||||
reprovado: 'badge-error',
|
||||
data_ajustada_aprovada: 'badge-info',
|
||||
EmFérias: 'badge-info',
|
||||
Cancelado_RH: 'badge-error'
|
||||
};
|
||||
return badges[status] || 'badge-neutral';
|
||||
}
|
||||
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: 'Aguardando Aprovação',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado',
|
||||
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
|
||||
EmFérias: 'Em Férias',
|
||||
Cancelado_RH: 'Cancelado RH'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
async function cancelarPorRH() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.atualizarStatus, {
|
||||
feriasId: solicitacao._id,
|
||||
novoStatus: 'Cancelado_RH',
|
||||
usuarioId: usuarioId
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatarData(data: number) {
|
||||
return new Date(data).toLocaleString('pt-BR');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl">
|
||||
{solicitacao.funcionario?.nome || 'Funcionário'}
|
||||
</h2>
|
||||
<p class="text-base-content/70 mt-1 text-sm">
|
||||
Ano de Referência: {solicitacao.anoReferencia}
|
||||
</p>
|
||||
</div>
|
||||
<div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Período Solicitado -->
|
||||
<div class="mt-4">
|
||||
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3>
|
||||
<div class="bg-base-200 rounded-lg p-4">
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-base-content/70">Início:</span>
|
||||
<span class="ml-1 font-semibold"
|
||||
>{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Fim:</span>
|
||||
<span class="ml-1 font-semibold"
|
||||
>{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Dias:</span>
|
||||
<span class="text-primary ml-1 font-bold">{solicitacao.diasFerias}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
{#if solicitacao.observacao}
|
||||
<div class="mt-4">
|
||||
<h3 class="mb-2 font-semibold">Observações</h3>
|
||||
<div class="bg-base-200 rounded-lg p-3 text-sm">
|
||||
{solicitacao.observacao}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Histórico -->
|
||||
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
|
||||
<div class="mt-4">
|
||||
<h3 class="mb-2 font-semibold">Histórico</h3>
|
||||
<div class="space-y-1">
|
||||
{#each solicitacao.historicoAlteracoes as hist (hist.data)}
|
||||
<div class="text-base-content/70 flex items-center gap-2 text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatarData(hist.data)}</span>
|
||||
<span>-</span>
|
||||
<span>{hist.acao}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ação: Cancelar por RH -->
|
||||
{#if solicitacao.status !== 'Cancelado_RH'}
|
||||
<div class="divider mt-6"></div>
|
||||
<div class="alert alert-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Cancelar Férias</h3>
|
||||
<div class="text-sm">
|
||||
Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não poderá mais ser processada.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions mt-4 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error gap-2"
|
||||
onclick={cancelarPorRH}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar Férias (RH)
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divider mt-6"></div>
|
||||
<div class="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Esta solicitação já foi cancelada pelo RH.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Motivo Reprovação (se reprovado) -->
|
||||
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-bold">Motivo da Reprovação:</div>
|
||||
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Fechar -->
|
||||
{#if onCancelar}
|
||||
<div class="card-actions mt-4 justify-end">
|
||||
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,426 +1,428 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import ErrorModal from "./ErrorModal.svelte";
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ErrorModal from './ErrorModal.svelte';
|
||||
|
||||
type SolicitacaoAusencia = Doc<"solicitacoesAusencias"> & {
|
||||
funcionario?: Doc<"funcionarios"> | null;
|
||||
gestor?: Doc<"usuarios"> | null;
|
||||
time?: Doc<"times"> | null;
|
||||
};
|
||||
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;
|
||||
}
|
||||
interface Props {
|
||||
solicitacao: SolicitacaoAusencia;
|
||||
gestorId: Id<'usuarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient();
|
||||
|
||||
let motivoReprovacao = $state("");
|
||||
let processando = $state(false);
|
||||
let erro = $state("");
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state("");
|
||||
let motivoReprovacao = $state('');
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
let mostrarModalErro = $state(false);
|
||||
let mensagemErroModal = $state('');
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const totalDias = $derived(
|
||||
calcularDias(solicitacao.dataInicio, solicitacao.dataFim),
|
||||
);
|
||||
const totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim));
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
mostrarModalErro = false;
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
mostrarModalErro = false;
|
||||
|
||||
await client.mutation(api.ausencias.aprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
});
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = 'Informe o motivo da reprovação';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
mostrarModalErro = false;
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
mostrarModalErro = false;
|
||||
|
||||
await client.mutation(api.ausencias.reprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao: motivoReprovacao.trim(),
|
||||
});
|
||||
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);
|
||||
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;
|
||||
}
|
||||
}
|
||||
// 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 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 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;
|
||||
}
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: 'Aguardando Aprovação',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="aprovar-ausencia">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-3xl font-bold text-primary mb-2">
|
||||
Aprovar/Reprovar Ausência
|
||||
</h2>
|
||||
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-primary mb-2 text-3xl font-bold">Aprovar/Reprovar Ausência</h2>
|
||||
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Principal -->
|
||||
<div class="card bg-base-100 shadow-2xl border-t-4 border-orange-500">
|
||||
<div class="card-body">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
Funcionário
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Nome</p>
|
||||
<p class="font-bold text-lg">
|
||||
{solicitacao.funcionario?.nome || "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
{#if solicitacao.time}
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Time</p>
|
||||
<div
|
||||
class="badge badge-lg font-semibold"
|
||||
style="background-color: {solicitacao.time
|
||||
.cor}20; border-color: {solicitacao.time
|
||||
.cor}; color: {solicitacao.time.cor}"
|
||||
>
|
||||
{solicitacao.time.nome}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card Principal -->
|
||||
<div class="card bg-base-100 border-t-4 border-primary shadow-2xl">
|
||||
<div class="card-body p-8">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
|
||||
<div class="rounded-lg bg-primary/10 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Funcionário
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
||||
<div class="rounded-xl bg-base-200/50 p-4 transition-all hover:bg-base-200">
|
||||
<p class="mb-2 text-sm font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Nome
|
||||
</p>
|
||||
<p class="text-lg font-bold text-base-content">
|
||||
{solicitacao.funcionario?.nome || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{#if solicitacao.time}
|
||||
<div class="rounded-xl bg-base-200/50 p-4 transition-all hover:bg-base-200">
|
||||
<p class="mb-2 text-sm font-semibold uppercase tracking-wide text-base-content/60">
|
||||
Time
|
||||
</p>
|
||||
<div
|
||||
class="badge badge-lg font-semibold"
|
||||
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
|
||||
.cor}; color: {solicitacao.time.cor}"
|
||||
>
|
||||
{solicitacao.time.nome}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-6"></div>
|
||||
|
||||
<!-- Período da Ausência -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Período da Ausência
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="stat bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="stat-title">Data Início</div>
|
||||
<div
|
||||
class="stat-value text-orange-600 dark:text-orange-400 text-2xl"
|
||||
>
|
||||
{new Date(solicitacao.dataInicio).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="stat-title">Data Fim</div>
|
||||
<div
|
||||
class="stat-value text-orange-600 dark:text-orange-400 text-2xl"
|
||||
>
|
||||
{new Date(solicitacao.dataFim).toLocaleDateString("pt-BR")}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 rounded-xl border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="stat-title">Total de Dias</div>
|
||||
<div
|
||||
class="stat-value text-orange-600 dark:text-orange-400 text-3xl"
|
||||
>
|
||||
{totalDias}
|
||||
</div>
|
||||
<div class="stat-desc">dias corridos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Período da Ausência -->
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
|
||||
<div class="rounded-lg bg-primary/10 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Período da Ausência
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
|
||||
>
|
||||
<div class="stat-title text-base-content/70">Data Início</div>
|
||||
<div class="stat-value text-2xl text-primary">
|
||||
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
|
||||
>
|
||||
<div class="stat-title text-base-content/70">Data Fim</div>
|
||||
<div class="stat-value text-2xl text-primary">
|
||||
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-primary/30 bg-gradient-to-br from-primary/10 to-primary/15 shadow-md transition-all hover:border-primary/40 hover:shadow-lg"
|
||||
>
|
||||
<div class="stat-title text-base-content/70">Total de Dias</div>
|
||||
<div class="stat-value text-3xl font-bold text-primary">
|
||||
{totalDias}
|
||||
</div>
|
||||
<div class="stat-desc text-base-content/60">dias corridos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-6"></div>
|
||||
|
||||
<!-- Motivo -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Motivo da Ausência
|
||||
</h3>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<p class="whitespace-pre-wrap">{solicitacao.motivo}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Motivo -->
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
|
||||
<div class="rounded-lg bg-primary/10 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
Motivo da Ausência
|
||||
</h3>
|
||||
<div class="card rounded-xl border-2 border-primary/10 bg-base-200/50 shadow-sm">
|
||||
<div class="card-body p-5">
|
||||
<p class="whitespace-pre-wrap leading-relaxed text-base-content">
|
||||
{solicitacao.motivo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Atual -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold">Status:</span>
|
||||
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Status Atual -->
|
||||
<div class="mb-8 rounded-xl bg-base-200/30 p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm font-semibold uppercase tracking-wide text-base-content/70"
|
||||
>Status:</span
|
||||
>
|
||||
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mb-6 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações -->
|
||||
{#if solicitacao.status === "aguardando_aprovacao"}
|
||||
<div class="card-actions justify-end gap-4 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-lg gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Reprovar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-lg gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Aprovar
|
||||
</button>
|
||||
</div>
|
||||
<!-- Ações -->
|
||||
{#if solicitacao.status === 'aguardando_aprovacao'}
|
||||
<div class="card-actions mt-8 justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-lg gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Reprovar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-lg gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
Aprovar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Reprovação -->
|
||||
{#if motivoReprovacao !== undefined}
|
||||
<div class="mt-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo-reprovacao">
|
||||
<span class="label-text font-bold">Motivo da Reprovação</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo-reprovacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Informe o motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Esta solicitação já foi processada.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Modal de Reprovação -->
|
||||
{#if motivoReprovacao !== undefined}
|
||||
<div class="mt-6 rounded-xl border-2 border-error/20 bg-error/5 p-5">
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo-reprovacao">
|
||||
<span class="label-text font-bold text-error">Motivo da Reprovação</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo-reprovacao"
|
||||
class="textarea textarea-bordered h-24 focus:border-error focus:outline-error"
|
||||
placeholder="Informe o motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="alert alert-info shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Esta solicitação já foi processada.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Cancelar -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Botão Cancelar -->
|
||||
<div class="mt-6 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Erro -->
|
||||
<ErrorModal
|
||||
open={mostrarModalErro}
|
||||
title="Erro de Permissão"
|
||||
message={mensagemErroModal ||
|
||||
"Você não tem permissão para realizar esta ação."}
|
||||
onClose={fecharModalErro}
|
||||
open={mostrarModalErro}
|
||||
title="Erro de Permissão"
|
||||
message={mensagemErroModal || 'Você não tem permissão para realizar esta ação.'}
|
||||
onClose={fecharModalErro}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.aprovar-ausencia {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.aprovar-ausencia {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,384 +1,521 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id, Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
interface Periodo {
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
type SolicitacaoFerias = Doc<"solicitacoesFerias"> & {
|
||||
funcionario?: Doc<"funcionarios"> | null;
|
||||
gestor?: Doc<"usuarios"> | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
solicitacao: SolicitacaoFerias;
|
||||
gestorId: Id<"usuarios">;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let modoAjuste = $state(false);
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let motivoReprovacao = $state("");
|
||||
let processando = $state(false);
|
||||
let erro = $state("");
|
||||
|
||||
$effect(() => {
|
||||
if (modoAjuste && periodos.length === 0) {
|
||||
periodos = solicitacao.periodos.map((p) => ({...p}));
|
||||
}
|
||||
});
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = "Data final não pode ser anterior à data inicial";
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = "";
|
||||
}
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.aprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = "Informe o motivo da reprovação";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.reprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ajustarEAprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.ajustarEAprovar, {
|
||||
solicitacaoId: solicitacao._id,
|
||||
gestorId: gestorId,
|
||||
novosPeriodos: periodos,
|
||||
});
|
||||
|
||||
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",
|
||||
};
|
||||
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",
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
function formatarData(data: number) {
|
||||
return new Date(data).toLocaleString("pt-BR");
|
||||
}
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
type PeriodoFerias = Doc<'ferias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
gestor?: Doc<'usuarios'> | null;
|
||||
time?: Doc<'times'> | null;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
periodo: PeriodoFerias;
|
||||
gestorId: Id<'usuarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let modoAjuste = $state(false);
|
||||
let novaDataInicio = $state(periodo.dataInicio);
|
||||
let novaDataFim = $state(periodo.dataFim);
|
||||
let motivoReprovacao = $state('');
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
|
||||
// Calcular dias do período ajustado
|
||||
const diasAjustados = $derived.by(() => {
|
||||
if (!novaDataInicio || !novaDataFim) return 0;
|
||||
const inicio = new Date(novaDataInicio);
|
||||
const fim = new Date(novaDataFim);
|
||||
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return diffDays;
|
||||
});
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
if (!dataInicio || !dataFim) return 0;
|
||||
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = 'Data final não pode ser anterior à data inicial';
|
||||
return 0;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
erro = '';
|
||||
return dias;
|
||||
}
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
// Validar se as datas e condições estão dentro do regime do funcionário
|
||||
if (!periodo.funcionario?._id) {
|
||||
erro = 'Funcionário não encontrado';
|
||||
processando = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
||||
funcionarioId: periodo.funcionario._id,
|
||||
anoReferencia: periodo.anoReferencia,
|
||||
periodos: [{
|
||||
dataInicio: periodo.dataInicio,
|
||||
dataFim: periodo.dataFim
|
||||
}]
|
||||
});
|
||||
|
||||
if (!validacao.valido) {
|
||||
erro = `Não é possível aprovar: ${validacao.erros.join('; ')}`;
|
||||
processando = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await client.mutation(api.ferias.aprovar, {
|
||||
feriasId: periodo._id,
|
||||
gestorId: gestorId
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function reprovar() {
|
||||
if (!motivoReprovacao.trim()) {
|
||||
erro = 'Informe o motivo da reprovação';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.reprovar, {
|
||||
feriasId: periodo._id,
|
||||
gestorId: gestorId,
|
||||
motivoReprovacao
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ajustarEAprovar() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
// Validar se as datas ajustadas e condições estão dentro do regime do funcionário
|
||||
if (!periodo.funcionario?._id) {
|
||||
erro = 'Funcionário não encontrado';
|
||||
processando = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar datas ajustadas
|
||||
if (!novaDataInicio || !novaDataFim) {
|
||||
erro = 'Informe as novas datas de início e fim';
|
||||
processando = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
||||
funcionarioId: periodo.funcionario._id,
|
||||
anoReferencia: periodo.anoReferencia,
|
||||
periodos: [{
|
||||
dataInicio: novaDataInicio,
|
||||
dataFim: novaDataFim
|
||||
}],
|
||||
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
|
||||
});
|
||||
|
||||
if (!validacao.valido) {
|
||||
erro = `Não é possível aprovar com ajuste: ${validacao.erros.join('; ')}`;
|
||||
processando = false;
|
||||
return;
|
||||
}
|
||||
|
||||
await client.mutation(api.ferias.ajustarEAprovar, {
|
||||
feriasId: periodo._id,
|
||||
gestorId: gestorId,
|
||||
novaDataInicio,
|
||||
novaDataFim
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e) {
|
||||
erro = e instanceof Error ? e.message : String(e);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
aguardando_aprovacao: 'badge-warning',
|
||||
aprovado: 'badge-success',
|
||||
reprovado: 'badge-error',
|
||||
data_ajustada_aprovada: 'badge-info',
|
||||
EmFérias: 'badge-info'
|
||||
};
|
||||
return badges[status] || 'badge-neutral';
|
||||
}
|
||||
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: 'Aguardando Aprovação',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado',
|
||||
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
|
||||
EmFérias: 'Em Férias'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
function formatarData(data: number) {
|
||||
return new Date(data).toLocaleString('pt-BR');
|
||||
}
|
||||
|
||||
// Função para formatar data sem problemas de timezone
|
||||
function formatarDataString(dataString: string): string {
|
||||
if (!dataString) return '';
|
||||
// Dividir a string da data (formato YYYY-MM-DD)
|
||||
const partes = dataString.split('-');
|
||||
if (partes.length !== 3) return dataString;
|
||||
// Retornar no formato DD/MM/YYYY
|
||||
return `${partes[2]}/${partes[1]}/${partes[0]}`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (modoAjuste) {
|
||||
novaDataInicio = periodo.dataInicio;
|
||||
novaDataFim = periodo.dataFim;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl">
|
||||
{solicitacao.funcionario?.nome || "Funcionário"}
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/70 mt-1">
|
||||
Ano de Referência: {solicitacao.anoReferencia}
|
||||
</p>
|
||||
</div>
|
||||
<div class={`badge ${getStatusBadge(solicitacao.status)} badge-lg`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Períodos Solicitados -->
|
||||
<div class="mt-4">
|
||||
<h3 class="font-semibold text-lg mb-3">Períodos Solicitados</h3>
|
||||
<div class="space-y-2">
|
||||
{#each solicitacao.periodos as periodo, index}
|
||||
<div class="flex items-center gap-4 p-3 bg-base-200 rounded-lg">
|
||||
<div class="badge badge-primary">{index + 1}</div>
|
||||
<div class="flex-1 grid grid-cols-3 gap-2 text-sm">
|
||||
<div>
|
||||
<span class="text-base-content/70">Início:</span>
|
||||
<span class="font-semibold ml-1">{new Date(periodo.dataInicio).toLocaleDateString("pt-BR")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Fim:</span>
|
||||
<span class="font-semibold ml-1">{new Date(periodo.dataFim).toLocaleDateString("pt-BR")}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Dias:</span>
|
||||
<span class="font-bold ml-1 text-primary">{periodo.diasCorridos}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
{#if solicitacao.observacao}
|
||||
<div class="mt-4">
|
||||
<h3 class="font-semibold mb-2">Observações</h3>
|
||||
<div class="p-3 bg-base-200 rounded-lg text-sm">
|
||||
{solicitacao.observacao}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Histórico -->
|
||||
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
|
||||
<div class="mt-4">
|
||||
<h3 class="font-semibold mb-2">Histórico</h3>
|
||||
<div class="space-y-1">
|
||||
{#each solicitacao.historicoAlteracoes as hist}
|
||||
<div class="text-xs text-base-content/70 flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{formatarData(hist.data)}</span>
|
||||
<span>-</span>
|
||||
<span>{hist.acao}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações (apenas para status aguardando_aprovacao) -->
|
||||
{#if solicitacao.status === "aguardando_aprovacao"}
|
||||
<div class="divider mt-6"></div>
|
||||
|
||||
{#if !modoAjuste}
|
||||
<!-- Modo Normal -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Aprovar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info gap-2"
|
||||
onclick={() => modoAjuste = true}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Ajustar Datas e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reprovar -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="font-semibold text-sm mb-2">Reprovar Solicitação</h4>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-sm mb-2"
|
||||
placeholder="Motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando || !motivoReprovacao.trim()}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Reprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Modo Ajuste -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Ajustar Períodos</h4>
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h5 class="font-medium mb-2">Período {index + 1}</h5>
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-inicio-${index}`}>
|
||||
<span class="label-text text-xs">Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`ajuste-inicio-${index}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-fim-${index}`}>
|
||||
<span class="label-text text-xs">Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`ajuste-fim-${index}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for={`ajuste-dias-${index}`}>
|
||||
<span class="label-text text-xs">Dias</span>
|
||||
</label>
|
||||
<div id={`ajuste-dias-${index}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
|
||||
<span class="font-bold">{periodo.diasCorridos}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => modoAjuste = false}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar Ajuste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={ajustarEAprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Confirmar e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Motivo Reprovação (se reprovado) -->
|
||||
{#if solicitacao.status === "reprovado" && solicitacao.motivoReprovacao}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-bold">Motivo da Reprovação:</div>
|
||||
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Fechar -->
|
||||
{#if onCancelar}
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={onCancelar}
|
||||
disabled={processando}
|
||||
>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="card-title text-2xl">
|
||||
{periodo.funcionario?.nome || 'Funcionário'}
|
||||
</h2>
|
||||
<p class="text-base-content/70 mt-1 text-sm">
|
||||
Ano de Referência: {periodo.anoReferencia}
|
||||
</p>
|
||||
</div>
|
||||
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
|
||||
{getStatusTexto(periodo.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Período Solicitado -->
|
||||
<div class="mt-4">
|
||||
<h3 class="mb-3 text-lg font-semibold">Período Solicitado</h3>
|
||||
<div class="bg-base-200 rounded-lg p-4">
|
||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-base-content/70">Início:</span>
|
||||
<span class="ml-1 font-semibold"
|
||||
>{formatarDataString(periodo.dataInicio)}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Fim:</span>
|
||||
<span class="ml-1 font-semibold"
|
||||
>{formatarDataString(periodo.dataFim)}</span
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Dias:</span>
|
||||
<span class="text-primary ml-1 font-bold">{periodo.diasFerias}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
{#if periodo.observacao}
|
||||
<div class="mt-4">
|
||||
<h3 class="mb-2 font-semibold">Observações</h3>
|
||||
<div class="bg-base-200 rounded-lg p-3 text-sm">
|
||||
{periodo.observacao}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Histórico -->
|
||||
{#if periodo.historicoAlteracoes && periodo.historicoAlteracoes.length > 0}
|
||||
<div class="mt-4">
|
||||
<h3 class="mb-2 font-semibold">Histórico</h3>
|
||||
<div class="space-y-1">
|
||||
{#each periodo.historicoAlteracoes as hist}
|
||||
<div class="text-base-content/70 flex items-center gap-2 text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{formatarData(hist.data)}</span>
|
||||
<span>-</span>
|
||||
<span>{hist.acao}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações (apenas para status aguardando_aprovacao) -->
|
||||
{#if periodo.status === 'aguardando_aprovacao'}
|
||||
<div class="divider mt-6"></div>
|
||||
|
||||
{#if !modoAjuste}
|
||||
<!-- Modo Normal -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Aprovar
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-info gap-2"
|
||||
onclick={() => (modoAjuste = true)}
|
||||
disabled={processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
Ajustar Datas e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Reprovar -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<h4 class="mb-2 text-sm font-semibold">Reprovar Período</h4>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-sm mb-2"
|
||||
placeholder="Motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
rows="2"
|
||||
></textarea>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando || !motivoReprovacao.trim()}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Reprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Modo Ajuste -->
|
||||
<div class="space-y-4">
|
||||
<h4 class="font-semibold">Ajustar Período</h4>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for="ajuste-inicio">
|
||||
<span class="label-text text-xs">Início</span>
|
||||
</label>
|
||||
<input
|
||||
id="ajuste-inicio"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={novaDataInicio}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="ajuste-fim">
|
||||
<span class="label-text text-xs">Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id="ajuste-fim"
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={novaDataFim}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="ajuste-dias">
|
||||
<span class="label-text text-xs">Dias</span>
|
||||
</label>
|
||||
<div
|
||||
id="ajuste-dias"
|
||||
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
>
|
||||
<span class="font-bold">{diasAjustados}</span>
|
||||
<span class="ml-2 text-xs opacity-70">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
onclick={() => (modoAjuste = false)}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar Ajuste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={ajustarEAprovar}
|
||||
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Confirmar e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Motivo Reprovação (se reprovado) -->
|
||||
{#if periodo.status === 'reprovado' && periodo.motivoReprovacao}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<div class="font-bold">Motivo da Reprovação:</div>
|
||||
<div class="text-sm">{periodo.motivoReprovacao}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Fechar -->
|
||||
{#if onCancelar}
|
||||
<div class="card-actions mt-4 justify-end">
|
||||
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,82 +1,254 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, X } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
import { AlertCircle, X, HelpCircle } from 'lucide-svelte';
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = "Erro",
|
||||
message,
|
||||
details,
|
||||
onClose,
|
||||
}: Props = $props();
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
details?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props();
|
||||
|
||||
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
// Função para calcular a posição baseada no relógio sincronizado
|
||||
function calcularPosicaoModal() {
|
||||
// Procurar pelo elemento do relógio sincronizado
|
||||
const relogioRef = document.getElementById('relogio-sincronizado-ref');
|
||||
|
||||
if (relogioRef) {
|
||||
const rect = relogioRef.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Posicionar o modal na mesma posição do relógio sincronizado
|
||||
// Centralizado horizontalmente no card do relógio
|
||||
const left = rect.left + (rect.width / 2);
|
||||
// Posicionar abaixo do card do relógio com um pequeno espaçamento
|
||||
const top = rect.bottom + 20;
|
||||
|
||||
return {
|
||||
top: top,
|
||||
left: left
|
||||
};
|
||||
}
|
||||
|
||||
// Se não encontrar, usar posição padrão (centro da tela)
|
||||
return null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && modalRef) {
|
||||
modalRef.showModal();
|
||||
} else if (!open && modalRef) {
|
||||
modalRef.close();
|
||||
}
|
||||
});
|
||||
$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) {
|
||||
// Garantir que o modal não saia da viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const modalWidth = 700; // Aproximadamente max-w-2xl
|
||||
const modalHeight = Math.min(viewportHeight * 0.9, 600);
|
||||
|
||||
let left = modalPosition.left;
|
||||
let top = modalPosition.top;
|
||||
|
||||
// Ajustar se o modal sair da viewport à direita
|
||||
if (left + (modalWidth / 2) > viewportWidth - 20) {
|
||||
left = viewportWidth - (modalWidth / 2) - 20;
|
||||
}
|
||||
// Ajustar se o modal sair da viewport à esquerda
|
||||
if (left - (modalWidth / 2) < 20) {
|
||||
left = (modalWidth / 2) + 20;
|
||||
}
|
||||
// Ajustar se o modal sair da viewport abaixo
|
||||
if (top + modalHeight > viewportHeight - 20) {
|
||||
top = viewportHeight - modalHeight - 20;
|
||||
}
|
||||
// Ajustar se o modal sair da viewport acima
|
||||
if (top < 20) {
|
||||
top = 20;
|
||||
}
|
||||
|
||||
// Usar transform para centralizar horizontalmente baseado no left calculado
|
||||
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
|
||||
}
|
||||
// Se não houver posição calculada, centralizar na tela
|
||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
|
||||
}
|
||||
|
||||
// Verificar se details contém instruções ou apenas detalhes técnicos
|
||||
const 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>
|
||||
|
||||
{#if open}
|
||||
<dialog
|
||||
bind:this={modalRef}
|
||||
class="modal"
|
||||
onclick={(e) => e.target === e.currentTarget && handleClose()}
|
||||
>
|
||||
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300">
|
||||
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2 text-error">
|
||||
<AlertCircle class="w-5 h-5" strokeWidth={2} />
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="fixed inset-0 z-50 pointer-events-none"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-error-title"
|
||||
>
|
||||
<!-- Backdrop leve -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
||||
onclick={handleClose}
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header fixo -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
|
||||
<h2 id="modal-error-title" class="text-error flex items-center gap-2 text-xl font-bold">
|
||||
<AlertCircle class="h-6 w-6" strokeWidth={2.5} />
|
||||
{title}
|
||||
</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="px-6 py-6">
|
||||
<p class="text-base-content mb-4">{message}</p>
|
||||
{#if details}
|
||||
<div class="bg-base-200 rounded-lg p-4 mb-4">
|
||||
<p class="text-sm text-base-content/70 whitespace-pre-line">{details}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Content com rolagem -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
|
||||
<!-- Mensagem principal -->
|
||||
<div class="mb-6">
|
||||
<p class="text-base-content text-base leading-relaxed font-medium">{message}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action px-6 pb-6">
|
||||
<button class="btn btn-primary" onclick={handleClose}>
|
||||
Fechar
|
||||
</button>
|
||||
</div>
|
||||
</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 h-5 w-5 shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/90 text-sm font-semibold mb-2">
|
||||
{temInstrucoes ? 'Como resolver:' : 'Informação adicional:'}
|
||||
</p>
|
||||
<div class="text-base-content/80 text-sm space-y-2">
|
||||
{#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 font-semibold shrink-0">{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>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={handleClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
<!-- Footer fixo -->
|
||||
<div class="flex justify-end px-6 py-4 border-t border-base-300 flex-shrink-0">
|
||||
<button class="btn btn-primary" onclick={handleClose}>Entendi, obrigado</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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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>
|
||||
|
||||
@@ -1,286 +1,343 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
File,
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
RefreshCw,
|
||||
} from "lucide-svelte";
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
File as FileIcon,
|
||||
Upload,
|
||||
Trash2,
|
||||
Eye,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
helpUrl?: string;
|
||||
value?: string; // storageId
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
onUpload: (file: File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}
|
||||
interface Props {
|
||||
label: string;
|
||||
helpUrl?: string;
|
||||
value?: string; // storageId
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
onUpload: (file: globalThis.File) => Promise<void>;
|
||||
onRemove: () => Promise<void>;
|
||||
}
|
||||
|
||||
let {
|
||||
label,
|
||||
helpUrl,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
required = false,
|
||||
onUpload,
|
||||
onRemove,
|
||||
}: Props = $props();
|
||||
let {
|
||||
label,
|
||||
helpUrl,
|
||||
value = $bindable(),
|
||||
disabled = false,
|
||||
required = false,
|
||||
onUpload,
|
||||
onRemove
|
||||
}: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const client = useConvexClient() as unknown as {
|
||||
storage: {
|
||||
getUrl: (id: string) => Promise<string | null>;
|
||||
};
|
||||
};
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileName = $state<string>("");
|
||||
let fileType = $state<string>("");
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let fileUrl = $state<string | null>(null);
|
||||
let fileInput: HTMLInputElement | null = null;
|
||||
let uploading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let fileName = $state<string>('');
|
||||
let fileType = $state<string>('');
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let fileUrl = $state<string | null>(null);
|
||||
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = [
|
||||
"application/pdf",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
];
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_TYPES = ['application/pdf', 'image/jpeg', 'image/jpg', 'image/png'];
|
||||
|
||||
// Buscar URL do arquivo quando houver um storageId
|
||||
$effect(() => {
|
||||
if (value && !fileName) {
|
||||
// Tem storageId mas não é um upload recente
|
||||
loadExistingFile(value);
|
||||
}
|
||||
});
|
||||
// Buscar URL do arquivo quando houver um storageId
|
||||
$effect(() => {
|
||||
if (value && !fileName) {
|
||||
// Tem storageId mas não é um upload recente
|
||||
void loadExistingFile(value);
|
||||
}
|
||||
|
||||
async function loadExistingFile(storageId: string) {
|
||||
try {
|
||||
const url = await client.storage.getUrl(storageId as any);
|
||||
if (url) {
|
||||
fileUrl = url;
|
||||
fileName = "Documento anexado";
|
||||
// Detectar tipo pelo URL ou assumir PDF
|
||||
if (url.includes(".pdf") || url.includes("application/pdf")) {
|
||||
fileType = "application/pdf";
|
||||
} else {
|
||||
fileType = "image/jpeg";
|
||||
previewUrl = url; // Para imagens, a URL serve como preview
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Erro ao carregar arquivo existente:", err);
|
||||
}
|
||||
}
|
||||
let cancelled = false;
|
||||
const storageId = value;
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
if (!storageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file) return;
|
||||
(async () => {
|
||||
try {
|
||||
const url = await client.storage.getUrl(storageId);
|
||||
if (!url || cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
error = null;
|
||||
fileUrl = url;
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = "Arquivo muito grande. Tamanho máximo: 10MB";
|
||||
target.value = "";
|
||||
return;
|
||||
}
|
||||
const path = url.split('?')[0] ?? '';
|
||||
const nameFromUrl = path.split('/').pop() ?? 'arquivo';
|
||||
fileName = decodeURIComponent(nameFromUrl);
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
error = "Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)";
|
||||
target.value = "";
|
||||
return;
|
||||
}
|
||||
const extension = fileName.toLowerCase().split('.').pop();
|
||||
const isPdf =
|
||||
extension === 'pdf' || url.includes('.pdf') || url.includes('application/pdf');
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
fileName = file.name;
|
||||
fileType = file.type;
|
||||
if (isPdf) {
|
||||
fileType = 'application/pdf';
|
||||
previewUrl = null;
|
||||
} else {
|
||||
fileType = 'image/jpeg';
|
||||
previewUrl = url;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('Erro ao carregar arquivo existente:', err);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
// Create preview for images
|
||||
if (file.type.startsWith("image/")) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
previewUrl = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
});
|
||||
|
||||
await onUpload(file);
|
||||
} catch (err: any) {
|
||||
error = err?.message || "Erro ao fazer upload do arquivo";
|
||||
previewUrl = null;
|
||||
} finally {
|
||||
uploading = false;
|
||||
target.value = "";
|
||||
}
|
||||
}
|
||||
async function loadExistingFile(storageId: string) {
|
||||
try {
|
||||
const url = await client.storage.getUrl(storageId);
|
||||
if (url) {
|
||||
fileUrl = url;
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm("Tem certeza que deseja remover este arquivo?")) {
|
||||
return;
|
||||
}
|
||||
// Detectar tipo pelo URL ou assumir PDF
|
||||
if (url.includes('.pdf') || url.includes('application/pdf')) {
|
||||
fileType = 'application/pdf';
|
||||
} else {
|
||||
fileType = 'image/jpeg';
|
||||
// Para imagens, a URL serve como preview
|
||||
previewUrl = url;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Erro ao carregar arquivo existente:', err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
await onRemove();
|
||||
fileName = "";
|
||||
fileType = "";
|
||||
previewUrl = null;
|
||||
fileUrl = null;
|
||||
} catch (err: any) {
|
||||
error = err?.message || "Erro ao remover arquivo";
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
|
||||
function handleView() {
|
||||
if (fileUrl) {
|
||||
window.open(fileUrl, "_blank");
|
||||
}
|
||||
}
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput?.click();
|
||||
}
|
||||
error = null;
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
error = 'Arquivo muito grande. Tamanho máximo: 10MB';
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
error = 'Tipo de arquivo não permitido. Use PDF ou imagens (JPG, PNG)';
|
||||
target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
fileName = file.name;
|
||||
fileType = file.type;
|
||||
|
||||
// Create preview for images
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const result = e.target?.result;
|
||||
if (typeof result === 'string') {
|
||||
previewUrl = result;
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
} else {
|
||||
previewUrl = null;
|
||||
}
|
||||
|
||||
await onUpload(file);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
error = err.message || 'Erro ao fazer upload do arquivo';
|
||||
} else {
|
||||
error = 'Erro ao fazer upload do arquivo';
|
||||
}
|
||||
previewUrl = null;
|
||||
} finally {
|
||||
uploading = false;
|
||||
target.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm('Tem certeza que deseja remover este arquivo?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
uploading = true;
|
||||
await onRemove();
|
||||
fileName = '';
|
||||
fileType = '';
|
||||
previewUrl = null;
|
||||
fileUrl = null;
|
||||
error = null;
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
error = err.message || 'Erro ao remover arquivo';
|
||||
} else {
|
||||
error = 'Erro ao remover arquivo';
|
||||
}
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleView() {
|
||||
if (fileUrl) {
|
||||
window.open(fileUrl, '_blank');
|
||||
}
|
||||
}
|
||||
|
||||
function openFileDialog() {
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
function setFileInput(node: HTMLInputElement) {
|
||||
fileInput = node;
|
||||
return {
|
||||
destroy() {
|
||||
if (fileInput === node) {
|
||||
fileInput = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="file-upload-input">
|
||||
<span class="label-text font-medium flex items-center gap-2">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
{#if helpUrl}
|
||||
<div
|
||||
class="tooltip tooltip-right"
|
||||
data-tip="Clique para acessar o link"
|
||||
>
|
||||
<a
|
||||
href={helpUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary-focus transition-colors"
|
||||
aria-label="Acessar link"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="label" for="file-upload-input">
|
||||
<span class="label-text flex items-center gap-2 font-medium">
|
||||
{label}
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
{#if helpUrl}
|
||||
<div class="tooltip tooltip-right" data-tip="Clique para acessar o link">
|
||||
<a
|
||||
href={helpUrl ?? '/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary hover:text-primary-focus transition-colors"
|
||||
aria-label="Acessar link"
|
||||
>
|
||||
<ExternalLink class="h-4 w-4" strokeWidth={2} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
bind:this={fileInput}
|
||||
onchange={handleFileSelect}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="hidden"
|
||||
{disabled}
|
||||
/>
|
||||
<input
|
||||
id="file-upload-input"
|
||||
type="file"
|
||||
use:setFileInput
|
||||
onchange={handleFileSelect}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
class="hidden"
|
||||
{disabled}
|
||||
/>
|
||||
|
||||
{#if value || fileName}
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 border border-base-300 rounded-lg bg-base-100"
|
||||
>
|
||||
<!-- Preview -->
|
||||
<div class="shrink-0">
|
||||
{#if previewUrl}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
class="w-12 h-12 object-cover rounded"
|
||||
/>
|
||||
{:else if fileType === "application/pdf" || fileName.endsWith(".pdf")}
|
||||
<div
|
||||
class="w-12 h-12 bg-error/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<FileText class="h-6 w-6 text-error" strokeWidth={2} />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="w-12 h-12 bg-success/10 rounded flex items-center justify-center"
|
||||
>
|
||||
<File class="h-6 w-6 text-success" strokeWidth={2} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if value || fileName}
|
||||
<div class="border-base-300 bg-base-100 flex items-center gap-2 rounded-lg border p-3">
|
||||
<!-- Preview -->
|
||||
<div class="shrink-0">
|
||||
{#if previewUrl}
|
||||
<img src={previewUrl} alt="Preview" class="h-12 w-12 rounded object-cover" />
|
||||
{:else if fileType === 'application/pdf' || fileName.endsWith('.pdf')}
|
||||
<div class="bg-error/10 flex h-12 w-12 items-center justify-center rounded">
|
||||
<FileText class="text-error h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="bg-success/10 flex h-12 w-12 items-center justify-center rounded">
|
||||
<FileIcon class="text-success h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- File info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">
|
||||
{fileName || "Arquivo anexado"}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{#if uploading}
|
||||
Carregando...
|
||||
{:else}
|
||||
Enviado com sucesso
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<!-- File info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">
|
||||
{fileName || 'Arquivo anexado'}
|
||||
</p>
|
||||
<p class="text-base-content/60 text-xs">
|
||||
{#if uploading}
|
||||
Carregando...
|
||||
{:else}
|
||||
Enviado com sucesso
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
{#if fileUrl}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleView}
|
||||
class="btn btn-sm btn-ghost text-info"
|
||||
disabled={uploading || disabled}
|
||||
title="Visualizar arquivo"
|
||||
>
|
||||
<Eye class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFileDialog}
|
||||
class="btn btn-sm btn-ghost"
|
||||
disabled={uploading || disabled}
|
||||
title="Substituir arquivo"
|
||||
>
|
||||
<RefreshCw class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleRemove}
|
||||
class="btn btn-sm btn-ghost text-error"
|
||||
disabled={uploading || disabled}
|
||||
title="Remover arquivo"
|
||||
>
|
||||
<Trash2 class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
onclick={openFileDialog}
|
||||
class="btn btn-outline btn-block justify-start gap-2"
|
||||
disabled={uploading || disabled}
|
||||
>
|
||||
{#if uploading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Carregando...
|
||||
{:else}
|
||||
<Upload class="h-5 w-5" strokeWidth={2} />
|
||||
Selecionar arquivo (PDF ou imagem, máx. 10MB)
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
<!-- 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}
|
||||
{#if error}
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-error">{error}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,189 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
interface Props {
|
||||
value?: string; // Id do funcionário selecionado
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
interface Props {
|
||||
value?: string; // Id do funcionário selecionado
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(),
|
||||
placeholder = "Selecione um funcionário",
|
||||
disabled = false,
|
||||
required = false,
|
||||
}: Props = $props();
|
||||
let {
|
||||
value = $bindable(),
|
||||
placeholder = 'Selecione um funcionário',
|
||||
disabled = false,
|
||||
required = false
|
||||
}: Props = $props();
|
||||
|
||||
let busca = $state("");
|
||||
let mostrarDropdown = $state(false);
|
||||
let busca = $state('');
|
||||
let mostrarDropdown = $state(false);
|
||||
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
const funcionarios = $derived(
|
||||
funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []
|
||||
);
|
||||
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
|
||||
|
||||
// Filtrar funcionários baseado na busca
|
||||
const funcionariosFiltrados = $derived.by(() => {
|
||||
if (!busca.trim()) return funcionarios;
|
||||
// Filtrar funcionários baseado na busca
|
||||
const funcionariosFiltrados = $derived.by(() => {
|
||||
if (!busca.trim()) return funcionarios;
|
||||
|
||||
const termo = busca.toLowerCase().trim();
|
||||
return funcionarios.filter((f) => {
|
||||
const nomeMatch = f.nome?.toLowerCase().includes(termo);
|
||||
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
|
||||
const cpfMatch = f.cpf?.replace(/\D/g, "").includes(termo.replace(/\D/g, ""));
|
||||
return nomeMatch || matriculaMatch || cpfMatch;
|
||||
});
|
||||
});
|
||||
const termo = busca.toLowerCase().trim();
|
||||
return funcionarios.filter((f) => {
|
||||
const nomeMatch = f.nome?.toLowerCase().includes(termo);
|
||||
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
|
||||
const cpfMatch = f.cpf?.replace(/\D/g, '').includes(termo.replace(/\D/g, ''));
|
||||
return nomeMatch || matriculaMatch || cpfMatch;
|
||||
});
|
||||
});
|
||||
|
||||
// Funcionário selecionado
|
||||
const funcionarioSelecionado = $derived.by(() => {
|
||||
if (!value) return null;
|
||||
return funcionarios.find((f) => f._id === value);
|
||||
});
|
||||
// Funcionário selecionado
|
||||
const funcionarioSelecionado = $derived.by(() => {
|
||||
if (!value) return null;
|
||||
return funcionarios.find((f) => f._id === value);
|
||||
});
|
||||
|
||||
function selecionarFuncionario(funcionarioId: string) {
|
||||
value = funcionarioId;
|
||||
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
||||
busca = funcionario?.nome || "";
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
function selecionarFuncionario(funcionarioId: string) {
|
||||
value = funcionarioId;
|
||||
const funcionario = funcionarios.find((f) => f._id === funcionarioId);
|
||||
busca = funcionario?.nome || '';
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
|
||||
function limpar() {
|
||||
value = undefined;
|
||||
busca = "";
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
function limpar() {
|
||||
value = undefined;
|
||||
busca = '';
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
|
||||
// Atualizar busca quando funcionário selecionado mudar externamente
|
||||
$effect(() => {
|
||||
if (value && !busca) {
|
||||
const funcionario = funcionarios.find((f) => f._id === value);
|
||||
busca = funcionario?.nome || "";
|
||||
}
|
||||
});
|
||||
// Atualizar busca quando funcionário selecionado mudar externamente
|
||||
$effect(() => {
|
||||
if (value && !busca) {
|
||||
const funcionario = funcionarios.find((f) => f._id === value);
|
||||
busca = funcionario?.nome || '';
|
||||
}
|
||||
});
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
}
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay para permitir click no dropdown
|
||||
setTimeout(() => {
|
||||
mostrarDropdown = false;
|
||||
}, 200);
|
||||
}
|
||||
function handleBlur() {
|
||||
// Delay para permitir click no dropdown
|
||||
setTimeout(() => {
|
||||
mostrarDropdown = false;
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="form-control w-full relative">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">
|
||||
Funcionário
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<div class="form-control relative w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">
|
||||
Funcionário
|
||||
{#if required}
|
||||
<span class="text-error">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={busca}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
class="input input-bordered w-full pr-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={busca}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
class="input input-bordered w-full pr-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
{#if value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={limpar}
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 btn btn-ghost btn-xs btn-circle"
|
||||
disabled={disabled}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/40"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
{#if value}
|
||||
<button
|
||||
type="button"
|
||||
onclick={limpar}
|
||||
class="btn btn-xs btn-circle absolute top-1/2 right-2 -translate-y-1/2"
|
||||
{disabled}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/40 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
|
||||
<div
|
||||
class="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}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selecionarFuncionario(funcionario._id)}
|
||||
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="text-sm text-base-content/60">
|
||||
{#if funcionario.matricula}
|
||||
Matrícula: {funcionario.matricula}
|
||||
{/if}
|
||||
{#if funcionario.descricaoCargo}
|
||||
{funcionario.matricula ? " • " : ""}
|
||||
{funcionario.descricaoCargo}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
|
||||
>
|
||||
{#each funcionariosFiltrados as funcionario}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selecionarFuncionario(funcionario._id)}
|
||||
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
|
||||
>
|
||||
<div class="font-medium">{funcionario.nome}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{#if funcionario.matricula}
|
||||
Matrícula: {funcionario.matricula}
|
||||
{/if}
|
||||
{#if funcionario.descricaoCargo}
|
||||
{funcionario.matricula ? ' • ' : ''}
|
||||
{funcionario.descricaoCargo}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
|
||||
<div
|
||||
class="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
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if mostrarDropdown && busca && funcionariosFiltrados.length === 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
|
||||
>
|
||||
Nenhum funcionário encontrado
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if funcionarioSelecionado}
|
||||
<div class="text-xs text-base-content/60 mt-1">
|
||||
Selecionado: {funcionarioSelecionado.nome}
|
||||
{#if funcionarioSelecionado.matricula}
|
||||
- {funcionarioSelecionado.matricula}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if funcionarioSelecionado}
|
||||
<div class="text-base-content/60 mt-1 text-xs">
|
||||
Selecionado: {funcionarioSelecionado.nome}
|
||||
{#if funcionarioSelecionado.matricula}
|
||||
- {funcionarioSelecionado.matricula}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
});
|
||||
|
||||
function verificarPermissoes() {
|
||||
// Dashboard e Solicitar Acesso são públicos
|
||||
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
|
||||
// Dashboard e abertura de chamados são públicos
|
||||
if (menuPath === "/" || menuPath === "/abrir-chamado") {
|
||||
verificando = false;
|
||||
temPermissao = true;
|
||||
return;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,80 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import type { Snippet } from "svelte";
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { onMount } from 'svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
maxLevel = 3,
|
||||
redirectTo = "/",
|
||||
}: {
|
||||
children: Snippet;
|
||||
requireAuth?: boolean;
|
||||
allowedRoles?: string[];
|
||||
maxLevel?: number;
|
||||
redirectTo?: string;
|
||||
} = $props();
|
||||
let {
|
||||
children,
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
maxLevel = 3,
|
||||
redirectTo = '/'
|
||||
}: {
|
||||
children: Snippet;
|
||||
requireAuth?: boolean;
|
||||
allowedRoles?: string[];
|
||||
maxLevel?: number;
|
||||
redirectTo?: string;
|
||||
} = $props();
|
||||
|
||||
let isChecking = $state(true);
|
||||
let hasAccess = $state(false);
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
let isChecking = $state(true);
|
||||
let hasAccess = $state(false);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let hasCheckedOnce = $state(false);
|
||||
let lastUserState = $state<typeof currentUser | undefined>(undefined);
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
onMount(() => {
|
||||
checkAccess();
|
||||
});
|
||||
// Usar $effect para reagir apenas às mudanças na query currentUser
|
||||
$effect(() => {
|
||||
// Não verificar novamente se já tem acesso concedido e usuário está autenticado
|
||||
if (hasAccess && currentUser?.data) {
|
||||
lastUserState = currentUser;
|
||||
return;
|
||||
}
|
||||
|
||||
// Evitar loop: só verificar se currentUser realmente mudou
|
||||
// Comparar dados, não o objeto proxy
|
||||
const currentData = currentUser?.data;
|
||||
const lastData = lastUserState?.data;
|
||||
if (currentData !== lastData || (currentUser === undefined) !== (lastUserState === undefined)) {
|
||||
lastUserState = currentUser;
|
||||
checkAccess();
|
||||
}
|
||||
});
|
||||
|
||||
function checkAccess() {
|
||||
isChecking = true;
|
||||
function checkAccess() {
|
||||
// Limpar timeout anterior se existir
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
// Aguardar um pouco para o authStore carregar do localStorage
|
||||
setTimeout(() => {
|
||||
// Verificar autenticação
|
||||
if (requireAuth && !currentUser?.data) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
// Se a query ainda está carregando (undefined), aguardar
|
||||
if (currentUser === undefined) {
|
||||
isChecking = true;
|
||||
hasAccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0 && currentUser?.data) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
// Marcar que já verificou pelo menos uma vez
|
||||
hasCheckedOnce = true;
|
||||
|
||||
// Verificar nível
|
||||
if (
|
||||
currentUser?.data &&
|
||||
currentUser.data.role?.nivel &&
|
||||
currentUser.data.role.nivel > maxLevel
|
||||
) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
// Se a query retornou dados, verificar autenticação
|
||||
if (currentUser?.data) {
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0) {
|
||||
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
|
||||
if (!hasRole) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
hasAccess = true;
|
||||
isChecking = false;
|
||||
}, 100);
|
||||
}
|
||||
// Verificar nível
|
||||
if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se chegou aqui, permitir acesso
|
||||
hasAccess = true;
|
||||
isChecking = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se não tem dados e requer autenticação
|
||||
if (requireAuth && !currentUser?.data) {
|
||||
// Se a query já retornou (não está mais undefined), finalizar estado
|
||||
if (currentUser !== undefined) {
|
||||
const currentPath = window.location.pathname;
|
||||
// Evitar redirecionamento em loop - verificar se já está na URL de erro
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (!urlParams.has('error')) {
|
||||
// Só redirecionar se não estiver em loop
|
||||
if (!hasCheckedOnce || currentUser === null) {
|
||||
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Se já tem erro na URL, permitir renderização para mostrar o alerta
|
||||
isChecking = false;
|
||||
hasAccess = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se ainda está carregando (undefined), aguardar
|
||||
isChecking = true;
|
||||
hasAccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se não requer autenticação, permitir acesso
|
||||
if (!requireAuth) {
|
||||
hasAccess = true;
|
||||
isChecking = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isChecking}
|
||||
<div class="flex justify-center items-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/70 mt-4">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if hasAccess}
|
||||
{@render children()}
|
||||
{@render children()}
|
||||
{/if}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,304 +1,353 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
|
||||
interface Periodo {
|
||||
id: string;
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
funcionarioId: string;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let anoReferencia = $state(new Date().getFullYear());
|
||||
let observacao = $state("");
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let processando = $state(false);
|
||||
let erro = $state("");
|
||||
|
||||
// Adicionar primeiro período ao carregar
|
||||
$effect(() => {
|
||||
if (periodos.length === 0) {
|
||||
adicionarPeriodo();
|
||||
}
|
||||
});
|
||||
|
||||
function adicionarPeriodo() {
|
||||
if (periodos.length >= 3) {
|
||||
erro = "Máximo de 3 períodos permitidos";
|
||||
return;
|
||||
}
|
||||
|
||||
periodos.push({
|
||||
id: crypto.randomUUID(),
|
||||
dataInicio: "",
|
||||
dataFim: "",
|
||||
diasCorridos: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function removerPeriodo(id: string) {
|
||||
periodos = periodos.filter(p => p.id !== id);
|
||||
}
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = "Data final não pode ser anterior à data inicial";
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = "";
|
||||
}
|
||||
|
||||
function validarPeriodos(): boolean {
|
||||
if (periodos.length === 0) {
|
||||
erro = "Adicione pelo menos 1 período";
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const periodo of periodos) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
erro = "Preencha as datas de todos os períodos";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (periodo.diasCorridos <= 0) {
|
||||
erro = "Todos os períodos devem ter pelo menos 1 dia";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar sobreposição de períodos
|
||||
for (let i = 0; i < periodos.length; i++) {
|
||||
for (let j = i + 1; j < periodos.length; j++) {
|
||||
const p1Inicio = new Date(periodos[i].dataInicio);
|
||||
const p1Fim = new Date(periodos[i].dataFim);
|
||||
const p2Inicio = new Date(periodos[j].dataInicio);
|
||||
const p2Fim = new Date(periodos[j].dataFim);
|
||||
|
||||
if (
|
||||
(p2Inicio >= p1Inicio && p2Inicio <= p1Fim) ||
|
||||
(p2Fim >= p1Inicio && p2Fim <= p1Fim) ||
|
||||
(p1Inicio >= p2Inicio && p1Inicio <= p2Fim)
|
||||
) {
|
||||
erro = "Os períodos não podem se sobrepor";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function enviarSolicitacao() {
|
||||
if (!validarPeriodos()) return;
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = "";
|
||||
|
||||
await client.mutation(api.ferias.criarSolicitacao, {
|
||||
funcionarioId: funcionarioId as any,
|
||||
anoReferencia,
|
||||
periodos: periodos.map(p => ({
|
||||
dataInicio: p.dataInicio,
|
||||
dataFim: p.dataFim,
|
||||
diasCorridos: p.diasCorridos,
|
||||
})),
|
||||
observacao: observacao || undefined,
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e: any) {
|
||||
erro = e.message || "Erro ao enviar solicitação";
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
periodos.forEach(p => calcularDias(p));
|
||||
});
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
interface Periodo {
|
||||
id: string;
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
diasCorridos: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
funcionarioId: string;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let anoReferencia = $state(new Date().getFullYear());
|
||||
let observacao = $state('');
|
||||
let periodos = $state<Periodo[]>([]);
|
||||
let processando = $state(false);
|
||||
let erro = $state('');
|
||||
|
||||
// Adicionar primeiro período ao carregar
|
||||
$effect(() => {
|
||||
if (periodos.length === 0) {
|
||||
adicionarPeriodo();
|
||||
}
|
||||
});
|
||||
|
||||
function adicionarPeriodo() {
|
||||
if (periodos.length >= 3) {
|
||||
erro = 'Máximo de 3 períodos permitidos';
|
||||
return;
|
||||
}
|
||||
|
||||
periodos.push({
|
||||
id: crypto.randomUUID(),
|
||||
dataInicio: '',
|
||||
dataFim: '',
|
||||
diasCorridos: 0
|
||||
});
|
||||
}
|
||||
|
||||
function removerPeriodo(id: string) {
|
||||
periodos = periodos.filter((p) => p.id !== id);
|
||||
}
|
||||
|
||||
function calcularDias(periodo: Periodo) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const inicio = new Date(periodo.dataInicio);
|
||||
const fim = new Date(periodo.dataFim);
|
||||
|
||||
if (fim < inicio) {
|
||||
erro = 'Data final não pode ser anterior à data inicial';
|
||||
periodo.diasCorridos = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
const dias = Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
periodo.diasCorridos = dias;
|
||||
erro = '';
|
||||
}
|
||||
|
||||
function validarPeriodos(): boolean {
|
||||
if (periodos.length === 0) {
|
||||
erro = 'Adicione pelo menos 1 período';
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const periodo of periodos) {
|
||||
if (!periodo.dataInicio || !periodo.dataFim) {
|
||||
erro = 'Preencha as datas de todos os períodos';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (periodo.diasCorridos <= 0) {
|
||||
erro = 'Todos os períodos devem ter pelo menos 1 dia';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar sobreposição de períodos
|
||||
for (let i = 0; i < periodos.length; i++) {
|
||||
for (let j = i + 1; j < periodos.length; j++) {
|
||||
const p1Inicio = new Date(periodos[i].dataInicio);
|
||||
const p1Fim = new Date(periodos[i].dataFim);
|
||||
const p2Inicio = new Date(periodos[j].dataInicio);
|
||||
const p2Fim = new Date(periodos[j].dataFim);
|
||||
|
||||
if (
|
||||
(p2Inicio >= p1Inicio && p2Inicio <= p1Fim) ||
|
||||
(p2Fim >= p1Inicio && p2Fim <= p1Fim) ||
|
||||
(p1Inicio >= p2Inicio && p1Inicio <= p2Fim)
|
||||
) {
|
||||
erro = 'Os períodos não podem se sobrepor';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function enviarSolicitacao() {
|
||||
if (!validarPeriodos()) return;
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.criarSolicitacao, {
|
||||
funcionarioId: funcionarioId as any,
|
||||
anoReferencia,
|
||||
periodos: periodos.map((p) => ({
|
||||
dataInicio: p.dataInicio,
|
||||
dataFim: p.dataFim,
|
||||
diasCorridos: p.diasCorridos
|
||||
})),
|
||||
observacao: observacao || undefined
|
||||
});
|
||||
|
||||
if (onSucesso) onSucesso();
|
||||
} catch (e: any) {
|
||||
erro = e.message || 'Erro ao enviar solicitação';
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
periodos.forEach((p) => calcularDias(p));
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-2xl mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Solicitar Férias
|
||||
</h2>
|
||||
|
||||
<!-- Ano de Referência -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ano-referencia">
|
||||
<span class="label-text font-semibold">Ano de Referência</span>
|
||||
</label>
|
||||
<input
|
||||
id="ano-referencia"
|
||||
type="number"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={anoReferencia}
|
||||
min={new Date().getFullYear()}
|
||||
max={new Date().getFullYear() + 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Períodos -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-lg">Períodos ({periodos.length}/3)</h3>
|
||||
{#if periodos.length < 3}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-primary gap-2"
|
||||
onclick={adicionarPeriodo}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Adicionar Período
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200 border border-base-300">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-medium">Período {index + 1}</h4>
|
||||
{#if periodos.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error btn-square"
|
||||
aria-label="Remover período"
|
||||
onclick={() => removerPeriodo(periodo.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`inicio-${periodo.id}`}>
|
||||
<span class="label-text">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`inicio-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`fim-${periodo.id}`}>
|
||||
<span class="label-text">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`fim-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`dias-${periodo.id}`}>
|
||||
<span class="label-text">Dias Corridos</span>
|
||||
</label>
|
||||
<div id={`dias-${periodo.id}`} class="flex items-center h-9 px-3 bg-base-300 rounded-lg" role="textbox" aria-readonly="true">
|
||||
<span class="font-bold text-lg">{periodo.diasCorridos}</span>
|
||||
<span class="ml-1 text-sm">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div class="form-control mt-6">
|
||||
<label class="label" for="observacao">
|
||||
<span class="label-text font-semibold">Observações (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="observacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Adicione observações sobre sua solicitação..."
|
||||
bind:value={observacao}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions justify-end mt-6">
|
||||
{#if onCancelar}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
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 class="card-body">
|
||||
<h2 class="card-title mb-4 text-2xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Solicitar Férias
|
||||
</h2>
|
||||
|
||||
<!-- Ano de Referência -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="ano-referencia">
|
||||
<span class="label-text font-semibold">Ano de Referência</span>
|
||||
</label>
|
||||
<input
|
||||
id="ano-referencia"
|
||||
type="number"
|
||||
class="input input-bordered w-full max-w-xs"
|
||||
bind:value={anoReferencia}
|
||||
min={new Date().getFullYear()}
|
||||
max={new Date().getFullYear() + 2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Períodos -->
|
||||
<div class="mt-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold">Períodos ({periodos.length}/3)</h3>
|
||||
{#if periodos.length < 3}
|
||||
<button type="button" class="btn btn-sm btn-primary gap-2" onclick={adicionarPeriodo}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Adicionar Período
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#each periodos as periodo, index}
|
||||
<div class="card bg-base-200 border-base-300 border">
|
||||
<div class="card-body p-4">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="font-medium">Período {index + 1}</h4>
|
||||
{#if periodos.length > 1}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs btn-error"
|
||||
aria-label="Remover período"
|
||||
onclick={() => removerPeriodo(periodo.id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for={`inicio-${periodo.id}`}>
|
||||
<span class="label-text">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
id={`inicio-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataInicio}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`fim-${periodo.id}`}>
|
||||
<span class="label-text">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
id={`fim-${periodo.id}`}
|
||||
type="date"
|
||||
class="input input-bordered input-sm"
|
||||
bind:value={periodo.dataFim}
|
||||
onchange={() => calcularDias(periodo)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for={`dias-${periodo.id}`}>
|
||||
<span class="label-text">Dias Corridos</span>
|
||||
</label>
|
||||
<div
|
||||
id={`dias-${periodo.id}`}
|
||||
class="bg-base-300 flex h-9 items-center rounded-lg px-3"
|
||||
role="textbox"
|
||||
aria-readonly="true"
|
||||
>
|
||||
<span class="text-lg font-bold">{periodo.diasCorridos}</span>
|
||||
<span class="ml-1 text-sm">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Observações -->
|
||||
<div class="form-control mt-6">
|
||||
<label class="label" for="observacao">
|
||||
<span class="label-text font-semibold">Observações (opcional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="observacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Adicione observações sobre sua solicitação..."
|
||||
bind:value={observacao}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="card-actions mt-6 justify-end">
|
||||
{#if onCancelar}
|
||||
<button type="button" class="btn" onclick={onCancelar} disabled={processando}>
|
||||
Cancelar
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import multiMonthPlugin from "@fullcalendar/multimonth";
|
||||
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
|
||||
import { SvelteDate } from "svelte/reactivity";
|
||||
|
||||
interface Props {
|
||||
dataInicio?: string;
|
||||
@@ -34,18 +35,6 @@
|
||||
let calendarEl: HTMLDivElement;
|
||||
let calendar: Calendar | null = null;
|
||||
let selecionando = $state(false); // Flag para evitar atualizações durante seleção
|
||||
let eventos: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
start: string;
|
||||
end: string;
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
textColor: string;
|
||||
extendedProps: {
|
||||
status: string;
|
||||
};
|
||||
}> = $state([]);
|
||||
|
||||
// Cores por status
|
||||
const coresStatus: Record<
|
||||
@@ -58,7 +47,7 @@
|
||||
};
|
||||
|
||||
// Converter ausências existentes em eventos
|
||||
function atualizarEventos() {
|
||||
let eventos = $derived.by(() => {
|
||||
const novosEventos: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -103,8 +92,8 @@
|
||||
});
|
||||
}
|
||||
|
||||
eventos = novosEventos;
|
||||
}
|
||||
return novosEventos;
|
||||
});
|
||||
|
||||
function getStatusTexto(status: string): string {
|
||||
const textos: Record<string, string> = {
|
||||
@@ -117,15 +106,15 @@
|
||||
|
||||
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
|
||||
function calcularDataFim(dataFim: string): string {
|
||||
const data = new Date(dataFim);
|
||||
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 Date(inicio);
|
||||
const dFim = new Date(fim);
|
||||
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;
|
||||
@@ -133,20 +122,23 @@
|
||||
|
||||
// Helper: Verificar se há sobreposição de datas
|
||||
function verificarSobreposicao(
|
||||
inicio1: Date,
|
||||
fim1: Date,
|
||||
inicio1: SvelteDate,
|
||||
fim1: SvelteDate,
|
||||
inicio2: string,
|
||||
fim2: string,
|
||||
): boolean {
|
||||
const d2Inicio = new Date(inicio2);
|
||||
const d2Fim = new Date(fim2);
|
||||
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: Date, fim: Date): boolean {
|
||||
function verificarSobreposicaoComAusencias(
|
||||
inicio: SvelteDate,
|
||||
fim: SvelteDate,
|
||||
): boolean {
|
||||
if (!ausenciasExistentes || ausenciasExistentes.length === 0) return false;
|
||||
|
||||
// Verificar apenas ausências aprovadas ou aguardando aprovação
|
||||
@@ -159,12 +151,17 @@
|
||||
);
|
||||
}
|
||||
|
||||
interface FullCalendarDayCellInfo {
|
||||
el: HTMLElement;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
// Helper: Atualizar classe de seleção em uma célula
|
||||
function atualizarClasseSelecionado(info: any) {
|
||||
function atualizarClasseSelecionado(info: FullCalendarDayCellInfo) {
|
||||
if (dataInicio && dataFim && !readonly) {
|
||||
const cellDate = new Date(info.date);
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
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);
|
||||
@@ -181,13 +178,13 @@
|
||||
}
|
||||
|
||||
// Helper: Atualizar classe de bloqueio para dias com ausências existentes
|
||||
function atualizarClasseBloqueado(info: any) {
|
||||
function atualizarClasseBloqueado(info: FullCalendarDayCellInfo) {
|
||||
if (readonly || !ausenciasExistentes || ausenciasExistentes.length === 0) {
|
||||
info.el.classList.remove("fc-day-blocked");
|
||||
return;
|
||||
}
|
||||
|
||||
const cellDate = new Date(info.date);
|
||||
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
|
||||
@@ -196,8 +193,8 @@
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
)
|
||||
.some((ausencia) => {
|
||||
const inicio = new Date(ausencia.dataInicio);
|
||||
const fim = new Date(ausencia.dataFim);
|
||||
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;
|
||||
@@ -218,8 +215,8 @@
|
||||
const view = calendar.view;
|
||||
if (!view) return;
|
||||
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const inicio = new SvelteDate(dataInicio);
|
||||
const fim = new SvelteDate(dataFim);
|
||||
inicio.setHours(0, 0, 0, 0);
|
||||
fim.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -235,14 +232,14 @@
|
||||
if (ariaLabel) {
|
||||
// Formato: "dia mês ano" ou similar
|
||||
try {
|
||||
const cellDate = new Date(ariaLabel);
|
||||
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 (e) {
|
||||
} catch {
|
||||
// Ignorar erros de parsing
|
||||
}
|
||||
}
|
||||
@@ -266,6 +263,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const calendarInstance = calendar;
|
||||
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
|
||||
const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
@@ -280,17 +278,17 @@
|
||||
cell.classList.remove("fc-day-blocked");
|
||||
|
||||
// Tentar obter a data de diferentes formas
|
||||
let cellDate: Date | null = null;
|
||||
let cellDate: SvelteDate | null = null;
|
||||
|
||||
// Método 1: aria-label
|
||||
const ariaLabel = cell.getAttribute("aria-label");
|
||||
if (ariaLabel) {
|
||||
try {
|
||||
const parsed = new Date(ariaLabel);
|
||||
const parsed = new SvelteDate(ariaLabel);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
cellDate = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignorar
|
||||
}
|
||||
}
|
||||
@@ -300,27 +298,27 @@
|
||||
const dataDate = cell.getAttribute("data-date");
|
||||
if (dataDate) {
|
||||
try {
|
||||
const parsed = new Date(dataDate);
|
||||
const parsed = new SvelteDate(dataDate);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
cellDate = parsed;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignorar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Método 3: Tentar obter do número do dia e contexto do calendário
|
||||
if (!cellDate && calendar.view) {
|
||||
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 Date(calendar.view.activeStart);
|
||||
const viewStart = new SvelteDate(calendarInstance.view.activeStart);
|
||||
const cellIndex = Array.from(cells).indexOf(cell);
|
||||
if (cellIndex >= 0) {
|
||||
const possibleDate = new Date(viewStart);
|
||||
const possibleDate = new SvelteDate(viewStart);
|
||||
possibleDate.setDate(viewStart.getDate() + cellIndex);
|
||||
// Verificar se o número do dia corresponde
|
||||
if (possibleDate.getDate() === dayNumber) {
|
||||
@@ -335,11 +333,11 @@
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const estaBloqueado = ausenciasBloqueantes.some((ausencia) => {
|
||||
const inicio = new Date(ausencia.dataInicio);
|
||||
const fim = new Date(ausencia.dataFim);
|
||||
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;
|
||||
return cellDate! >= inicio && cellDate! <= fim;
|
||||
});
|
||||
|
||||
if (estaBloqueado) {
|
||||
@@ -354,9 +352,7 @@
|
||||
if (!calendar || selecionando) return; // Não atualizar durante seleção
|
||||
|
||||
// Garantir que temos as ausências antes de atualizar
|
||||
const ausencias = ausenciasExistentes;
|
||||
|
||||
atualizarEventos();
|
||||
void ausenciasExistentes;
|
||||
|
||||
// Usar requestAnimationFrame para evitar múltiplas atualizações durante seleção
|
||||
requestAnimationFrame(() => {
|
||||
@@ -398,8 +394,6 @@
|
||||
onMount(() => {
|
||||
if (!calendarEl) return;
|
||||
|
||||
atualizarEventos();
|
||||
|
||||
calendar = new Calendar(calendarEl, {
|
||||
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
|
||||
initialView:
|
||||
@@ -416,9 +410,9 @@
|
||||
selectMirror: true,
|
||||
unselectAuto: false,
|
||||
selectOverlap: false,
|
||||
selectConstraint: null, // Permite seleção entre meses diferentes
|
||||
selectConstraint: undefined, // Permite seleção entre meses diferentes
|
||||
validRange: {
|
||||
start: new Date().toISOString().split("T")[0], // Não permite selecionar datas passadas
|
||||
start: new SvelteDate().toISOString().split("T")[0], // Não permite selecionar datas passadas
|
||||
},
|
||||
events: eventos,
|
||||
|
||||
@@ -437,12 +431,12 @@
|
||||
|
||||
// Usar setTimeout para evitar conflito com atualizações de estado
|
||||
setTimeout(() => {
|
||||
const inicio = new Date(info.startStr);
|
||||
const fim = new Date(info.endStr);
|
||||
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 Date();
|
||||
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");
|
||||
@@ -511,11 +505,11 @@
|
||||
|
||||
// Desabilitar datas passadas e períodos que sobrepõem com ausências existentes
|
||||
selectAllow: (selectInfo) => {
|
||||
const hoje = new Date();
|
||||
const hoje = new SvelteDate();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
|
||||
// Bloquear datas passadas
|
||||
if (new Date(selectInfo.start) < hoje) {
|
||||
if (new SvelteDate(selectInfo.start) < hoje) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -525,8 +519,8 @@
|
||||
ausenciasExistentes &&
|
||||
ausenciasExistentes.length > 0
|
||||
) {
|
||||
const inicioSelecao = new Date(selectInfo.start);
|
||||
const fimSelecao = new Date(selectInfo.end);
|
||||
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);
|
||||
@@ -578,7 +572,7 @@
|
||||
ausenciasExistentes &&
|
||||
ausenciasExistentes.length > 0
|
||||
) {
|
||||
const cellDate = new Date(arg.date);
|
||||
const cellDate = new SvelteDate(arg.date);
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
@@ -587,8 +581,8 @@
|
||||
);
|
||||
|
||||
const estaBloqueado = ausenciasBloqueantes.some((ausencia) => {
|
||||
const inicio = new Date(ausencia.dataInicio);
|
||||
const fim = new Date(ausencia.dataFim);
|
||||
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;
|
||||
@@ -646,9 +640,6 @@
|
||||
|
||||
<!-- Alerta sobre dias bloqueados -->
|
||||
{#if ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
|
||||
{@const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
)}
|
||||
<div class="alert alert-warning shadow-lg border-2 border-warning/50">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -744,10 +735,10 @@
|
||||
<!-- Informação do período selecionado -->
|
||||
{#if dataInicio && dataFim && !readonly}
|
||||
<div
|
||||
class="mt-6 card bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 shadow-lg border-2 border-orange-500/30"
|
||||
class="mt-6 card shadow-lg border border-orange-400"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-orange-700 dark:text-orange-400">
|
||||
<h3 class="card-title">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
|
||||
@@ -1,503 +1,487 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient, useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import CalendarioAusencias from "./CalendarioAusencias.svelte";
|
||||
import ErrorModal from "../ErrorModal.svelte";
|
||||
import { toast } from "svelte-sonner";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import CalendarioAusencias from './CalendarioAusencias.svelte';
|
||||
import ErrorModal from '../ErrorModal.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<"funcionarios">;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
onSucesso?: () => void;
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
// Cliente Convex
|
||||
const client = useConvexClient();
|
||||
// Cliente Convex
|
||||
const client = useConvexClient();
|
||||
|
||||
// Estado do wizard
|
||||
let passoAtual = $state(1);
|
||||
const totalPassos = 2;
|
||||
// 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);
|
||||
// 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("");
|
||||
// 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,
|
||||
},
|
||||
);
|
||||
// Buscar ausências existentes para exibir no calendário
|
||||
const ausenciasExistentesQuery = useQuery(api.ausencias.listarMinhasSolicitacoes, {
|
||||
funcionarioId
|
||||
});
|
||||
|
||||
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
||||
const ausenciasExistentes = $derived(
|
||||
(ausenciasExistentesQuery?.data || [])
|
||||
.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
)
|
||||
.map((a) => ({
|
||||
dataInicio: a.dataInicio,
|
||||
dataFim: a.dataFim,
|
||||
status: a.status as "aguardando_aprovacao" | "aprovado",
|
||||
})),
|
||||
);
|
||||
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
||||
const ausenciasExistentes = $derived(
|
||||
(ausenciasExistentesQuery?.data || [])
|
||||
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
|
||||
.map((a) => ({
|
||||
dataInicio: a.dataInicio,
|
||||
dataFim: a.dataFim,
|
||||
status: a.status as 'aguardando_aprovacao' | 'aprovado'
|
||||
}))
|
||||
);
|
||||
|
||||
// Calcular dias selecionados
|
||||
function calcularDias(inicio: string, fim: string): number {
|
||||
if (!inicio || !fim) return 0;
|
||||
const dInicio = new Date(inicio);
|
||||
const dFim = new Date(fim);
|
||||
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
// Calcular dias selecionados
|
||||
function calcularDias(inicio: string, fim: string): number {
|
||||
if (!inicio || !fim) return 0;
|
||||
const dInicio = new Date(inicio);
|
||||
const dFim = new Date(fim);
|
||||
const diffTime = Math.abs(dFim.getTime() - dInicio.getTime());
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const totalDias = $derived(calcularDias(dataInicio, dataFim));
|
||||
const totalDias = $derived(calcularDias(dataInicio, dataFim));
|
||||
|
||||
// Funções de navegação
|
||||
function proximoPasso() {
|
||||
if (passoAtual === 1) {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error("Selecione o período de ausência no calendário");
|
||||
return;
|
||||
}
|
||||
// Funções de navegação
|
||||
function proximoPasso() {
|
||||
if (passoAtual === 1) {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error('Selecione o período de ausência no calendário');
|
||||
return;
|
||||
}
|
||||
|
||||
const hoje = new Date();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const inicio = new Date(dataInicio);
|
||||
const hoje = new SvelteDate();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const inicio = new Date(dataInicio);
|
||||
|
||||
if (inicio < hoje) {
|
||||
toast.error("A data de início não pode ser no passado");
|
||||
return;
|
||||
}
|
||||
if (inicio < hoje) {
|
||||
toast.error('A data de início não pode ser no passado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(dataFim) < new Date(dataInicio)) {
|
||||
toast.error("A data de fim deve ser maior ou igual à data de início");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (new Date(dataFim) < new Date(dataInicio)) {
|
||||
toast.error('A data de fim deve ser maior ou igual à data de início');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (passoAtual < totalPassos) {
|
||||
passoAtual++;
|
||||
}
|
||||
}
|
||||
if (passoAtual < totalPassos) {
|
||||
passoAtual++;
|
||||
}
|
||||
}
|
||||
|
||||
function passoAnterior() {
|
||||
if (passoAtual > 1) {
|
||||
passoAtual--;
|
||||
}
|
||||
}
|
||||
function passoAnterior() {
|
||||
if (passoAtual > 1) {
|
||||
passoAtual--;
|
||||
}
|
||||
}
|
||||
|
||||
async function enviarSolicitacao() {
|
||||
if (!dataInicio || !dataFim) {
|
||||
toast.error("Selecione o período de ausência");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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 = "";
|
||||
try {
|
||||
processando = true;
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = '';
|
||||
|
||||
await client.mutation(api.ausencias.criarSolicitacao, {
|
||||
funcionarioId,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
motivo: motivo.trim(),
|
||||
});
|
||||
await client.mutation(api.ausencias.criarSolicitacao, {
|
||||
funcionarioId,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
motivo: motivo.trim()
|
||||
});
|
||||
|
||||
toast.success("Solicitação de ausência criada com sucesso!");
|
||||
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);
|
||||
if (onSucesso) {
|
||||
onSucesso();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar solicitação:', error);
|
||||
const mensagemErro = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Verificar se é erro de sobreposição de período
|
||||
if (
|
||||
mensagemErro.includes("Já existe uma solicitação") ||
|
||||
mensagemErro.includes("já existe") ||
|
||||
mensagemErro.includes("solicitação aprovada ou pendente")
|
||||
) {
|
||||
mensagemErroModal = "Não é possível criar esta solicitação.";
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString("pt-BR")} até ${new Date(dataFim).toLocaleDateString("pt-BR")}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros continuam usando toast
|
||||
toast.error(mensagemErro);
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
// Verificar se é erro de sobreposição de período
|
||||
if (
|
||||
mensagemErro.includes('Já existe uma solicitação') ||
|
||||
mensagemErro.includes('já existe') ||
|
||||
mensagemErro.includes('solicitação aprovada ou pendente')
|
||||
) {
|
||||
mensagemErroModal = 'Não é possível criar esta solicitação.';
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${new Date(dataInicio).toLocaleDateString('pt-BR')} até ${new Date(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros continuam usando toast
|
||||
toast.error(mensagemErro);
|
||||
}
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = "";
|
||||
detalhesErroModal = "";
|
||||
}
|
||||
function fecharModalErro() {
|
||||
mostrarModalErro = false;
|
||||
mensagemErroModal = '';
|
||||
detalhesErroModal = '';
|
||||
}
|
||||
|
||||
function handlePeriodoSelecionado(periodo: {
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
}) {
|
||||
dataInicio = periodo.dataInicio;
|
||||
dataFim = periodo.dataFim;
|
||||
}
|
||||
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>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<p class="text-base-content/70">Solicite uma ausência para assuntos particulares</p>
|
||||
</div>
|
||||
|
||||
<!-- Indicador de progresso -->
|
||||
<div class="steps mb-8">
|
||||
<div class="step {passoAtual >= 1 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{passoAtual}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Selecionar Período</div>
|
||||
<div class="step-description">Escolha as datas no calendário</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step {passoAtual >= 2 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 2}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
2
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Informar Motivo</div>
|
||||
<div class="step-description">Descreva o motivo da ausência</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Indicador de progresso -->
|
||||
<div class="steps mb-8">
|
||||
<div class="step {passoAtual >= 1 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
{passoAtual}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Selecionar Período</div>
|
||||
<div class="step-description">Escolha as datas no calendário</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step {passoAtual >= 2 ? 'step-primary' : ''}">
|
||||
<div class="step-item">
|
||||
<div class="step-marker">
|
||||
{#if passoAtual > 2}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
2
|
||||
{/if}
|
||||
</div>
|
||||
<div class="step-content">
|
||||
<div class="step-title">Informar Motivo</div>
|
||||
<div class="step-description">Descreva o motivo da ausência</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo dos passos -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
{#if passoAtual === 1}
|
||||
<!-- Passo 1: Selecionar Período -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold mb-2">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>
|
||||
<!-- 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="ml-4 text-base-content/70"
|
||||
>Carregando ausências existentes...</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<CalendarioAusencias
|
||||
{dataInicio}
|
||||
{dataFim}
|
||||
{ausenciasExistentes}
|
||||
onPeriodoSelecionado={handlePeriodoSelecionado}
|
||||
/>
|
||||
{/if}
|
||||
{#if ausenciasExistentesQuery === undefined}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
<span class="text-base-content/70 ml-4">Carregando ausências existentes...</span>
|
||||
</div>
|
||||
{:else}
|
||||
<CalendarioAusencias
|
||||
{dataInicio}
|
||||
{dataFim}
|
||||
{ausenciasExistentes}
|
||||
onPeriodoSelecionado={handlePeriodoSelecionado}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if dataInicio && dataFim}
|
||||
<div class="alert alert-success shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">Período selecionado!</h4>
|
||||
<p>
|
||||
De {new Date(dataInicio).toLocaleDateString("pt-BR")} até{" "}
|
||||
{new Date(dataFim).toLocaleDateString("pt-BR")} ({totalDias} dias)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if passoAtual === 2}
|
||||
<!-- Passo 2: Informar Motivo -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="text-2xl font-bold mb-2">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>
|
||||
{#if dataInicio && dataFim}
|
||||
<div class="alert alert-success shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">Período selecionado!</h4>
|
||||
<p>
|
||||
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if passoAtual === 2}
|
||||
<!-- Passo 2: Informar Motivo -->
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h3 class="mb-2 text-2xl font-bold">Informe o Motivo</h3>
|
||||
<p class="text-base-content/70">
|
||||
Descreva o motivo da sua solicitação de ausência (mínimo 10 caracteres)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Resumo do período -->
|
||||
{#if dataInicio && dataFim}
|
||||
<div
|
||||
class="card bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950 border-2 border-orange-500/30"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-orange-700 dark:text-orange-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Resumo do Período
|
||||
</h4>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Data Início</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Data Fim</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataFim).toLocaleDateString("pt-BR")}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Total de Dias</p>
|
||||
<p
|
||||
class="font-bold text-xl text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
{totalDias} dias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Resumo do período -->
|
||||
{#if dataInicio && dataFim}
|
||||
<div
|
||||
class="card border-2 border-base-content/20"
|
||||
>
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-orange-700 dark:text-orange-400">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Resumo do Período
|
||||
</h4>
|
||||
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Total de Dias</p>
|
||||
<p class="text-xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{totalDias} dias
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Campo de motivo -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo">
|
||||
<span class="label-text font-bold">Motivo da Ausência</span>
|
||||
<span class="label-text-alt">
|
||||
{motivo.trim().length}/10 caracteres mínimos
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo"
|
||||
class="textarea textarea-bordered h-32 text-lg"
|
||||
placeholder="Descreva o motivo da sua solicitação de ausência..."
|
||||
bind:value={motivo}
|
||||
maxlength={500}
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/70">
|
||||
Mínimo 10 caracteres. Seja claro e objetivo.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Campo de motivo -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo">
|
||||
<span class="label-text font-bold">Motivo da Ausência</span>
|
||||
<span class="label-text-alt">
|
||||
{motivo.trim().length}/10 caracteres mínimos
|
||||
</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo"
|
||||
class="textarea textarea-bordered h-32 text-lg"
|
||||
placeholder="Descreva o motivo da sua solicitação de ausência..."
|
||||
bind:value={motivo}
|
||||
maxlength={500}
|
||||
></textarea>
|
||||
<label class="label" for="motivo">
|
||||
<span class="label-text-alt text-base-content/70">
|
||||
Mínimo 10 caracteres. Seja claro e objetivo.
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
|
||||
<div class="alert alert-warning shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botões de navegação -->
|
||||
<div class="card-actions justify-between mt-6">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={passoAnterior}
|
||||
disabled={passoAtual === 1 || processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</button>
|
||||
<!-- Botões de navegação -->
|
||||
<div class="card-actions mt-6 justify-between">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
onclick={passoAnterior}
|
||||
disabled={passoAtual === 1 || processando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
{#if passoAtual < totalPassos}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={proximoPasso}
|
||||
disabled={processando}
|
||||
>
|
||||
Próximo
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 ml-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando || motivo.trim().length < 10}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if passoAtual < totalPassos}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={proximoPasso}
|
||||
disabled={processando}
|
||||
>
|
||||
Próximo
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="ml-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success"
|
||||
onclick={enviarSolicitacao}
|
||||
disabled={processando || motivo.trim().length < 10}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Botão cancelar -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
disabled={processando}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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}
|
||||
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;
|
||||
}
|
||||
.wizard-ausencia {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
133
apps/web/src/lib/components/call/CallControls.svelte
Normal file
133
apps/web/src/lib/components/call/CallControls.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<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;
|
||||
}
|
||||
|
||||
let {
|
||||
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')}`;
|
||||
}
|
||||
|
||||
const 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>
|
||||
|
||||
|
||||
327
apps/web/src/lib/components/call/CallSettings.svelte
Normal file
327
apps/web/src/lib/components/call/CallSettings.svelte
Normal file
@@ -0,0 +1,327 @@
|
||||
<script lang="ts">
|
||||
import { X, Check, Volume2, VolumeX } from 'lucide-svelte';
|
||||
import { obterDispositivosDisponiveis, solicitarPermissaoMidia } from '$lib/utils/jitsi';
|
||||
import type { DispositivoMedia } from '$lib/utils/jitsi';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
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}
|
||||
|
||||
1494
apps/web/src/lib/components/call/CallWindow.svelte
Normal file
1494
apps/web/src/lib/components/call/CallWindow.svelte
Normal file
File diff suppressed because it is too large
Load Diff
113
apps/web/src/lib/components/call/HostControls.svelte
Normal file
113
apps/web/src/lib/components/call/HostControls.svelte
Normal file
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import { Mic, MicOff, Video, VideoOff, User, Shield } from 'lucide-svelte';
|
||||
import UserAvatar from '../chat/UserAvatar.svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
let { 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>
|
||||
|
||||
|
||||
23
apps/web/src/lib/components/call/RecordingIndicator.svelte
Normal file
23
apps/web/src/lib/components/call/RecordingIndicator.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
gravando: boolean;
|
||||
iniciadoPor?: string;
|
||||
}
|
||||
|
||||
let { 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="h-3 w-3 rounded-full bg-error-content"></div>
|
||||
</div>
|
||||
<span>
|
||||
{iniciadoPor ? `Gravando iniciada por ${iniciadoPor}` : 'Chamada está sendo gravada'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
183
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
type Props = {
|
||||
dadosSla: {
|
||||
statusSla: {
|
||||
dentroPrazo: number;
|
||||
proximoVencimento: number;
|
||||
vencido: number;
|
||||
semPrazo: number;
|
||||
};
|
||||
porPrioridade: {
|
||||
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
media: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
alta: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
};
|
||||
taxaCumprimento: number;
|
||||
totalComPrazo: number;
|
||||
atualizadoEm: number;
|
||||
};
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let { dadosSla, height = 400 }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart | null = null;
|
||||
|
||||
function prepararDados() {
|
||||
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
|
||||
const cores = {
|
||||
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
|
||||
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
|
||||
vencido: 'rgba(239, 68, 68, 0.8)', // vermelho
|
||||
};
|
||||
|
||||
return {
|
||||
labels: prioridades,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Dentro do Prazo',
|
||||
data: [
|
||||
dadosSla.porPrioridade.baixa.dentroPrazo,
|
||||
dadosSla.porPrioridade.media.dentroPrazo,
|
||||
dadosSla.porPrioridade.alta.dentroPrazo,
|
||||
dadosSla.porPrioridade.critica.dentroPrazo,
|
||||
],
|
||||
backgroundColor: cores.dentroPrazo,
|
||||
borderColor: 'rgba(34, 197, 94, 1)',
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: 'Próximo ao Vencimento',
|
||||
data: [
|
||||
dadosSla.porPrioridade.baixa.proximoVencimento,
|
||||
dadosSla.porPrioridade.media.proximoVencimento,
|
||||
dadosSla.porPrioridade.alta.proximoVencimento,
|
||||
dadosSla.porPrioridade.critica.proximoVencimento,
|
||||
],
|
||||
backgroundColor: cores.proximoVencimento,
|
||||
borderColor: 'rgba(251, 191, 36, 1)',
|
||||
borderWidth: 2,
|
||||
},
|
||||
{
|
||||
label: 'Vencido',
|
||||
data: [
|
||||
dadosSla.porPrioridade.baixa.vencido,
|
||||
dadosSla.porPrioridade.media.vencido,
|
||||
dadosSla.porPrioridade.alta.vencido,
|
||||
dadosSla.porPrioridade.critica.vencido,
|
||||
],
|
||||
backgroundColor: cores.vencido,
|
||||
borderColor: 'rgba(239, 68, 68, 1)',
|
||||
borderWidth: 2,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const chartData = prepararDados();
|
||||
chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#a6adbb',
|
||||
font: {
|
||||
size: 12,
|
||||
family: "'Inter', sans-serif",
|
||||
},
|
||||
usePointStyle: true,
|
||||
padding: 15,
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: '#570df8',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
const prioridade = context.label;
|
||||
return `${label}: ${value} chamado(s)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
ticks: {
|
||||
color: '#a6adbb',
|
||||
font: {
|
||||
size: 11,
|
||||
weight: '500',
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
ticks: {
|
||||
color: '#a6adbb',
|
||||
font: {
|
||||
size: 11,
|
||||
},
|
||||
stepSize: 1,
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800,
|
||||
easing: 'easeInOutQuart'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart && dadosSla) {
|
||||
const chartData = prepararDados();
|
||||
chart.data = chartData;
|
||||
chart.update('active');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="height: {height}px; position: relative;">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
|
||||
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
107
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
corPrazo,
|
||||
formatarData,
|
||||
getStatusBadge,
|
||||
getStatusDescription,
|
||||
getStatusLabel,
|
||||
prazoRestante,
|
||||
} from "$lib/utils/chamados";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
|
||||
interface Props {
|
||||
ticket: Ticket;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{ select: { ticketId: Id<"tickets"> } }>();
|
||||
const props = $props<Props>();
|
||||
const ticket = $derived(props.ticket);
|
||||
const selected = $derived(props.selected ?? false);
|
||||
|
||||
const prioridadeClasses: Record<string, string> = {
|
||||
baixa: "badge badge-sm bg-base-200 text-base-content/70",
|
||||
media: "badge badge-sm badge-info badge-outline",
|
||||
alta: "badge badge-sm badge-warning",
|
||||
critica: "badge badge-sm badge-error",
|
||||
};
|
||||
|
||||
function handleSelect() {
|
||||
dispatch("select", { ticketId: ticket._id });
|
||||
}
|
||||
|
||||
function getPrazoBadges() {
|
||||
const badges: Array<{ label: string; classe: string }> = [];
|
||||
if (ticket.prazoResposta) {
|
||||
const cor = corPrazo(ticket.prazoResposta);
|
||||
badges.push({
|
||||
label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ""}`,
|
||||
classe: `badge badge-xs ${
|
||||
cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success"
|
||||
}`,
|
||||
});
|
||||
}
|
||||
if (ticket.prazoConclusao) {
|
||||
const cor = corPrazo(ticket.prazoConclusao);
|
||||
badges.push({
|
||||
label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ""}`,
|
||||
classe: `badge badge-xs ${
|
||||
cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success"
|
||||
}`,
|
||||
});
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={`rounded-2xl border p-4 transition-all duration-200 ${
|
||||
selected
|
||||
? "border-primary bg-primary/5 shadow-lg"
|
||||
: "border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md"
|
||||
}`}
|
||||
>
|
||||
<button class="w-full text-left" type="button" onclick={handleSelect}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs uppercase tracking-wide text-base-content/50">
|
||||
Ticket {ticket.numero}
|
||||
</p>
|
||||
<h3 class="text-lg font-semibold text-base-content">{ticket.titulo}</h3>
|
||||
</div>
|
||||
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/60 mt-2 text-sm line-clamp-2">{ticket.descricao}</p>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
||||
<span class={prioridadeClasses[ticket.prioridade] ?? "badge badge-sm"}>
|
||||
Prioridade {ticket.prioridade}
|
||||
</span>
|
||||
<span class="badge badge-xs badge-outline">
|
||||
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
|
||||
</span>
|
||||
{#if ticket.setorResponsavel}
|
||||
<span class="badge badge-xs badge-outline badge-ghost">
|
||||
{ticket.setorResponsavel}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 space-y-1 text-xs text-base-content/50">
|
||||
<p>
|
||||
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
|
||||
</p>
|
||||
<p>{getStatusDescription(ticket.status)}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each getPrazoBadges() as badge (badge.label)}
|
||||
<span class={badge.classe}>{badge.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
|
||||
308
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
308
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
@@ -0,0 +1,308 @@
|
||||
<script lang="ts">
|
||||
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
interface FormValues {
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
tipo: Doc<"tickets">["tipo"];
|
||||
prioridade: Doc<"tickets">["prioridade"];
|
||||
categoria: string;
|
||||
canalOrigem?: string;
|
||||
anexos: File[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
|
||||
const props = $props<Props>();
|
||||
const loading = $derived(props.loading ?? false);
|
||||
|
||||
let titulo = $state("");
|
||||
let descricao = $state("");
|
||||
let tipo = $state<Doc<"tickets">["tipo"]>("chamado");
|
||||
let prioridade = $state<Doc<"tickets">["prioridade"]>("media");
|
||||
let categoria = $state("");
|
||||
let canalOrigem = $state("Portal SGSE");
|
||||
let anexos = $state<Array<File>>([]);
|
||||
let errors = $state<Record<string, string>>({});
|
||||
function validate(): boolean {
|
||||
const novoErros: Record<string, string> = {};
|
||||
if (!titulo.trim()) novoErros.titulo = "Informe um título para o chamado.";
|
||||
if (!descricao.trim()) novoErros.descricao = "Descrição é obrigatória.";
|
||||
if (!categoria.trim()) novoErros.categoria = "Informe uma categoria.";
|
||||
errors = novoErros;
|
||||
return Object.keys(novoErros).length === 0;
|
||||
}
|
||||
|
||||
function handleFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
anexos = files.slice(0, 5); // limitar para 5 anexos
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
anexos = anexos.filter((_, idx) => idx !== index);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
titulo = "";
|
||||
descricao = "";
|
||||
categoria = "";
|
||||
tipo = "chamado";
|
||||
prioridade = "media";
|
||||
anexos = [];
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
dispatch("submit", {
|
||||
values: {
|
||||
titulo: titulo.trim(),
|
||||
descricao: descricao.trim(),
|
||||
tipo,
|
||||
prioridade,
|
||||
categoria: categoria.trim(),
|
||||
canalOrigem,
|
||||
anexos,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="space-y-8" onsubmit={handleSubmit}>
|
||||
<!-- Título do Chamado -->
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content">Título do chamado</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-primary w-full"
|
||||
placeholder="Ex: Erro ao acessar o módulo de licitações"
|
||||
bind:value={titulo}
|
||||
/>
|
||||
{#if errors.titulo}
|
||||
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Tipo de Solicitação e Prioridade -->
|
||||
<section class="grid gap-6 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content">Tipo de solicitação</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2 rounded-xl border border-base-300 bg-base-200/30 p-3">
|
||||
{#each [
|
||||
{ value: "chamado", label: "Chamado", icon: "📋" },
|
||||
{ value: "reclamacao", label: "Reclamação", icon: "⚠️" },
|
||||
{ value: "elogio", label: "Elogio", icon: "⭐" },
|
||||
{ value: "sugestao", label: "Sugestão", icon: "💡" }
|
||||
] as opcao}
|
||||
<label
|
||||
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
|
||||
tipo === opcao.value
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tipo"
|
||||
class="radio radio-primary radio-sm shrink-0"
|
||||
value={opcao.value}
|
||||
checked={tipo === opcao.value}
|
||||
onclick={() => (tipo = opcao.value as typeof tipo)}
|
||||
/>
|
||||
<span class="text-base shrink-0">{opcao.icon}</span>
|
||||
<span class="text-sm font-medium flex-1 text-center">{opcao.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content">Prioridade</span>
|
||||
</label>
|
||||
<div class="grid grid-cols-2 gap-2 rounded-xl border border-base-300 bg-base-200/30 p-3">
|
||||
{#each [
|
||||
{ value: "baixa", label: "Baixa", color: "badge-success" },
|
||||
{ value: "media", label: "Média", color: "badge-info" },
|
||||
{ value: "alta", label: "Alta", color: "badge-warning" },
|
||||
{ value: "critica", label: "Crítica", color: "badge-error" }
|
||||
] as opcao}
|
||||
<label
|
||||
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
|
||||
prioridade === opcao.value
|
||||
? "border-primary bg-primary/10 shadow-md"
|
||||
: "border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="prioridade"
|
||||
class={`radio radio-sm shrink-0 ${
|
||||
opcao.value === "baixa" ? "radio-success" :
|
||||
opcao.value === "media" ? "radio-info" :
|
||||
opcao.value === "alta" ? "radio-warning" :
|
||||
"radio-error"
|
||||
}`}
|
||||
value={opcao.value}
|
||||
checked={prioridade === opcao.value}
|
||||
onclick={() => (prioridade = opcao.value as typeof prioridade)}
|
||||
/>
|
||||
<span class={`badge badge-sm ${opcao.color} flex-1 justify-center`}>{opcao.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Categoria -->
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content">Categoria</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
|
||||
bind:value={categoria}
|
||||
/>
|
||||
{#if errors.categoria}
|
||||
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Descrição Detalhada -->
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold text-base-content">Descrição detalhada</span>
|
||||
<span class="label-text-alt text-base-content/50">Obrigatório</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-lg min-h-[180px]"
|
||||
placeholder="Descreva o problema, erro ou sugestão com o máximo de detalhes possível..."
|
||||
bind:value={descricao}
|
||||
></textarea>
|
||||
{#if errors.descricao}
|
||||
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Anexos -->
|
||||
<section class="space-y-4 rounded-xl border border-base-300 bg-base-200/30 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-base-content">Anexos (opcional)</p>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Suporte a PDF e imagens (máx. 10MB por arquivo)
|
||||
</p>
|
||||
</div>
|
||||
<label class="btn btn-outline btn-sm">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
Selecionar arquivos
|
||||
<input type="file" class="hidden" multiple accept=".pdf,.png,.jpg,.jpeg" onchange={handleFiles} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if anexos.length > 0}
|
||||
<div class="space-y-2 rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
||||
{#each anexos as file, index (file.name + index)}
|
||||
<div class="flex items-center justify-between gap-3 rounded-xl border border-base-200 bg-base-100 px-3 py-2">
|
||||
<div>
|
||||
<p class="text-sm font-medium">{file.name}</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => removeFile(index)}
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-2xl border border-dashed border-base-300 bg-base-100/50 p-6 text-center text-sm text-base-content/60">
|
||||
Nenhum arquivo selecionado.
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Ações do Formulário -->
|
||||
<section class="flex flex-wrap gap-3 border-t border-base-300 pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary flex-1 min-w-[200px] shadow-lg"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Registrar chamado
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={resetForm}
|
||||
disabled={loading}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Limpar
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
86
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import {
|
||||
formatarData,
|
||||
formatarTimelineEtapa,
|
||||
prazoRestante,
|
||||
timelineStatus,
|
||||
} from "$lib/utils/chamados";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
|
||||
|
||||
interface Props {
|
||||
timeline?: Array<TimelineEntry>;
|
||||
}
|
||||
|
||||
const props = $props<Props>();
|
||||
const timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
|
||||
|
||||
const badgeClasses: Record<string, string> = {
|
||||
success: "bg-success/20 text-success border-success/40",
|
||||
warning: "bg-warning/20 text-warning border-warning/40",
|
||||
error: "bg-error/20 text-error border-error/40",
|
||||
info: "bg-info/20 text-info border-info/40",
|
||||
};
|
||||
|
||||
function getBadgeClass(entry: TimelineEntry) {
|
||||
const status = timelineStatus(entry);
|
||||
return badgeClasses[status] ?? badgeClasses.info;
|
||||
}
|
||||
|
||||
function getStatusLabel(entry: TimelineEntry) {
|
||||
if (entry.status === "concluido") return "Concluído";
|
||||
if (entry.status === "em_andamento") return "Em andamento";
|
||||
if (entry.status === "vencido") return "Vencido";
|
||||
return "Pendente";
|
||||
}
|
||||
|
||||
function getPrazoDescricao(entry: TimelineEntry) {
|
||||
if (entry.status === "concluido" && entry.concluidoEm) {
|
||||
return `Concluído em ${formatarData(entry.concluidoEm)}`;
|
||||
}
|
||||
if (!entry.prazo) return "Sem prazo definido";
|
||||
return `${formatarData(entry.prazo)} • ${prazoRestante(entry.prazo) ?? ""}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if timeline.length === 0}
|
||||
<div class="alert alert-info">
|
||||
<span>Nenhuma etapa registrada ainda.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each timeline as entry (entry.etapa + entry.prazo)}
|
||||
<div class="flex gap-3">
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div class={`badge border ${getBadgeClass(entry)}`}>
|
||||
{formatarTimelineEtapa(entry.etapa)}
|
||||
</div>
|
||||
{#if entry !== timeline[timeline.length - 1]}
|
||||
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 rounded-2xl border border-base-200 bg-base-100/80 p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-semibold text-base-content">
|
||||
{getStatusLabel(entry)}
|
||||
</span>
|
||||
{#if entry.status !== "concluido" && entry.prazo}
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{prazoRestante(entry.prazo)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.observacao}
|
||||
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
|
||||
{/if}
|
||||
<p class="text-base-content/50 mt-3 text-xs uppercase tracking-wide">
|
||||
{getPrazoDescricao(entry)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ChatList from './ChatList.svelte';
|
||||
import ChatWindow from './ChatWindow.svelte';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
|
||||
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
||||
@@ -34,14 +34,12 @@
|
||||
if (!usuario) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (usuario.fotoPerfil) {
|
||||
return usuario.fotoPerfil;
|
||||
if (usuario.fotoPerfilUrl) {
|
||||
return usuario.fotoPerfilUrl;
|
||||
}
|
||||
if (usuario.avatar) {
|
||||
return getAvatarUrl(usuario.avatar);
|
||||
}
|
||||
// Fallback: gerar avatar baseado no nome
|
||||
return getAvatarUrl(usuario.nome);
|
||||
|
||||
// Fallback: retornar null para usar o ícone User do Lucide
|
||||
return null;
|
||||
});
|
||||
|
||||
// Posição do widget (arrastável)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,454 +1,422 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { abrirConversa } from "$lib/stores/chatStore";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import {
|
||||
MessageSquare,
|
||||
User,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
UserX,
|
||||
} from "lucide-svelte";
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { abrirConversa } from '$lib/stores/chatStore';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import {
|
||||
MessageSquare,
|
||||
User,
|
||||
Users,
|
||||
Video,
|
||||
X,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
UserX
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
let { onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let activeTab = $state<"individual" | "grupo" | "sala_reuniao">("individual");
|
||||
let searchQuery = $state("");
|
||||
let selectedUsers = $state<string[]>([]);
|
||||
let groupName = $state("");
|
||||
let salaReuniaoName = $state("");
|
||||
let loading = $state(false);
|
||||
let activeTab = $state<'individual' | 'grupo' | 'sala_reuniao'>('individual');
|
||||
let searchQuery = $state('');
|
||||
let selectedUsers = $state<string[]>([]);
|
||||
let groupName = $state('');
|
||||
let salaReuniaoName = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios?.data) return [];
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios?.data) return [];
|
||||
|
||||
// Filtrar o próprio usuário
|
||||
const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
|
||||
let lista = usuarios.data.filter((u: any) => u._id !== meuId);
|
||||
// Filtrar o próprio usuário
|
||||
const meuId = currentUser?.data?._id || meuPerfil?.data?._id;
|
||||
let lista = usuarios.data.filter((u: any) => u._id !== meuId);
|
||||
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter(
|
||||
(u: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter(
|
||||
(u: any) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar: online primeiro, depois por nome
|
||||
return lista.sort((a: any, b: any) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4,
|
||||
};
|
||||
const statusA =
|
||||
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB =
|
||||
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
// Ordenar: online primeiro, depois por nome
|
||||
return lista.sort((a: any, b: any) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4
|
||||
};
|
||||
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return (a.nome || "").localeCompare(b.nome || "");
|
||||
});
|
||||
});
|
||||
if (statusA !== statusB) return statusA - statusB;
|
||||
return (a.nome || '').localeCompare(b.nome || '');
|
||||
});
|
||||
});
|
||||
|
||||
function toggleUserSelection(userId: string) {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
selectedUsers = selectedUsers.filter((id) => id !== userId);
|
||||
} else {
|
||||
selectedUsers = [...selectedUsers, userId];
|
||||
}
|
||||
}
|
||||
function toggleUserSelection(userId: string) {
|
||||
if (selectedUsers.includes(userId)) {
|
||||
selectedUsers = selectedUsers.filter((id) => id !== userId);
|
||||
} else {
|
||||
selectedUsers = [...selectedUsers, userId];
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCriarIndividual(userId: string) {
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: "individual",
|
||||
participantes: [userId as any],
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erro ao criar conversa:", error);
|
||||
alert("Erro ao criar conversa");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function handleCriarIndividual(userId: string) {
|
||||
try {
|
||||
loading = true;
|
||||
const conversaId = await client.mutation(api.chat.criarConversa, {
|
||||
tipo: 'individual',
|
||||
participantes: [userId as any]
|
||||
});
|
||||
abrirConversa(conversaId);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar conversa:', error);
|
||||
alert('Erro ao criar conversa');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCriarGrupo() {
|
||||
if (selectedUsers.length < 2) {
|
||||
alert("Selecione pelo menos 2 participantes");
|
||||
return;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="modal-box max-w-2xl max-h-[85vh] flex flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<h2 class="text-2xl font-bold flex items-center gap-2">
|
||||
<MessageSquare class="w-6 h-6 text-primary" />
|
||||
Nova Conversa
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
class="modal-box flex max-h-[85vh] max-w-2xl flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 class="flex items-center gap-2 text-2xl font-bold">
|
||||
<MessageSquare class="text-primary h-6 w-6" />
|
||||
Nova Conversa
|
||||
</h2>
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tabs melhoradas -->
|
||||
<div class="tabs tabs-boxed p-4 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === "individual"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = "individual";
|
||||
selectedUsers = [];
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<User class="w-4 h-4" />
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === "grupo"
|
||||
? "tab-active bg-primary text-primary-content font-semibold"
|
||||
: "hover:bg-base-300"
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = "grupo";
|
||||
selectedUsers = [];
|
||||
searchQuery = "";
|
||||
}}
|
||||
>
|
||||
<Users class="w-4 h-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="w-4 h-4" />
|
||||
Sala de Reunião
|
||||
</button>
|
||||
</div>
|
||||
<!-- Tabs melhoradas -->
|
||||
<div class="tabs tabs-boxed bg-base-200/50 p-4">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === 'individual'
|
||||
? 'tab-active bg-primary text-primary-content font-semibold'
|
||||
: 'hover:bg-base-300'
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = 'individual';
|
||||
selectedUsers = [];
|
||||
searchQuery = '';
|
||||
}}
|
||||
>
|
||||
<User class="h-4 w-4" />
|
||||
Individual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === 'grupo'
|
||||
? 'tab-active bg-primary text-primary-content font-semibold'
|
||||
: 'hover:bg-base-300'
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = 'grupo';
|
||||
selectedUsers = [];
|
||||
searchQuery = '';
|
||||
}}
|
||||
>
|
||||
<Users class="h-4 w-4" />
|
||||
Grupo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 transition-all duration-200 ${
|
||||
activeTab === 'sala_reuniao'
|
||||
? 'tab-active bg-primary text-primary-content font-semibold'
|
||||
: 'hover:bg-base-300'
|
||||
}`}
|
||||
onclick={() => {
|
||||
activeTab = 'sala_reuniao';
|
||||
selectedUsers = [];
|
||||
searchQuery = '';
|
||||
}}
|
||||
>
|
||||
<Video class="h-4 w-4" />
|
||||
Sala de Reunião
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === "grupo"}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome do Grupo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome do grupo..."
|
||||
class="input input-bordered w-full focus:input-primary transition-colors"
|
||||
bind:value={groupName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
{#if activeTab === 'grupo'}
|
||||
<!-- Criar Grupo -->
|
||||
<div class="mb-4">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome do Grupo</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome do grupo..."
|
||||
class="input input-bordered focus:input-primary w-full transition-colors"
|
||||
bind:value={groupName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if activeTab === "sala_reuniao"}
|
||||
<!-- Criar Sala de Reunião -->
|
||||
<div class="mb-4">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome da Sala de Reunião</span
|
||||
>
|
||||
<span class="label-text-alt text-primary font-medium"
|
||||
>👑 Você será o administrador</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome da sala de reunião..."
|
||||
class="input input-bordered w-full focus:input-primary transition-colors"
|
||||
bind:value={salaReuniaoName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{:else if activeTab === 'sala_reuniao'}
|
||||
<!-- Criar Sala de Reunião -->
|
||||
<div class="mb-4">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">Nome da Sala de Reunião</span>
|
||||
<span class="label-text-alt text-primary font-medium">👑 Você será o administrador</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Digite o nome da sala de reunião..."
|
||||
class="input input-bordered focus:input-primary w-full transition-colors"
|
||||
bind:value={salaReuniaoName}
|
||||
maxlength="50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? "s" : ""})`
|
||||
: ""}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mb-3">
|
||||
<label class="label pb-2">
|
||||
<span class="label-text font-semibold">
|
||||
Participantes {selectedUsers.length > 0
|
||||
? `(${selectedUsers.length} selecionado${selectedUsers.length > 1 ? 's' : ''})`
|
||||
: ''}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Search melhorado -->
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários por nome, email ou matrícula..."
|
||||
class="input input-bordered w-full pl-10 focus:input-primary transition-colors"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
|
||||
/>
|
||||
</div>
|
||||
<!-- Search melhorado -->
|
||||
<div class="relative mb-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários por nome, email ou matrícula..."
|
||||
class="input input-bordered focus:input-primary w-full pl-10 transition-colors"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search class="text-base-content/40 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2" />
|
||||
</div>
|
||||
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios?.data && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isSelected = selectedUsers.includes(usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full text-left px-4 py-3 rounded-xl border-2 transition-all duration-200 flex items-center gap-3 ${
|
||||
isSelected
|
||||
? "border-primary bg-primary/10 shadow-md scale-[1.02]"
|
||||
: "border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm"
|
||||
} ${loading ? "opacity-50 cursor-not-allowed" : "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 -bottom-1 -right-1">
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lista de usuários -->
|
||||
<div class="space-y-2">
|
||||
{#if usuarios?.data && usuariosFiltrados().length > 0}
|
||||
{#each usuariosFiltrados() as usuario (usuario._id)}
|
||||
{@const isSelected = selectedUsers.includes(usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class={`flex w-full items-center gap-3 rounded-xl border-2 px-4 py-3 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/10 scale-[1.02] shadow-md'
|
||||
: 'border-base-300 hover:bg-base-200 hover:border-primary/30 hover:shadow-sm'
|
||||
} ${loading ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
onclick={() => {
|
||||
if (loading) return;
|
||||
if (activeTab === 'individual') {
|
||||
handleCriarIndividual(usuario._id);
|
||||
} else {
|
||||
toggleUserSelection(usuario._id);
|
||||
}
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
/>
|
||||
<div class="absolute -right-1 -bottom-1">
|
||||
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{usuario.setor ||
|
||||
usuario.email ||
|
||||
usuario.matricula ||
|
||||
"Sem informações"}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Info -->
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<p class="text-base-content/60 truncate text-sm">
|
||||
{usuario.setor || usuario.email || usuario.matricula || 'Sem informações'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Checkbox melhorado (para grupo e sala de reunião) -->
|
||||
{#if activeTab === "grupo" || activeTab === "sala_reuniao"}
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-lg"
|
||||
checked={isSelected}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Ícone de seta para individual -->
|
||||
<ChevronRight class="w-5 h-5 text-base-content/40" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"
|
||||
></span>
|
||||
<p class="mt-4 text-base-content/60">Carregando usuários...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex flex-col items-center justify-center py-12 text-center"
|
||||
>
|
||||
<UserX class="w-16 h-16 text-base-content/30 mb-4" />
|
||||
<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-sm text-base-content/50 mt-2">
|
||||
Tente buscar por nome, email ou matrícula
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Checkbox melhorado (para grupo e sala de reunião) -->
|
||||
{#if activeTab === 'grupo' || activeTab === 'sala_reuniao'}
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-lg"
|
||||
checked={isSelected}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Ícone de seta para individual -->
|
||||
<ChevronRight class="text-base-content/40 h-5 w-5" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<div class="flex flex-col items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="text-base-content/60 mt-4">Carregando usuários...</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<UserX class="text-base-content/30 mb-4 h-16 w-16" />
|
||||
<p class="text-base-content/70 font-medium">
|
||||
{searchQuery.trim() ? 'Nenhum usuário encontrado' : 'Nenhum usuário disponível'}
|
||||
</p>
|
||||
{#if searchQuery.trim()}
|
||||
<p class="text-base-content/50 mt-2 text-sm">
|
||||
Tente buscar por nome, email ou matrícula
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer (para grupo e sala de reunião) -->
|
||||
{#if activeTab === "grupo"}
|
||||
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
onclick={handleCriarGrupo}
|
||||
disabled={loading || selectedUsers.length < 2 || !groupName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando grupo...
|
||||
{:else}
|
||||
<Plus class="w-5 h-5" />
|
||||
Criar Grupo
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedUsers.length < 2 && activeTab === "grupo"}
|
||||
<p class="text-xs text-base-content/50 text-center mt-2">
|
||||
Selecione pelo menos 2 participantes
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === "sala_reuniao"}
|
||||
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-block btn-lg font-semibold shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
onclick={handleCriarSalaReuniao}
|
||||
disabled={loading ||
|
||||
selectedUsers.length < 1 ||
|
||||
!salaReuniaoName.trim()}
|
||||
>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Criando sala...
|
||||
{:else}
|
||||
<Plus class="w-5 h-5" />
|
||||
Criar Sala de Reunião
|
||||
{/if}
|
||||
</button>
|
||||
{#if selectedUsers.length < 1 && activeTab === "sala_reuniao"}
|
||||
<p class="text-xs text-base-content/50 text-center mt-2">
|
||||
Selecione pelo menos 1 participante
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
<!-- 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>
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
// Verificar se o usuário está autenticado antes de gerenciar presença
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined);
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -13,6 +18,8 @@
|
||||
|
||||
// Detectar atividade do usuário
|
||||
function handleActivity() {
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
lastActivity = Date.now();
|
||||
|
||||
// Limpar timeout de inatividade anterior
|
||||
@@ -22,16 +29,29 @@
|
||||
|
||||
// Configurar novo timeout (5 minutos)
|
||||
inactivityTimeout = setTimeout(() => {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
if (usuarioAutenticado) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Configurar como online ao montar
|
||||
// Só configurar presença se usuário estiver autenticado
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
// Configurar como online ao montar (apenas se autenticado)
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
|
||||
// Heartbeat a cada 30 segundos
|
||||
// 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
|
||||
@@ -47,10 +67,14 @@
|
||||
});
|
||||
|
||||
// Configurar timeout inicial de inatividade
|
||||
handleActivity();
|
||||
if (usuarioAutenticado) {
|
||||
handleActivity();
|
||||
}
|
||||
|
||||
// Detectar quando a aba fica inativa/ativa
|
||||
function handleVisibilityChange() {
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
if (document.hidden) {
|
||||
// Aba ficou inativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
@@ -65,8 +89,10 @@
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Marcar como offline ao desmontar
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
|
||||
// Marcar como offline ao desmontar (apenas se autenticado)
|
||||
if (usuarioAutenticado) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
|
||||
}
|
||||
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
@@ -86,4 +112,3 @@
|
||||
</script>
|
||||
|
||||
<!-- Componente invisível - apenas lógica -->
|
||||
|
||||
|
||||
@@ -1,487 +1,435 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import {
|
||||
X,
|
||||
Users,
|
||||
UserPlus,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Trash2,
|
||||
Search,
|
||||
} from "lucide-svelte";
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, isAdmin, onClose }: Props = $props();
|
||||
let { conversaId, isAdmin, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const todosUsuariosQuery = useQuery(api.chat.listarTodosUsuarios, {});
|
||||
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 activeTab = $state<'participantes' | 'adicionar'>('participantes');
|
||||
let searchQuery = $state('');
|
||||
let loading = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const conversa = $derived(() => {
|
||||
if (!conversas?.data) return null;
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
const conversa = $derived(() => {
|
||||
if (!conversas?.data) return null;
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
|
||||
const todosUsuarios = $derived(() => {
|
||||
return todosUsuariosQuery?.data || [];
|
||||
});
|
||||
const todosUsuarios = $derived(() => {
|
||||
return todosUsuariosQuery?.data || [];
|
||||
});
|
||||
|
||||
const participantes = $derived(() => {
|
||||
try {
|
||||
const conv = conversa();
|
||||
const usuarios = todosUsuarios();
|
||||
if (!conv || !usuarios || usuarios.length === 0) return [];
|
||||
const participantes = $derived(() => {
|
||||
try {
|
||||
const conv = conversa();
|
||||
const usuarios = todosUsuarios();
|
||||
if (!conv || !usuarios || usuarios.length === 0) return [];
|
||||
|
||||
const participantesInfo = conv.participantesInfo || [];
|
||||
if (!Array.isArray(participantesInfo) || participantesInfo.length === 0)
|
||||
return [];
|
||||
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;
|
||||
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;
|
||||
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 [];
|
||||
}
|
||||
});
|
||||
// Combinar dados do usuário com dados do participante (se p for objeto)
|
||||
return {
|
||||
...usuario,
|
||||
...(typeof p === 'object' && p !== null && p !== undefined ? p : {}),
|
||||
// Garantir que _id existe e priorizar o do usuario
|
||||
_id: usuario._id
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('Erro ao processar participante:', err, p);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((p: any) => p !== null && p._id);
|
||||
} catch (err) {
|
||||
console.error('Erro ao calcular participantes:', err);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const administradoresIds = $derived(() => {
|
||||
return conversa()?.administradores || [];
|
||||
});
|
||||
const administradoresIds = $derived(() => {
|
||||
return conversa()?.administradores || [];
|
||||
});
|
||||
|
||||
const usuariosDisponiveis = $derived(() => {
|
||||
const usuarios = todosUsuarios();
|
||||
if (!usuarios || usuarios.length === 0) return [];
|
||||
const participantesIds = conversa()?.participantes || [];
|
||||
return usuarios.filter(
|
||||
(u: any) =>
|
||||
!participantesIds.some((pid: any) => String(pid) === String(u._id)),
|
||||
);
|
||||
});
|
||||
const usuariosDisponiveis = $derived(() => {
|
||||
const usuarios = todosUsuarios();
|
||||
if (!usuarios || usuarios.length === 0) return [];
|
||||
const participantesIds = conversa()?.participantes || [];
|
||||
return usuarios.filter(
|
||||
(u: any) => !participantesIds.some((pid: any) => String(pid) === String(u._id))
|
||||
);
|
||||
});
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
const disponiveis = usuariosDisponiveis();
|
||||
if (!searchQuery.trim()) return disponiveis;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return disponiveis.filter(
|
||||
(u: any) =>
|
||||
(u.nome || "").toLowerCase().includes(query) ||
|
||||
(u.email || "").toLowerCase().includes(query) ||
|
||||
(u.matricula || "").toLowerCase().includes(query),
|
||||
);
|
||||
});
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
const disponiveis = usuariosDisponiveis();
|
||||
if (!searchQuery.trim()) return disponiveis;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return disponiveis.filter(
|
||||
(u: any) =>
|
||||
(u.nome || '').toLowerCase().includes(query) ||
|
||||
(u.email || '').toLowerCase().includes(query) ||
|
||||
(u.matricula || '').toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
function isParticipanteAdmin(usuarioId: string): boolean {
|
||||
const admins = administradoresIds();
|
||||
return admins.some((adminId: any) => String(adminId) === String(usuarioId));
|
||||
}
|
||||
function 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;
|
||||
}
|
||||
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;
|
||||
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,
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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,
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
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,
|
||||
},
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 max-w-2xl max-h-[80vh] flex flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold flex items-center gap-2">
|
||||
<Users class="w-5 h-5 text-primary" />
|
||||
Gerenciar Sala de Reunião
|
||||
</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{conversa()?.nome || "Sem nome"}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<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="w-4 h-4" />
|
||||
Participantes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex items-center gap-2 ${activeTab === "adicionar" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "adicionar")}
|
||||
>
|
||||
<UserPlus class="w-4 h-4" />
|
||||
Adicionar Participante
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- 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="mx-6 mt-2 alert alert-error">
|
||||
<span>{error}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => (error = null)}
|
||||
>
|
||||
<X class="w-4 h-4" />
|
||||
</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="ml-2 text-sm text-base-content/60"
|
||||
>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="ml-2 text-sm text-base-content/60"
|
||||
>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="flex items-center gap-3 p-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={participante.avatar}
|
||||
fotoPerfilUrl={participante.fotoPerfilUrl ||
|
||||
participante.fotoPerfil}
|
||||
nome={participante.nome || "Usuário"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge
|
||||
status={participante.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 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="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{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-sm text-base-content/60 truncate">
|
||||
{participante.setor || participante.email || ""}
|
||||
</p>
|
||||
</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="w-4 h-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="w-4 h-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="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
Nenhum participante encontrado
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if activeTab === "adicionar" && isAdmin}
|
||||
<!-- Adicionar Participante -->
|
||||
<div class="mb-4 relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
<Search
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/40"
|
||||
/>
|
||||
</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="w-full text-left px-4 py-3 rounded-lg border border-base-300 hover:bg-base-200 transition-colors flex items-center gap-3"
|
||||
onclick={() => adicionarParticipante(usuarioId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl || usuario.fotoPerfil}
|
||||
nome={usuario.nome || "Usuário"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</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="flex-1 min-w-0">
|
||||
<p class="font-medium text-base-content truncate">
|
||||
{usuario.nome || "Usuário"}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60 truncate">
|
||||
{usuario.setor || usuario.email || ""}
|
||||
</p>
|
||||
</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="w-5 h-5 text-primary" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-center py-8 text-base-content/50">
|
||||
{searchQuery.trim()
|
||||
? "Nenhum usuário encontrado"
|
||||
: "Todos os usuários já são participantes"}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</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="px-6 py-4 border-t border-base-300">
|
||||
<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>
|
||||
<!-- Footer -->
|
||||
<div class="border-base-300 border-t px-6 py-4">
|
||||
<button type="button" class="btn btn-block" onclick={onClose}> Fechar </button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={onClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
@@ -1,288 +1,269 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { format } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { Clock, X, Trash2 } from "lucide-svelte";
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Clock, X, Trash2 } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
onClose: () => void;
|
||||
}
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
conversaId,
|
||||
});
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
conversaId
|
||||
});
|
||||
|
||||
let mensagem = $state("");
|
||||
let data = $state("");
|
||||
let hora = $state("");
|
||||
let loading = $state(false);
|
||||
let mensagem = $state('');
|
||||
let data = $state('');
|
||||
let hora = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
// Rastrear mudanças nas mensagens agendadas
|
||||
$effect(() => {
|
||||
console.log(
|
||||
"📅 [ScheduleModal] Mensagens agendadas atualizadas:",
|
||||
mensagensAgendadas?.data,
|
||||
);
|
||||
});
|
||||
// Rastrear mudanças nas mensagens agendadas
|
||||
$effect(() => {
|
||||
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
|
||||
});
|
||||
|
||||
// Definir data/hora mínima (agora)
|
||||
const now = new Date();
|
||||
const minDate = format(now, "yyyy-MM-dd");
|
||||
const minTime = format(now, "HH:mm");
|
||||
// Definir data/hora mínima (agora)
|
||||
const now = new Date();
|
||||
const minDate = format(now, 'yyyy-MM-dd');
|
||||
const minTime = format(now, 'HH:mm');
|
||||
|
||||
function getPreviewText(): string {
|
||||
if (!data || !hora) return "";
|
||||
function getPreviewText(): string {
|
||||
if (!data || !hora) return '';
|
||||
|
||||
try {
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
try {
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
return `Será enviada em ${format(dataHora, "dd/MM/yyyy 'às' HH:mm", { locale: ptBR })}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAgendar() {
|
||||
if (!mensagem.trim() || !data || !hora) {
|
||||
alert("Preencha todos os campos");
|
||||
return;
|
||||
}
|
||||
async function handleAgendar() {
|
||||
if (!mensagem.trim() || !data || !hora) {
|
||||
alert('Preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
try {
|
||||
loading = true;
|
||||
const dataHora = new Date(`${data}T${hora}`);
|
||||
|
||||
// Validar data futura
|
||||
if (dataHora.getTime() <= Date.now()) {
|
||||
alert("A data e hora devem ser futuras");
|
||||
return;
|
||||
}
|
||||
// Validar data futura
|
||||
if (dataHora.getTime() <= Date.now()) {
|
||||
alert('A data e hora devem ser futuras');
|
||||
return;
|
||||
}
|
||||
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId,
|
||||
conteudo: mensagem.trim(),
|
||||
agendadaPara: dataHora.getTime(),
|
||||
});
|
||||
await client.mutation(api.chat.agendarMensagem, {
|
||||
conversaId,
|
||||
conteudo: mensagem.trim(),
|
||||
agendadaPara: dataHora.getTime()
|
||||
});
|
||||
|
||||
mensagem = "";
|
||||
data = "";
|
||||
hora = "";
|
||||
mensagem = '';
|
||||
data = '';
|
||||
hora = '';
|
||||
|
||||
// Dar tempo para o Convex processar e recarregar a lista
|
||||
setTimeout(() => {
|
||||
alert("Mensagem agendada com sucesso!");
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error("Erro ao agendar mensagem:", error);
|
||||
alert("Erro ao agendar mensagem");
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
// Dar tempo para o Convex processar e recarregar a lista
|
||||
setTimeout(() => {
|
||||
alert('Mensagem agendada com sucesso!');
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Erro ao agendar mensagem:', error);
|
||||
alert('Erro ao agendar mensagem');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCancelar(mensagemId: string) {
|
||||
if (!confirm("Deseja cancelar esta mensagem agendada?")) return;
|
||||
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");
|
||||
}
|
||||
}
|
||||
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";
|
||||
}
|
||||
}
|
||||
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>
|
||||
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="modal-box max-w-2xl max-h-[90vh] flex flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300"
|
||||
>
|
||||
<h2 id="modal-title" class="text-xl font-bold flex items-center gap-2">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
Agendar Mensagem
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
onclick={onClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<dialog class="modal modal-open" onclick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div
|
||||
class="modal-box flex max-h-[90vh] max-w-2xl flex-col p-0"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
|
||||
<Clock class="text-primary h-5 w-5" />
|
||||
Agendar Mensagem
|
||||
</h2>
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
<!-- Formulário de Agendamento -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
<!-- Formulário de Agendamento -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Nova Mensagem Agendada</h3>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="mensagem-input">
|
||||
<span class="label-text">Mensagem</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="mensagem-input"
|
||||
class="textarea textarea-bordered h-24"
|
||||
placeholder="Digite a mensagem..."
|
||||
bind:value={mensagem}
|
||||
maxlength="500"
|
||||
aria-describedby="char-count"
|
||||
></textarea>
|
||||
<div class="label">
|
||||
<span id="char-count" class="label-text-alt"
|
||||
>{mensagem.length}/500</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" 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="grid md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="data-input">
|
||||
<span class="label-text">Data</span>
|
||||
</label>
|
||||
<input
|
||||
id="data-input"
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={data}
|
||||
min={minDate}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label" for="data-input">
|
||||
<span class="label-text">Data</span>
|
||||
</label>
|
||||
<input
|
||||
id="data-input"
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={data}
|
||||
min={minDate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="hora-input">
|
||||
<span class="label-text">Hora</span>
|
||||
</label>
|
||||
<input
|
||||
id="hora-input"
|
||||
type="time"
|
||||
class="input input-bordered"
|
||||
bind:value={hora}
|
||||
min={data === minDate ? minTime : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="hora-input">
|
||||
<span class="label-text">Hora</span>
|
||||
</label>
|
||||
<input
|
||||
id="hora-input"
|
||||
type="time"
|
||||
class="input input-bordered"
|
||||
bind:value={hora}
|
||||
min={data === minDate ? minTime : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if getPreviewText()}
|
||||
<div class="alert alert-info">
|
||||
<Clock class="w-6 h-6" />
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if getPreviewText()}
|
||||
<div class="alert alert-info">
|
||||
<Clock class="h-6 w-6" />
|
||||
<span>{getPreviewText()}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="card-actions justify-end">
|
||||
<!-- Botão AGENDAR ultra moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="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"
|
||||
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="card-actions justify-end">
|
||||
<!-- Botão AGENDAR ultra moderno -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
onclick={handleAgendar}
|
||||
disabled={loading || !mensagem.trim() || !data || !hora}
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
|
||||
></div>
|
||||
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Agendando...</span>
|
||||
{:else}
|
||||
<Clock
|
||||
class="w-5 h-5 group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
<span class="group-hover:scale-105 transition-transform"
|
||||
>Agendar</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative z-10 flex items-center gap-2">
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span>Agendando...</span>
|
||||
{:else}
|
||||
<Clock class="h-5 w-5 transition-transform group-hover:scale-110" />
|
||||
<span class="transition-transform group-hover:scale-105">Agendar</span>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Mensagens Agendadas -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
|
||||
<!-- Lista de Mensagens Agendadas -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Mensagens Agendadas</h3>
|
||||
|
||||
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each mensagensAgendadas.data as msg (msg._id)}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg">
|
||||
<div class="shrink-0 mt-1">
|
||||
<Clock class="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
{#if mensagensAgendadas?.data && mensagensAgendadas.data.length > 0}
|
||||
<div class="space-y-3">
|
||||
{#each mensagensAgendadas.data as msg (msg._id)}
|
||||
<div class="bg-base-100 flex items-start gap-3 rounded-lg p-3">
|
||||
<div class="mt-1 shrink-0">
|
||||
<Clock class="text-primary h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div class="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="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="flex items-center justify-center w-9 h-9 rounded-lg transition-all duration-300 group relative overflow-hidden"
|
||||
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="absolute inset-0 bg-error/0 group-hover:bg-error/20 transition-colors duration-300"
|
||||
></div>
|
||||
<Trash2
|
||||
class="w-5 h-5 text-error relative z-10 group-hover:scale-110 transition-transform"
|
||||
/>
|
||||
</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">
|
||||
<Clock class="w-12 h-12 mx-auto mb-2 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>
|
||||
<!-- 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>
|
||||
|
||||
@@ -1,41 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
}
|
||||
import { User } from 'lucide-svelte';
|
||||
|
||||
let { avatar, fotoPerfilUrl, nome, size = "md" }: Props = $props();
|
||||
interface Props {
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "w-8 h-8",
|
||||
sm: "w-10 h-10",
|
||||
md: "w-12 h-12",
|
||||
lg: "w-16 h-16",
|
||||
};
|
||||
let { fotoPerfilUrl, nome, size = 'md' }: Props = $props();
|
||||
|
||||
function getAvatarUrl(avatarId: string): string {
|
||||
// Usar gerador local ao invés da API externa
|
||||
return generateAvatarUrl(avatarId);
|
||||
}
|
||||
const sizeClasses = {
|
||||
xs: 'w-8 h-8',
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-16 h-16',
|
||||
xl: 'w-32 h-32'
|
||||
};
|
||||
|
||||
const avatarUrlToShow = $derived(() => {
|
||||
if (fotoPerfilUrl) return fotoPerfilUrl;
|
||||
if (avatar) return getAvatarUrl(avatar);
|
||||
return getAvatarUrl(nome); // Fallback usando o nome
|
||||
});
|
||||
const iconSizes = {
|
||||
xs: 16,
|
||||
sm: 20,
|
||||
md: 24,
|
||||
lg: 32,
|
||||
xl: 64
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="avatar">
|
||||
<div class={`${sizeClasses[size]} rounded-full bg-base-200 overflow-hidden`}>
|
||||
<img
|
||||
src={avatarUrlToShow()}
|
||||
alt={`Avatar de ${nome}`}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="avatar placeholder">
|
||||
<div
|
||||
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
|
||||
>
|
||||
{#if fotoPerfilUrl}
|
||||
<img
|
||||
src={fotoPerfilUrl}
|
||||
alt={`Foto de perfil de ${nome}`}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<User size={iconSizes[size]} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import CalendarioFerias from './CalendarioFerias.svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
@@ -30,7 +29,15 @@
|
||||
let observacao = $state('');
|
||||
let processando = $state(false);
|
||||
|
||||
// Estados para os selects de data
|
||||
let dataInicioPeriodo = $state('');
|
||||
let dataFimPeriodo = $state('');
|
||||
|
||||
// Queries
|
||||
const funcionarioQuery = useQuery(api.funcionarios.getById, { id: funcionarioId });
|
||||
const funcionario = $derived(funcionarioQuery?.data);
|
||||
const regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
|
||||
|
||||
const saldoQuery = $derived(
|
||||
useQuery(api.saldoFerias.obterSaldo, {
|
||||
funcionarioId,
|
||||
@@ -62,9 +69,98 @@
|
||||
return [anoAtual - 1, anoAtual, anoAtual + 1];
|
||||
});
|
||||
|
||||
// Configurações do calendário (baseado no saldo/regime)
|
||||
const maxPeriodos = $derived(saldo?.regimeTrabalho?.includes('Servidor') ? 2 : 3);
|
||||
const minDiasPorPeriodo = $derived(saldo?.regimeTrabalho?.includes('Servidor') ? 10 : 5);
|
||||
// Verificar se é regime estatutário PE ou Municipal
|
||||
const ehEstatutarioPEOuMunicipal = $derived(
|
||||
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
|
||||
);
|
||||
|
||||
// Função para calcular dias entre duas datas
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
if (!dataInicio || !dataFim) return 0;
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return diffDays;
|
||||
}
|
||||
|
||||
// Função para formatar data sem problemas de timezone
|
||||
function formatarDataString(dataString: string): string {
|
||||
if (!dataString) return '';
|
||||
// Dividir a string da data (formato YYYY-MM-DD)
|
||||
const partes = dataString.split('-');
|
||||
if (partes.length !== 3) return dataString;
|
||||
// Retornar no formato DD/MM/YYYY
|
||||
return `${partes[2]}/${partes[1]}/${partes[0]}`;
|
||||
}
|
||||
|
||||
// Função para adicionar período
|
||||
function adicionarPeriodo() {
|
||||
if (!dataInicioPeriodo || !dataFimPeriodo) {
|
||||
toast.error('Selecione as datas de início e fim');
|
||||
return;
|
||||
}
|
||||
|
||||
const dias = calcularDias(dataInicioPeriodo, dataFimPeriodo);
|
||||
|
||||
if (dias <= 0) {
|
||||
toast.error('Data de fim deve ser posterior à data de início');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validações específicas para estatutário PE e Municipal
|
||||
// Permite períodos fracionados: cada período deve ser 15 ou 30 dias
|
||||
// Total não pode exceder 30 dias, mas pode ser menos
|
||||
if (ehEstatutarioPEOuMunicipal) {
|
||||
// Verificar se o período individual é válido (15 ou 30 dias)
|
||||
if (dias !== 15 && dias !== 30) {
|
||||
toast.error('Para seu regime, cada período deve ter exatamente 15 ou 30 dias');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se já tem 2 períodos
|
||||
if (periodosFerias.length >= 2) {
|
||||
toast.error('Máximo de 2 períodos permitidos para seu regime');
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se o total não excede 30 dias
|
||||
const novoTotal = totalDiasSelecionados + dias;
|
||||
if (novoTotal > 30) {
|
||||
toast.error(`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar se o total não excede o saldo disponível
|
||||
const novoTotal = totalDiasSelecionados + dias;
|
||||
if (saldo && novoTotal > saldo.diasDisponiveis) {
|
||||
toast.error(`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`);
|
||||
return;
|
||||
}
|
||||
|
||||
periodosFerias = [
|
||||
...periodosFerias,
|
||||
{
|
||||
dataInicio: dataInicioPeriodo,
|
||||
dataFim: dataFimPeriodo,
|
||||
dias
|
||||
}
|
||||
];
|
||||
|
||||
toast.success(`Período de ${dias} dias adicionado! ✅`);
|
||||
|
||||
// Limpar campos
|
||||
dataInicioPeriodo = '';
|
||||
dataFimPeriodo = '';
|
||||
}
|
||||
|
||||
// Função para remover período
|
||||
function removerPeriodo(index: number) {
|
||||
const removido = periodosFerias[index];
|
||||
periodosFerias = periodosFerias.filter((_, i) => i !== index);
|
||||
toast.info(`Período de ${removido.dias} dias removido`);
|
||||
}
|
||||
|
||||
// Funções
|
||||
function proximoPasso() {
|
||||
@@ -74,7 +170,7 @@
|
||||
}
|
||||
|
||||
if (passoAtual === 2 && periodosFerias.length === 0) {
|
||||
toast.error('Selecione pelo menos 1 período de férias');
|
||||
toast.error('Adicione pelo menos 1 período de férias');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -124,27 +220,20 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handlePeriodoAdicionado(periodo: { dataInicio: string; dataFim: string; dias: number }) {
|
||||
periodosFerias = [...periodosFerias, periodo];
|
||||
toast.success(`Período de ${periodo.dias} dias adicionado! ✅`);
|
||||
}
|
||||
|
||||
function handlePeriodoRemovido(index: number) {
|
||||
const removido = periodosFerias[index];
|
||||
periodosFerias = periodosFerias.filter((_, i) => i !== index);
|
||||
toast.info(`Período de ${removido.dias} dias removido`);
|
||||
}
|
||||
// Calcular dias do período atual
|
||||
const diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
|
||||
</script>
|
||||
|
||||
<div class="wizard-ferias-container">
|
||||
<!-- Progress Bar -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="relative flex items-start">
|
||||
{#each Array(totalPassos) as _, i (i)}
|
||||
<div class="flex flex-1 items-center">
|
||||
{@const labels = ['Ano & Saldo', 'Períodos', 'Confirmação']}
|
||||
<div class="relative z-10 flex flex-1 flex-col items-center">
|
||||
<!-- Círculo do passo -->
|
||||
<div
|
||||
class="relative flex h-12 w-12 items-center justify-center rounded-full font-bold transition-all duration-300"
|
||||
class="relative z-20 flex h-12 w-12 items-center justify-center rounded-full font-bold transition-all duration-300"
|
||||
class:bg-primary={passoAtual > i + 1}
|
||||
class:text-white={passoAtual > i + 1}
|
||||
class:border-4={passoAtual === i + 1}
|
||||
@@ -173,10 +262,16 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Label do passo -->
|
||||
<p class="mt-3 text-center text-sm font-semibold" class:text-primary={passoAtual === i + 1}>
|
||||
{labels[i]}
|
||||
</p>
|
||||
|
||||
<!-- Linha conectora -->
|
||||
{#if i < totalPassos - 1}
|
||||
<div
|
||||
class="mx-2 h-1 flex-1 transition-all duration-300"
|
||||
class="absolute left-1/2 top-6 z-10 h-1 transition-all duration-300"
|
||||
style="width: calc(100% - 1.5rem); margin-left: calc(50% + 0.75rem);"
|
||||
class:bg-primary={passoAtual > i + 1}
|
||||
class:bg-base-300={passoAtual <= i + 1}
|
||||
></div>
|
||||
@@ -184,19 +279,6 @@
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Labels dos passos -->
|
||||
<div class="mt-4 flex justify-between px-1">
|
||||
<div class="flex-1 text-center">
|
||||
<p class="text-sm font-semibold" class:text-primary={passoAtual === 1}>Ano & Saldo</p>
|
||||
</div>
|
||||
<div class="flex-1 text-center">
|
||||
<p class="text-sm font-semibold" class:text-primary={passoAtual === 2}>Períodos</p>
|
||||
</div>
|
||||
<div class="flex-1 text-center">
|
||||
<p class="text-sm font-semibold" class:text-primary={passoAtual === 3}>Confirmação</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo dos Passos -->
|
||||
@@ -216,8 +298,12 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg transition-all duration-300 hover:scale-105"
|
||||
class:btn-primary={anoSelecionado === ano}
|
||||
class:btn-outline={anoSelecionado !== ano}
|
||||
style:border-color={anoSelecionado === ano ? '#f97316' : undefined}
|
||||
style:border-width={anoSelecionado === ano ? '2px' : undefined}
|
||||
style:color={anoSelecionado === ano ? '#000000' : undefined}
|
||||
style:background-color={anoSelecionado === ano ? 'transparent' : undefined}
|
||||
style:box-shadow={anoSelecionado === ano ? '0 0 10px rgba(249, 115, 22, 0.3)' : undefined}
|
||||
onclick={() => (anoSelecionado = ano)}
|
||||
>
|
||||
{ano}
|
||||
@@ -322,9 +408,14 @@
|
||||
<div>
|
||||
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
|
||||
<p class="text-sm">
|
||||
Período aquisitivo: {new Date(saldo.dataInicio).toLocaleDateString('pt-BR')}
|
||||
a {new Date(saldo.dataFim).toLocaleDateString('pt-BR')}
|
||||
Período aquisitivo: {formatarDataString(saldo.dataInicio)}
|
||||
a {formatarDataString(saldo.dataFim)}
|
||||
</p>
|
||||
{#if ehEstatutarioPEOuMunicipal}
|
||||
<p class="mt-2 text-sm font-semibold">
|
||||
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -401,18 +492,131 @@
|
||||
{totalDiasSelecionados} dias | <strong>Restante:</strong>
|
||||
{(saldo?.diasDisponiveis || 0) - totalDiasSelecionados} dias
|
||||
</p>
|
||||
{#if ehEstatutarioPEOuMunicipal}
|
||||
<p class="mt-2 text-sm font-semibold">
|
||||
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30 dias.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calendário -->
|
||||
<CalendarioFerias
|
||||
periodosExistentes={periodosFerias}
|
||||
onPeriodoAdicionado={handlePeriodoAdicionado}
|
||||
onPeriodoRemovido={handlePeriodoRemovido}
|
||||
{maxPeriodos}
|
||||
{minDiasPorPeriodo}
|
||||
modoVisualizacao="month"
|
||||
></CalendarioFerias>
|
||||
<!-- Formulário para adicionar período -->
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Adicionar Período</h3>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={dataInicioPeriodo}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Data Fim</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={dataFimPeriodo}
|
||||
min={dataInicioPeriodo || undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Dias</span>
|
||||
</label>
|
||||
<div class="input input-bordered flex items-center">
|
||||
<span class="font-bold text-primary">{diasPeriodoAtual}</span>
|
||||
<span class="ml-2 text-sm opacity-70">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary gap-2"
|
||||
onclick={adicionarPeriodo}
|
||||
disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Adicionar Período
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de períodos adicionados -->
|
||||
{#if periodosFerias.length > 0}
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
|
||||
<div class="space-y-3">
|
||||
{#each periodosFerias as periodo, index (index)}
|
||||
<div class="bg-base-200 flex items-center gap-4 rounded-lg p-4">
|
||||
<div
|
||||
class="badge badge-lg badge-primary flex h-12 w-12 items-center justify-center font-bold text-white"
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">
|
||||
{formatarDataString(periodo.dataInicio)}
|
||||
até
|
||||
{formatarDataString(periodo.dataFim)}
|
||||
</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
{periodo.dias} dias corridos
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-sm gap-2"
|
||||
onclick={() => removerPeriodo(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Validações -->
|
||||
{#if validacao && periodosFerias.length > 0}
|
||||
@@ -529,17 +733,9 @@
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="font-semibold">
|
||||
{new Date(periodo.dataInicio).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
{formatarDataString(periodo.dataInicio)}
|
||||
até
|
||||
{new Date(periodo.dataFim).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
{formatarDataString(periodo.dataFim)}
|
||||
</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
{periodo.dias} dias corridos
|
||||
@@ -589,7 +785,7 @@
|
||||
Voltar
|
||||
</button>
|
||||
{:else if onCancelar}
|
||||
<button type="button" class="btn btn-ghost btn-lg" onclick={onCancelar}> Cancelar </button>
|
||||
<button type="button" class="btn btn-lg" onclick={onCancelar}> Cancelar </button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
585
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
585
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
@@ -0,0 +1,585 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import jsPDF from 'jspdf';
|
||||
import { Printer, X, User, Clock, CheckCircle2, XCircle, Calendar, MapPin } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
registroId: Id<'registrosPonto'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { registroId, onClose }: Props = $props();
|
||||
|
||||
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||
|
||||
let gerando = $state(false);
|
||||
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Função para calcular a posição baseada no relógio sincronizado
|
||||
function calcularPosicaoModal() {
|
||||
// Procurar pelo elemento do relógio sincronizado
|
||||
const relogioRef = document.getElementById('relogio-sincronizado-ref');
|
||||
|
||||
if (relogioRef) {
|
||||
const rect = relogioRef.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Posicionar o modal na mesma posição do relógio sincronizado
|
||||
// Centralizado horizontalmente no card do relógio
|
||||
const left = rect.left + (rect.width / 2);
|
||||
// Posicionar abaixo do card do relógio com um pequeno espaçamento
|
||||
const top = rect.bottom + 20;
|
||||
|
||||
return {
|
||||
top: top,
|
||||
left: left
|
||||
};
|
||||
}
|
||||
|
||||
// Se não encontrar, usar posição padrão (centro da tela)
|
||||
return null;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// 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);
|
||||
};
|
||||
});
|
||||
|
||||
// Função para obter estilo do modal baseado na posição calculada
|
||||
function getModalStyle() {
|
||||
if (modalPosition) {
|
||||
// Garantir que o modal não saia da viewport
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const modalWidth = 700; // Aproximadamente max-w-2xl
|
||||
const modalHeight = Math.min(viewportHeight * 0.9, 600);
|
||||
|
||||
let left = modalPosition.left;
|
||||
let top = modalPosition.top;
|
||||
|
||||
// Ajustar se o modal sair da viewport à direita
|
||||
if (left + (modalWidth / 2) > viewportWidth - 20) {
|
||||
left = viewportWidth - (modalWidth / 2) - 20;
|
||||
}
|
||||
// Ajustar se o modal sair da viewport à esquerda
|
||||
if (left - (modalWidth / 2) < 20) {
|
||||
left = (modalWidth / 2) + 20;
|
||||
}
|
||||
// Ajustar se o modal sair da viewport abaixo
|
||||
if (top + modalHeight > viewportHeight - 20) {
|
||||
top = viewportHeight - modalHeight - 20;
|
||||
}
|
||||
// Ajustar se o modal sair da viewport acima
|
||||
if (top < 20) {
|
||||
top = 20;
|
||||
}
|
||||
|
||||
// Usar transform para centralizar horizontalmente baseado no left calculado
|
||||
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
|
||||
}
|
||||
// Se não houver posição calculada, centralizar na tela
|
||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
|
||||
}
|
||||
|
||||
async function gerarPDF() {
|
||||
if (!registroQuery?.data) return;
|
||||
|
||||
gerando = true;
|
||||
|
||||
try {
|
||||
const registro = registroQuery.data;
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Logo
|
||||
let yPosition = 20;
|
||||
try {
|
||||
const logoImg = new Image();
|
||||
logoImg.src = logoGovPE;
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
logoImg.onload = () => resolve();
|
||||
logoImg.onerror = () => reject();
|
||||
setTimeout(() => reject(), 3000);
|
||||
});
|
||||
|
||||
const logoWidth = 25;
|
||||
const aspectRatio = logoImg.height / logoImg.width;
|
||||
const logoHeight = logoWidth * aspectRatio;
|
||||
|
||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||
yPosition = Math.max(20, 10 + logoHeight / 2);
|
||||
} catch (err) {
|
||||
console.warn('Não foi possível carregar a logo:', err);
|
||||
}
|
||||
|
||||
// Cabeçalho
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(41, 128, 185);
|
||||
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
||||
|
||||
yPosition += 15;
|
||||
|
||||
// Informações do Funcionário
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
|
||||
if (registro.funcionario) {
|
||||
if (registro.funcionario.matricula) {
|
||||
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
if (registro.funcionario.descricaoCargo) {
|
||||
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
}
|
||||
if (registro.funcionario.simbolo) {
|
||||
doc.text(
|
||||
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
|
||||
15,
|
||||
yPosition
|
||||
);
|
||||
yPosition += 6;
|
||||
}
|
||||
}
|
||||
|
||||
yPosition += 5;
|
||||
|
||||
// Informações do Registro
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
|
||||
const config = configQuery?.data;
|
||||
const tipoLabel = config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
nomeSaida: config.nomeSaida,
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo);
|
||||
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
|
||||
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
||||
yPosition += 6;
|
||||
|
||||
doc.text(
|
||||
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
||||
15,
|
||||
yPosition
|
||||
);
|
||||
yPosition += 10;
|
||||
|
||||
// Imagem capturada (se disponível)
|
||||
if (registro.imagemUrl) {
|
||||
yPosition += 10;
|
||||
// Verificar se precisa de nova página
|
||||
if (yPosition > 200) {
|
||||
doc.addPage();
|
||||
yPosition = 20;
|
||||
}
|
||||
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
||||
doc.setFont('helvetica', 'normal');
|
||||
yPosition += 10;
|
||||
|
||||
try {
|
||||
// Carregar imagem usando fetch para evitar problemas de CORS
|
||||
const response = await fetch(registro.imagemUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao carregar imagem');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const reader = new FileReader();
|
||||
|
||||
// Converter blob para base64
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Erro ao converter imagem'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
// Criar elemento de imagem para obter dimensões
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
||||
img.src = base64;
|
||||
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
||||
});
|
||||
|
||||
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
||||
const maxWidth = 80;
|
||||
const maxHeight = 60;
|
||||
let imgWidth = img.width;
|
||||
let imgHeight = img.height;
|
||||
const aspectRatio = imgWidth / imgHeight;
|
||||
|
||||
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
||||
if (aspectRatio > 1) {
|
||||
// Imagem horizontal
|
||||
imgWidth = maxWidth;
|
||||
imgHeight = maxWidth / aspectRatio;
|
||||
} else {
|
||||
// Imagem vertical
|
||||
imgHeight = maxHeight;
|
||||
imgWidth = maxHeight * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
// Centralizar imagem
|
||||
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
||||
|
||||
// Verificar se cabe na página atual
|
||||
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
||||
doc.addPage();
|
||||
yPosition = 20;
|
||||
}
|
||||
|
||||
// Adicionar imagem ao PDF usando base64
|
||||
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
||||
yPosition += imgHeight + 10;
|
||||
} catch (error) {
|
||||
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
||||
doc.setFontSize(10);
|
||||
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
||||
yPosition += 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Rodapé
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text(
|
||||
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||
doc.internal.pageSize.getWidth() / 2,
|
||||
doc.internal.pageSize.getHeight() - 10,
|
||||
{ align: 'center' }
|
||||
);
|
||||
}
|
||||
|
||||
// Salvar
|
||||
const nomeArquivo = `comprovante-ponto-${registro.data}-${registro.hora}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||
doc.save(nomeArquivo);
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
alert('Erro ao gerar comprovante PDF. Tente novamente.');
|
||||
} finally {
|
||||
gerando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed inset-0 z-50 pointer-events-none"
|
||||
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="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
||||
onclick={onClose}
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="absolute bg-gradient-to-br from-base-100 via-base-100 to-primary/5 rounded-2xl shadow-2xl border-2 border-primary/20 max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
||||
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="flex items-center justify-between px-6 py-5 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-b-2 border-primary/20 flex-shrink-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2.5 bg-primary/20 rounded-xl shadow-lg">
|
||||
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="modal-comprovante-title" class="font-bold text-xl text-base-content">Comprovante de Registro de Ponto</h3>
|
||||
<p class="text-sm text-base-content/70 mt-0.5">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="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
|
||||
{#if registroQuery === undefined}
|
||||
<div class="flex justify-center items-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 bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<User class="h-5 w-5 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-bold text-lg text-base-content">Dados do Funcionário</h4>
|
||||
</div>
|
||||
{#if registro.funcionario}
|
||||
<div class="space-y-3">
|
||||
{#if registro.funcionario.matricula}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Matrícula</span>
|
||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.matricula}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Nome</span>
|
||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.nome}</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if registro.funcionario.descricaoCargo}
|
||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
||||
<div class="flex-1">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Cargo/Função</span>
|
||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.descricaoCargo}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações do Registro -->
|
||||
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20 hover:shadow-xl transition-all">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-primary/20 rounded-lg">
|
||||
<Clock class="h-5 w-5 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-bold text-lg text-base-content">Dados do Registro</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Tipo -->
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tipo</span>
|
||||
<p class="text-lg font-bold text-primary mt-1">
|
||||
{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 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Data e Hora</span>
|
||||
<p class="text-lg font-bold text-base-content mt-1">
|
||||
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">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 rounded-lg p-4 border border-base-300 shadow-sm">
|
||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tolerância</span>
|
||||
<p class="text-lg font-bold text-base-content mt-1">{registro.toleranciaMinutos} minutos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imagem Capturada -->
|
||||
{#if registro.imagemUrl}
|
||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="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="font-bold text-lg text-base-content">Foto Capturada</h4>
|
||||
</div>
|
||||
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/20">
|
||||
<img
|
||||
src={registro.imagemUrl}
|
||||
alt="Foto do registro de ponto"
|
||||
class="max-w-full max-h-[300px] rounded-lg shadow-md object-contain"
|
||||
onerror={(e) => {
|
||||
console.error('Erro ao carregar imagem:', e);
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer fixo com botões -->
|
||||
<div class="flex justify-end gap-3 px-6 py-4 border-t-2 border-primary/20 bg-base-100/50 backdrop-blur-sm flex-shrink-0">
|
||||
<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 hover:shadow-xl transition-all" 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>
|
||||
|
||||
26
apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte
Normal file
26
apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte
Normal file
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { MapPin, AlertCircle, HelpCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
dentroRaioPermitido: boolean | null | undefined;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
let { dentroRaioPermitido, showTooltip = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if dentroRaioPermitido === true}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
|
||||
<MapPin class="h-5 w-5 text-success" strokeWidth={2.5} />
|
||||
</div>
|
||||
{:else if dentroRaioPermitido === false}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
|
||||
<AlertCircle class="h-5 w-5 text-error" strokeWidth={2.5} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
|
||||
<HelpCircle class="h-5 w-5 text-base-content/40" strokeWidth={2.5} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
200
apps/web/src/lib/components/ponto/PrintPontoModal.svelte
Normal file
200
apps/web/src/lib/components/ponto/PrintPontoModal.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, X, Printer } from 'lucide-svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
onClose: () => void;
|
||||
onGenerate: (sections: {
|
||||
dadosFuncionario: boolean;
|
||||
registrosPonto: boolean;
|
||||
saldoDiario: boolean;
|
||||
bancoHoras: boolean;
|
||||
alteracoesGestor: boolean;
|
||||
dispensasRegistro: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onClose, onGenerate }: Props = $props();
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
|
||||
// Seções selecionáveis
|
||||
let sections = $state({
|
||||
dadosFuncionario: true,
|
||||
registrosPonto: true,
|
||||
saldoDiario: true,
|
||||
bancoHoras: true,
|
||||
alteracoesGestor: true,
|
||||
dispensasRegistro: true,
|
||||
});
|
||||
|
||||
function selectAll() {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = true;
|
||||
});
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGenerate() {
|
||||
onGenerate(sections);
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (modalRef) {
|
||||
modalRef.close();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (modalRef) {
|
||||
modalRef.showModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modalRef} class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="font-bold text-2xl">Selecionar Campos para Impressão</h3>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={handleClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
||||
<!-- Seção 1: Dados do Funcionário -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Dados do Funcionário</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.dadosFuncionario}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Nome, matrícula, cargo e informações básicas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 2: Registros de Ponto -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Registros de Ponto</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.registrosPonto}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Data, tipo, horário e status de cada registro
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 3: Saldo Diário -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Saldo Diário</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.saldoDiario}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Saldo em horas e minutos de cada dia (positivo/negativo)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 4: Banco de Horas -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Banco de Horas</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.bancoHoras}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Saldo acumulado do banco de horas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 5: Alterações pelo Gestor -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Alterações pelo Gestor</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.alteracoesGestor}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Edições e ajustes realizados pelo gestor (se houver)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 6: Dispensas de Registro -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Dispensas de Registro</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.dispensasRegistro}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/70 mt-2">
|
||||
Períodos onde o funcionário esteve dispensado de registrar ponto
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick={selectAll}>
|
||||
Selecionar Todos
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline" onclick={deselectAll}>
|
||||
Desmarcar Todos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost" onclick={handleClose}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
|
||||
<Printer class="h-4 w-4" />
|
||||
Gerar PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" onsubmit={handleClose}>
|
||||
<button type="submit">fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
1745
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
1745
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
File diff suppressed because it is too large
Load Diff
153
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
153
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
@@ -0,0 +1,153 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { obterTempoServidor, obterTempoPC } from '$lib/utils/sincronizacaoTempo';
|
||||
import { CheckCircle2, AlertCircle, Clock } from 'lucide-svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let tempoAtual = $state<Date>(new Date());
|
||||
let sincronizado = $state(false);
|
||||
let usandoServidorExterno = $state(false);
|
||||
let offsetSegundos = $state(0);
|
||||
let erro = $state<string | null>(null);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function atualizarTempo() {
|
||||
try {
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
// 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
|
||||
// Quando GMT é 0, usar timestamp UTC puro e deixar toLocaleTimeString() fazer a conversão automática
|
||||
// Quando GMT ≠ 0, aplicar offset configurado ao timestamp
|
||||
let timestampAjustado: number;
|
||||
if (gmtOffset !== 0) {
|
||||
// Aplicar offset configurado
|
||||
timestampAjustado = timestampBase + (gmtOffset * 60 * 60 * 1000);
|
||||
} else {
|
||||
// Quando GMT = 0, manter timestamp UTC puro
|
||||
// O toLocaleTimeString() converterá automaticamente para o timezone local do navegador
|
||||
timestampAjustado = timestampBase;
|
||||
}
|
||||
tempoAtual = new Date(timestampAjustado);
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter tempo:', error);
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
erro = 'Erro ao obter tempo do servidor';
|
||||
}
|
||||
}
|
||||
|
||||
function atualizarRelogio() {
|
||||
// Atualizar segundo a segundo
|
||||
const agora = new Date(tempoAtual.getTime() + 1000);
|
||||
tempoAtual = agora;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await atualizarTempo();
|
||||
// Sincronizar a cada 30 segundos
|
||||
setInterval(atualizarTempo, 30000);
|
||||
// Atualizar display a cada segundo
|
||||
intervalId = setInterval(atualizarRelogio, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
const horaFormatada = $derived.by(() => {
|
||||
return tempoAtual.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
});
|
||||
|
||||
const dataFormatada = $derived.by(() => {
|
||||
return tempoAtual.toLocaleDateString('pt-BR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
<!-- Hora -->
|
||||
<div class="text-5xl font-black font-mono text-primary tracking-tight drop-shadow-sm">
|
||||
{horaFormatada}
|
||||
</div>
|
||||
|
||||
<!-- Data -->
|
||||
<div class="text-base font-semibold text-base-content/80 capitalize">
|
||||
{dataFormatada}
|
||||
</div>
|
||||
|
||||
<!-- Status de Sincronização -->
|
||||
<div class="flex items-center gap-2 px-4 py-2 rounded-full {
|
||||
sincronizado
|
||||
? 'bg-success/20 text-success border border-success/30'
|
||||
: erro
|
||||
? 'bg-warning/20 text-warning border border-warning/30'
|
||||
: 'bg-base-300/50 text-base-content/60 border border-base-300'
|
||||
}">
|
||||
{#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>
|
||||
|
||||
36
apps/web/src/lib/components/ponto/SaldoDiarioBadge.svelte
Normal file
36
apps/web/src/lib/components/ponto/SaldoDiarioBadge.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
saldo?: {
|
||||
saldoMinutos: number;
|
||||
horas: number;
|
||||
minutos: number;
|
||||
positivo: boolean;
|
||||
} | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { 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}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
saldo?: {
|
||||
trabalhadoMinutos: number;
|
||||
esperadoMinutos: number;
|
||||
diferencaMinutos: number;
|
||||
} | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { 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 font-semibold shadow-sm border {
|
||||
isNegativo
|
||||
? 'bg-red-50 border-red-200 text-red-700 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400'
|
||||
: 'bg-green-50 border-green-200 text-green-700 dark:bg-green-900/20 dark:border-green-800 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}
|
||||
|
||||
680
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
680
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
@@ -0,0 +1,680 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Camera, X, Check, AlertCircle } from 'lucide-svelte';
|
||||
import { validarWebcamDisponivel, capturarWebcamComPreview } from '$lib/utils/webcam';
|
||||
|
||||
interface Props {
|
||||
onCapture: (blob: Blob | null) => void;
|
||||
onCancel: () => void;
|
||||
onError?: () => void;
|
||||
autoCapture?: boolean;
|
||||
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
|
||||
}
|
||||
|
||||
let { onCapture, onCancel, onError, autoCapture = false, fotoObrigatoria = false }: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let webcamDisponivel = $state(false);
|
||||
let capturando = $state(false);
|
||||
let erro = $state<string | null>(null);
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let videoReady = $state(false);
|
||||
|
||||
// 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));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamObtido) {
|
||||
throw ultimoErro || new Error('Não foi possível acessar a webcam');
|
||||
}
|
||||
|
||||
// Agora que temos o stream, aguardar o elemento de vídeo estar disponível
|
||||
let tentativas = 0;
|
||||
while (!videoElement && tentativas < 30) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
tentativas++;
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
erro = 'Elemento de vídeo não encontrado';
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
webcamDisponivel = false;
|
||||
if (fotoObrigatoria) {
|
||||
return;
|
||||
}
|
||||
if (autoCapture && onError) {
|
||||
onError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Atribuir stream ao elemento de vídeo
|
||||
if (videoElement && stream) {
|
||||
videoElement.srcObject = stream;
|
||||
|
||||
// Aguardar o vídeo estar pronto com timeout maior
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
// Se o vídeo tem dimensões, considerar pronto mesmo sem eventos
|
||||
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||
videoReady = true;
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Timeout ao carregar vídeo'));
|
||||
}
|
||||
}, 15000); // Aumentar timeout para 15 segundos
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
// Aguardar um pouco mais para garantir que o vídeo esteja realmente visível
|
||||
setTimeout(() => {
|
||||
videoReady = true;
|
||||
resolve();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onLoadedData = () => {
|
||||
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
videoReady = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
videoReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
reject(new Error('Erro ao carregar vídeo'));
|
||||
};
|
||||
|
||||
videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement.addEventListener('loadeddata', onLoadedData);
|
||||
videoElement.addEventListener('playing', onPlaying);
|
||||
videoElement.addEventListener('error', onError);
|
||||
|
||||
// Tentar reproduzir 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 flex-col items-center gap-4 p-4 w-full">
|
||||
{#if !webcamDisponivel && !erro}
|
||||
<div class="text-warning flex items-center gap-2">
|
||||
<Camera class="h-5 w-5" />
|
||||
<span>Verificando webcam...</span>
|
||||
</div>
|
||||
{#if !autoCapture && !fotoObrigatoria}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
</div>
|
||||
{:else if fotoObrigatoria}
|
||||
<div class="alert alert-info max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>A captura de foto é obrigatória para registrar o ponto.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if erro && !webcamDisponivel}
|
||||
<div class="alert alert-error max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{#if fotoObrigatoria}
|
||||
<div class="alert alert-warning max-w-md">
|
||||
<span>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam e tente novamente.</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={async () => {
|
||||
erro = null;
|
||||
webcamDisponivel = false;
|
||||
videoReady = false;
|
||||
// Limpar stream anterior se existir
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
// Tentar reiniciar a webcam
|
||||
try {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
webcamDisponivel = true;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} else {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
stream = null;
|
||||
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
}
|
||||
} else {
|
||||
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao tentar novamente:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||
erro = 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
|
||||
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('DevicesNotFoundError')) {
|
||||
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
} else {
|
||||
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
}
|
||||
}}>Tentar Novamente</button>
|
||||
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
|
||||
</div>
|
||||
{:else if autoCapture}
|
||||
<div class="text-sm text-base-content/70 text-center">
|
||||
O registro será feito sem foto.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if previewUrl}
|
||||
<!-- Preview da imagem capturada -->
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
{#if autoCapture}
|
||||
<!-- Modo automático: mostrar apenas preview sem botões -->
|
||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||
Foto capturada automaticamente...
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
|
||||
/>
|
||||
{#if !autoCapture}
|
||||
<!-- Botões apenas se não for automático -->
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<button class="btn btn-success" onclick={confirmar}>
|
||||
<Check class="h-5 w-5" />
|
||||
Confirmar
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={recapturar}>
|
||||
<Camera class="h-5 w-5" />
|
||||
Recapturar
|
||||
</button>
|
||||
<button class="btn btn-error" onclick={cancelar}>
|
||||
<X class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Webcam ativa -->
|
||||
<div class="flex flex-col items-center gap-4 w-full">
|
||||
{#if autoCapture}
|
||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||
Capturando foto automaticamente...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm text-base-content/70 mb-2 text-center">
|
||||
Posicione-se na frente da câmera e clique em "Capturar Foto"
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative w-full flex justify-center">
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain bg-black {!videoReady ? 'opacity-50' : ''}"
|
||||
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
|
||||
></video>
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
{#if !videoReady && webcamDisponivel}
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/70 rounded-lg gap-2">
|
||||
<span class="loading loading-spinner loading-lg text-white"></span>
|
||||
<span class="text-white text-sm">Carregando câmera...</span>
|
||||
</div>
|
||||
{:else if videoReady && webcamDisponivel}
|
||||
<div class="absolute bottom-2 left-1/2 transform -translate-x-1/2">
|
||||
<div class="badge badge-success gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
Câmera ativa
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if erro}
|
||||
<div class="alert alert-error max-w-md">
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !autoCapture}
|
||||
<!-- Botões sempre visíveis quando não for automático -->
|
||||
<div class="flex gap-2 flex-wrap justify-center">
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={capturar}
|
||||
disabled={capturando || !videoReady || !webcamDisponivel}
|
||||
>
|
||||
{#if capturando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Capturando...
|
||||
{:else}
|
||||
<Camera class="h-5 w-5" />
|
||||
Capturar Foto
|
||||
{/if}
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={cancelar}>
|
||||
<X class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
148
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
148
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { Clock, CheckCircle2, XCircle } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
|
||||
<div class="card-body">
|
||||
<!-- Cabeçalho da Categoria -->
|
||||
<div class="flex items-start gap-6 mb-6">
|
||||
<div class="p-4 bg-blue-500/20 rounded-2xl">
|
||||
<div class="text-blue-600">
|
||||
<Clock class="h-12 w-12" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="card-title text-2xl mb-2 text-blue-600">
|
||||
Gestão de Pontos
|
||||
</h2>
|
||||
<p class="text-base-content/70">Registros de ponto do dia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid de Opções -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
|
||||
>
|
||||
<div
|
||||
class="text-blue-600 group-hover:text-white"
|
||||
>
|
||||
<Clock class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
|
||||
>
|
||||
Gestão de Pontos
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
Visualizar e gerenciar registros de ponto
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/homologacao')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
|
||||
>
|
||||
<div
|
||||
class="text-green-600 group-hover:text-white"
|
||||
>
|
||||
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
|
||||
>
|
||||
Homologação de Registro
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
Edite registros de ponto e ajuste banco de horas
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/dispensa')}
|
||||
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 hover:border-primary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div
|
||||
class="p-3 bg-base-100 rounded-lg group-hover:bg-primary group-hover:text-white transition-colors duration-300"
|
||||
>
|
||||
<div
|
||||
class="text-orange-600 group-hover:text-white"
|
||||
>
|
||||
<XCircle class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-base-content/30 group-hover:text-primary transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-lg font-bold text-base-content mb-2 group-hover:text-primary transition-colors duration-300"
|
||||
>
|
||||
Dispensa de Registro
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70 flex-1">
|
||||
Gerencie períodos de dispensa de registro de ponto
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,502 +1,479 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
|
||||
const alertas = $derived.by(() => {
|
||||
if (!alertasQuery) return [];
|
||||
// O useQuery pode retornar o array diretamente ou em .data
|
||||
if (Array.isArray(alertasQuery)) return alertasQuery;
|
||||
return alertasQuery.data ?? [];
|
||||
});
|
||||
const client = useConvexClient();
|
||||
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
|
||||
const alertas = $derived.by(() => {
|
||||
if (!alertasQuery) return [];
|
||||
// O useQuery pode retornar o array diretamente ou em .data
|
||||
if (Array.isArray(alertasQuery)) return alertasQuery;
|
||||
return alertasQuery.data ?? [];
|
||||
});
|
||||
|
||||
// Estado para novo alerta
|
||||
let editingAlertId = $state<Id<"alertConfigurations"> | null>(null);
|
||||
let metricName = $state("cpuUsage");
|
||||
let threshold = $state(80);
|
||||
let operator = $state<">" | "<" | ">=" | "<=" | "==">(">");
|
||||
let enabled = $state(true);
|
||||
let notifyByEmail = $state(false);
|
||||
let notifyByChat = $state(true);
|
||||
let saving = $state(false);
|
||||
let showForm = $state(false);
|
||||
// Estado para novo alerta
|
||||
let editingAlertId = $state<Id<'alertConfigurations'> | null>(null);
|
||||
let metricName = $state('cpuUsage');
|
||||
let threshold = $state(80);
|
||||
let operator = $state<'>' | '<' | '>=' | '<=' | '=='>('>');
|
||||
let enabled = $state(true);
|
||||
let notifyByEmail = $state(false);
|
||||
let notifyByChat = $state(true);
|
||||
let saving = $state(false);
|
||||
let showForm = $state(false);
|
||||
|
||||
const metricOptions = [
|
||||
{ value: "cpuUsage", label: "Uso de CPU (%)" },
|
||||
{ value: "memoryUsage", label: "Uso de Memória (%)" },
|
||||
{ value: "networkLatency", label: "Latência de Rede (ms)" },
|
||||
{ value: "storageUsed", label: "Armazenamento Usado (%)" },
|
||||
{ value: "usuariosOnline", label: "Usuários Online" },
|
||||
{ value: "mensagensPorMinuto", label: "Mensagens por Minuto" },
|
||||
{ value: "tempoRespostaMedio", label: "Tempo de Resposta (ms)" },
|
||||
{ value: "errosCount", label: "Contagem de Erros" },
|
||||
];
|
||||
const metricOptions = [
|
||||
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
|
||||
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
|
||||
{ value: 'networkLatency', label: 'Latência de Rede (ms)' },
|
||||
{ value: 'storageUsed', label: 'Armazenamento Usado (%)' },
|
||||
{ value: 'usuariosOnline', label: 'Usuários Online' },
|
||||
{ value: 'mensagensPorMinuto', label: 'Mensagens por Minuto' },
|
||||
{ value: 'tempoRespostaMedio', label: 'Tempo de Resposta (ms)' },
|
||||
{ value: 'errosCount', label: 'Contagem de Erros' }
|
||||
];
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: ">", label: "Maior que (>)" },
|
||||
{ value: ">=", label: "Maior ou igual (≥)" },
|
||||
{ value: "<", label: "Menor que (<)" },
|
||||
{ value: "<=", label: "Menor ou igual (≤)" },
|
||||
{ value: "==", label: "Igual a (=)" },
|
||||
];
|
||||
const operatorOptions = [
|
||||
{ value: '>', label: 'Maior que (>)' },
|
||||
{ value: '>=', label: 'Maior ou igual (≥)' },
|
||||
{ value: '<', label: 'Menor que (<)' },
|
||||
{ value: '<=', label: 'Menor ou igual (≤)' },
|
||||
{ value: '==', label: 'Igual a (=)' }
|
||||
];
|
||||
|
||||
function resetForm() {
|
||||
editingAlertId = null;
|
||||
metricName = "cpuUsage";
|
||||
threshold = 80;
|
||||
operator = ">";
|
||||
enabled = true;
|
||||
notifyByEmail = false;
|
||||
notifyByChat = true;
|
||||
showForm = false;
|
||||
}
|
||||
function resetForm() {
|
||||
editingAlertId = null;
|
||||
metricName = 'cpuUsage';
|
||||
threshold = 80;
|
||||
operator = '>';
|
||||
enabled = true;
|
||||
notifyByEmail = false;
|
||||
notifyByChat = true;
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function editAlert(alert: any) {
|
||||
editingAlertId = alert._id;
|
||||
metricName = alert.metricName;
|
||||
threshold = alert.threshold;
|
||||
operator = alert.operator;
|
||||
enabled = alert.enabled;
|
||||
notifyByEmail = alert.notifyByEmail;
|
||||
notifyByChat = alert.notifyByChat;
|
||||
showForm = true;
|
||||
}
|
||||
function editAlert(alert: any) {
|
||||
editingAlertId = alert._id;
|
||||
metricName = alert.metricName;
|
||||
threshold = alert.threshold;
|
||||
operator = alert.operator;
|
||||
enabled = alert.enabled;
|
||||
notifyByEmail = alert.notifyByEmail;
|
||||
notifyByChat = alert.notifyByChat;
|
||||
showForm = true;
|
||||
}
|
||||
|
||||
async function saveAlert() {
|
||||
saving = true;
|
||||
try {
|
||||
await client.mutation(api.monitoramento.configurarAlerta, {
|
||||
alertId: editingAlertId || undefined,
|
||||
metricName,
|
||||
threshold,
|
||||
operator,
|
||||
enabled,
|
||||
notifyByEmail,
|
||||
notifyByChat,
|
||||
});
|
||||
async function saveAlert() {
|
||||
saving = true;
|
||||
try {
|
||||
await client.mutation(api.monitoramento.configurarAlerta, {
|
||||
alertId: editingAlertId || undefined,
|
||||
metricName,
|
||||
threshold,
|
||||
operator,
|
||||
enabled,
|
||||
notifyByEmail,
|
||||
notifyByChat
|
||||
});
|
||||
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error("Erro ao salvar alerta:", error);
|
||||
alert("Erro ao salvar alerta. Tente novamente.");
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar alerta:', error);
|
||||
alert('Erro ao salvar alerta. Tente novamente.');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlert(alertId: Id<"alertConfigurations">) {
|
||||
if (!confirm("Tem certeza que deseja deletar este alerta?")) return;
|
||||
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
|
||||
if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
|
||||
|
||||
try {
|
||||
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
||||
} catch (error) {
|
||||
console.error("Erro ao deletar alerta:", error);
|
||||
alert("Erro ao deletar alerta. Tente novamente.");
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar alerta:', error);
|
||||
alert('Erro ao deletar alerta. Tente novamente.');
|
||||
}
|
||||
}
|
||||
|
||||
function getMetricLabel(metricName: string): string {
|
||||
return (
|
||||
metricOptions.find((m) => m.value === metricName)?.label || metricName
|
||||
);
|
||||
}
|
||||
function getMetricLabel(metricName: string): string {
|
||||
return metricOptions.find((m) => m.value === metricName)?.label || metricName;
|
||||
}
|
||||
|
||||
function getOperatorLabel(op: string): string {
|
||||
return operatorOptions.find((o) => o.value === op)?.label || op;
|
||||
}
|
||||
function getOperatorLabel(op: string): string {
|
||||
return operatorOptions.find((o) => o.value === op)?.label || op;
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl bg-linear-to-br from-base-100 to-base-200">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2"
|
||||
onclick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<div class="modal-box from-base-100 to-base-200 max-w-4xl bg-linear-to-br">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
onclick={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<h3 class="font-bold text-3xl text-primary mb-2">
|
||||
⚙️ Configuração de Alertas
|
||||
</h3>
|
||||
<p class="text-base-content/60 mb-6">
|
||||
Configure alertas personalizados para monitoramento do sistema
|
||||
</p>
|
||||
<h3 class="text-primary mb-2 text-3xl font-bold">⚙️ Configuração de Alertas</h3>
|
||||
<p class="text-base-content/60 mb-6">
|
||||
Configure alertas personalizados para monitoramento do sistema
|
||||
</p>
|
||||
|
||||
<!-- Botão Novo Alerta -->
|
||||
{#if !showForm}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mb-6"
|
||||
onclick={() => (showForm = true)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Alerta
|
||||
</button>
|
||||
{/if}
|
||||
<!-- Botão Novo Alerta -->
|
||||
{#if !showForm}
|
||||
<button type="button" class="btn btn-primary mb-6" onclick={() => (showForm = true)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Novo Alerta
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário de Alerta -->
|
||||
{#if showForm}
|
||||
<div class="card bg-base-100 shadow-xl mb-6 border-2 border-primary/20">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-xl">
|
||||
{editingAlertId ? "Editar Alerta" : "Novo Alerta"}
|
||||
</h4>
|
||||
<!-- Formulário de Alerta -->
|
||||
{#if showForm}
|
||||
<div class="card bg-base-100 border-primary/20 mb-6 border-2 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h4 class="card-title text-xl">
|
||||
{editingAlertId ? 'Editar Alerta' : 'Novo Alerta'}
|
||||
</h4>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
<!-- Métrica -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="metric">
|
||||
<span class="label-text font-semibold">Métrica</span>
|
||||
</label>
|
||||
<select
|
||||
id="metric"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={metricName}
|
||||
>
|
||||
{#each metricOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Métrica -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="metric">
|
||||
<span class="label-text font-semibold">Métrica</span>
|
||||
</label>
|
||||
<select
|
||||
id="metric"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={metricName}
|
||||
>
|
||||
{#each metricOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Operador -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="operator">
|
||||
<span class="label-text font-semibold">Condição</span>
|
||||
</label>
|
||||
<select
|
||||
id="operator"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={operator}
|
||||
>
|
||||
{#each operatorOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<!-- Operador -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="operator">
|
||||
<span class="label-text font-semibold">Condição</span>
|
||||
</label>
|
||||
<select
|
||||
id="operator"
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={operator}
|
||||
>
|
||||
{#each operatorOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Threshold -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="threshold">
|
||||
<span class="label-text font-semibold">Valor Limite</span>
|
||||
</label>
|
||||
<input
|
||||
id="threshold"
|
||||
type="number"
|
||||
class="input input-bordered input-primary"
|
||||
bind:value={threshold}
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
<!-- Threshold -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="threshold">
|
||||
<span class="label-text font-semibold">Valor Limite</span>
|
||||
</label>
|
||||
<input
|
||||
id="threshold"
|
||||
type="number"
|
||||
class="input input-bordered input-primary"
|
||||
bind:value={threshold}
|
||||
min="0"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Ativo -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<span class="label-text font-semibold">Alerta Ativo</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
bind:checked={enabled}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Ativo -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-4">
|
||||
<span class="label-text font-semibold">Alerta Ativo</span>
|
||||
<input type="checkbox" class="toggle toggle-primary" bind:checked={enabled} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notificações -->
|
||||
<div class="divider">Método de Notificação</div>
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={notifyByChat}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 inline mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por Chat
|
||||
</span>
|
||||
</label>
|
||||
<!-- Notificações -->
|
||||
<div class="divider">Método de Notificação</div>
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={notifyByChat}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 inline h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por Chat
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary"
|
||||
bind:checked={notifyByEmail}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 inline mr-2"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por E-mail
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-secondary"
|
||||
bind:checked={notifyByEmail}
|
||||
/>
|
||||
<span class="label-text">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="mr-2 inline h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Notificar por E-mail
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Preview -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">Preview do Alerta:</h4>
|
||||
<p class="text-sm">
|
||||
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
|
||||
<strong>{getOperatorLabel(operator)}</strong> a
|
||||
<strong>{threshold}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Preview -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="font-bold">Preview do Alerta:</h4>
|
||||
<p class="text-sm">
|
||||
Alertar quando <strong>{getMetricLabel(metricName)}</strong> for
|
||||
<strong>{getOperatorLabel(operator)}</strong> a
|
||||
<strong>{threshold}</strong>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="card-actions justify-end mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={resetForm}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={saveAlert}
|
||||
disabled={saving || (!notifyByChat && !notifyByEmail)}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Salvar Alerta
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Botões -->
|
||||
<div class="card-actions mt-4 justify-end">
|
||||
<button type="button" class="btn" onclick={resetForm} disabled={saving}>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={saveAlert}
|
||||
disabled={saving || (!notifyByChat && !notifyByEmail)}
|
||||
>
|
||||
{#if saving}
|
||||
<span class="loading loading-spinner"></span>
|
||||
Salvando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Salvar Alerta
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Lista de Alertas -->
|
||||
<div class="divider">Alertas Configurados</div>
|
||||
<!-- Lista de Alertas -->
|
||||
<div class="divider">Alertas Configurados</div>
|
||||
|
||||
{#if alertas.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Métrica</th>
|
||||
<th>Condição</th>
|
||||
<th>Status</th>
|
||||
<th>Notificações</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each alertas as alerta}
|
||||
<tr class={!alerta.enabled ? "opacity-50" : ""}>
|
||||
<td>
|
||||
<div class="font-semibold">
|
||||
{getMetricLabel(alerta.metricName)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-outline">
|
||||
{getOperatorLabel(alerta.operator)}
|
||||
{alerta.threshold}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if alerta.enabled}
|
||||
<div class="badge badge-success gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Ativo
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-ghost gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Inativo
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
{#if alerta.notifyByChat}
|
||||
<div class="badge badge-primary badge-sm">Chat</div>
|
||||
{/if}
|
||||
{#if alerta.notifyByEmail}
|
||||
<div class="badge badge-secondary badge-sm">Email</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => editAlert(alerta)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => deleteAlert(alerta._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span
|
||||
>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
{#if alertas.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Métrica</th>
|
||||
<th>Condição</th>
|
||||
<th>Status</th>
|
||||
<th>Notificações</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each alertas as alerta}
|
||||
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
|
||||
<td>
|
||||
<div class="font-semibold">
|
||||
{getMetricLabel(alerta.metricName)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="badge badge-outline">
|
||||
{getOperatorLabel(alerta.operator)}
|
||||
{alerta.threshold}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{#if alerta.enabled}
|
||||
<div class="badge badge-success gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Ativo
|
||||
</div>
|
||||
{:else}
|
||||
<div class="badge badge-ghost gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Inativo
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-1">
|
||||
{#if alerta.notifyByChat}
|
||||
<div class="badge badge-primary badge-sm">Chat</div>
|
||||
{/if}
|
||||
{#if alerta.notifyByEmail}
|
||||
<div class="badge badge-secondary badge-sm">Email</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-xs" onclick={() => editAlert(alerta)}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs text-error"
|
||||
onclick={() => deleteAlert(alerta._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Nenhum alerta configurado. Clique em "Novo Alerta" para criar um.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={onClose}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-lg" onclick={onClose}>Fechar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={onClose}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
2744
apps/web/src/lib/components/ti/CybersecurityWizcard.svelte
Normal file
2744
apps/web/src/lib/components/ti/CybersecurityWizcard.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -197,7 +197,7 @@
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text(
|
||||
`SGSE - Sistema de Gestão da Secretaria de Esportes | Página ${i} de ${pageCount}`,
|
||||
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||
doc.internal.pageSize.getWidth() / 2,
|
||||
doc.internal.pageSize.getHeight() - 10,
|
||||
{ align: 'center' }
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import AreaChart from './charts/AreaChart.svelte';
|
||||
import DoughnutChart from './charts/DoughnutChart.svelte';
|
||||
import BarChart from './charts/BarChart.svelte';
|
||||
import { obterInformacoesDispositivo } from '$lib/utils/deviceInfo';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -45,6 +46,18 @@
|
||||
serviceWorkerStatus: string;
|
||||
domNodes: number;
|
||||
jsHeapSize: number;
|
||||
// GPS
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
gpsPrecision?: number;
|
||||
gpsConfidence?: number;
|
||||
// Acelerômetro
|
||||
accelerometerX?: number;
|
||||
accelerometerY?: number;
|
||||
accelerometerZ?: number;
|
||||
movementDetected?: boolean;
|
||||
movementMagnitude?: number;
|
||||
sensorAvailable?: boolean;
|
||||
};
|
||||
|
||||
type NetworkInformationEx = {
|
||||
@@ -548,7 +561,8 @@
|
||||
usuariosOnline,
|
||||
tempoRespostaMedio,
|
||||
batteryInfo,
|
||||
indexedDBSize
|
||||
indexedDBSize,
|
||||
deviceInfo
|
||||
] = await Promise.all([
|
||||
estimateCPU(),
|
||||
Promise.resolve(getMemoryUsage()),
|
||||
@@ -557,14 +571,15 @@
|
||||
getUsuariosOnline(),
|
||||
getResponseTime(),
|
||||
getBatteryInfo(),
|
||||
getIndexedDBSize()
|
||||
getIndexedDBSize(),
|
||||
obterInformacoesDispositivo().catch(() => ({}) as Record<string, unknown>) // Capturar erro se falhar
|
||||
]);
|
||||
|
||||
const browserInfo = getBrowserInfo();
|
||||
const networkInfo = getNetworkInfo();
|
||||
const wsInfo = getWebSocketStatus();
|
||||
|
||||
const newMetrics = {
|
||||
const newMetrics: Metrics = {
|
||||
timestamp: Date.now(),
|
||||
cpuUsage,
|
||||
memoryUsage,
|
||||
@@ -594,7 +609,19 @@
|
||||
indexedDBSize,
|
||||
serviceWorkerStatus: getServiceWorkerStatus(),
|
||||
domNodes: getDOMNodeCount(),
|
||||
jsHeapSize: getJSHeapSize()
|
||||
jsHeapSize: getJSHeapSize(),
|
||||
// GPS
|
||||
latitude: deviceInfo.latitude,
|
||||
longitude: deviceInfo.longitude,
|
||||
gpsPrecision: deviceInfo.precisao,
|
||||
gpsConfidence: deviceInfo.confiabilidadeGPS,
|
||||
// Acelerômetro
|
||||
accelerometerX: deviceInfo.acelerometro?.x,
|
||||
accelerometerY: deviceInfo.acelerometro?.y,
|
||||
accelerometerZ: deviceInfo.acelerometro?.z,
|
||||
movementDetected: deviceInfo.acelerometro?.movimentoDetectado,
|
||||
movementMagnitude: deviceInfo.acelerometro?.magnitude,
|
||||
sensorAvailable: deviceInfo.sensorDisponivel
|
||||
};
|
||||
|
||||
// Resetar contadores
|
||||
@@ -1445,6 +1472,181 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção de GPS e Sensores -->
|
||||
<div class="mt-8">
|
||||
<div class="divider">
|
||||
<h2 class="text-primary flex items-center gap-3 text-2xl font-bold">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
</svg>
|
||||
GPS e Sensores
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<!-- Latitude -->
|
||||
{#if metrics.latitude !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-primary/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Latitude</div>
|
||||
<div class="stat-value text-primary text-2xl">{metrics.latitude.toFixed(6)}</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-primary badge-sm">GPS</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Longitude -->
|
||||
{#if metrics.longitude !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-primary/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Longitude</div>
|
||||
<div class="stat-value text-primary text-2xl">{metrics.longitude.toFixed(6)}</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-primary badge-sm">GPS</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Precisão GPS -->
|
||||
{#if metrics.gpsPrecision !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-info/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Precisão GPS</div>
|
||||
<div class="stat-value text-info text-2xl">{metrics.gpsPrecision.toFixed(2)}m</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-info badge-sm">Precisão</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Confiança GPS -->
|
||||
{#if metrics.gpsConfidence !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-success/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Confiança GPS</div>
|
||||
<div class="stat-value text-success text-2xl">
|
||||
{(metrics.gpsConfidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-success badge-sm">Confiança</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Acelerômetro X -->
|
||||
{#if metrics.accelerometerX !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-warning/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Acelerômetro X</div>
|
||||
<div class="stat-value text-warning text-2xl">{metrics.accelerometerX.toFixed(3)}</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-warning badge-sm">m/s²</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Acelerômetro Y -->
|
||||
{#if metrics.accelerometerY !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-warning/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Acelerômetro Y</div>
|
||||
<div class="stat-value text-warning text-2xl">{metrics.accelerometerY.toFixed(3)}</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-warning badge-sm">m/s²</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Acelerômetro Z -->
|
||||
{#if metrics.accelerometerZ !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-warning/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Acelerômetro Z</div>
|
||||
<div class="stat-value text-warning text-2xl">{metrics.accelerometerZ.toFixed(3)}</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-warning badge-sm">m/s²</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Magnitude de Movimento -->
|
||||
{#if metrics.movementMagnitude !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-error/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Magnitude de Movimento</div>
|
||||
<div class="stat-value text-error text-2xl">{metrics.movementMagnitude.toFixed(3)}</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div class="badge badge-error badge-sm">m/s²</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Movimento Detectado -->
|
||||
{#if metrics.movementDetected !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-accent/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Movimento Detectado</div>
|
||||
<div class="stat-value text-accent text-3xl">
|
||||
{metrics.movementDetected ? 'Sim' : 'Não'}
|
||||
</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div
|
||||
class="badge {metrics.movementDetected ? 'badge-success' : 'badge-warning'} badge-sm"
|
||||
>
|
||||
{metrics.movementDetected ? 'Ativo' : 'Inativo'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status do Sensor -->
|
||||
{#if metrics.sensorAvailable !== undefined}
|
||||
<div
|
||||
class="stat bg-base-100 border-secondary/10 rounded-2xl border shadow-lg transition-all duration-300 hover:scale-105 hover:shadow-xl"
|
||||
>
|
||||
<div class="stat-title font-semibold">Sensor Disponível</div>
|
||||
<div class="stat-value text-secondary text-3xl">
|
||||
{metrics.sensorAvailable ? 'Sim' : 'Não'}
|
||||
</div>
|
||||
<div class="stat-desc mt-2">
|
||||
<div
|
||||
class="badge {metrics.sensorAvailable ? 'badge-success' : 'badge-ghost'} badge-sm"
|
||||
>
|
||||
{metrics.sensorAvailable ? 'Disponível' : 'Indisponível'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção de Gráficos Interativos -->
|
||||
{#if metricsHistory.length > 5}
|
||||
<div class="mt-8">
|
||||
@@ -1608,14 +1810,16 @@
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">
|
||||
Monitoramento Ativo (Modo Local) - 23 Métricas Técnicas + 4 Gráficos Interativos
|
||||
Monitoramento Ativo (Modo Local) - Métricas Técnicas + GPS + Sensores + 4 Gráficos Interativos
|
||||
</h3>
|
||||
<div class="text-xs">
|
||||
<strong>Sistema:</strong> CPU, RAM, Latência, Storage |
|
||||
<strong>Aplicação:</strong> Usuários, Mensagens, Tempo Resposta, Erros, HTTP Requests |
|
||||
<strong>Performance:</strong> FPS, Conexão, Navegador, Tela |
|
||||
<strong>Hardware:</strong> RAM Física, Núcleos CPU, Cache, Bateria, Uptime |
|
||||
<strong>Avançado:</strong> WebSocket, IndexedDB, Service Worker, DOM Nodes, JS Heap
|
||||
<strong>Avançado:</strong> WebSocket, IndexedDB, Service Worker, DOM Nodes, JS Heap |
|
||||
<strong>GPS:</strong> Latitude, Longitude, Precisão, Confiança |
|
||||
<strong>Sensores:</strong> Acelerômetro (X, Y, Z), Magnitude, Movimento Detectado
|
||||
<br />
|
||||
<strong>Gráficos:</strong> Linha (Recursos), Área (Atividade), Donut (Distribuição),
|
||||
Barras (Métricas)
|
||||
|
||||
372
apps/web/src/lib/components/ti/charts/BarChart3D.svelte
Normal file
372
apps/web/src/lib/components/ti/charts/BarChart3D.svelte
Normal file
@@ -0,0 +1,372 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor?: string | string[];
|
||||
borderColor?: string | string[];
|
||||
borderWidth?: number;
|
||||
}>;
|
||||
};
|
||||
title?: string;
|
||||
height?: number;
|
||||
stacked?: boolean;
|
||||
};
|
||||
|
||||
let { data, title = '', height = 400, stacked = false }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart | null = null;
|
||||
|
||||
// Função para clarear cor
|
||||
function lightenColor(color: string, percent: number): string {
|
||||
const num = parseInt(color.replace('#', ''), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = Math.min(255, (num >> 16) + amt);
|
||||
const G = Math.min(255, ((num >> 8) & 0x00ff) + amt);
|
||||
const B = Math.min(255, (num & 0x0000ff) + amt);
|
||||
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
// Função para escurecer cor
|
||||
function darkenColor(color: string, percent: number): string {
|
||||
const num = parseInt(color.replace('#', ''), 16);
|
||||
const amt = Math.round(2.55 * percent);
|
||||
const R = Math.max(0, (num >> 16) - amt);
|
||||
const G = Math.max(0, ((num >> 8) & 0x00ff) - amt);
|
||||
const B = Math.max(0, (num & 0x0000ff) - amt);
|
||||
return `#${(0x1000000 + R * 0x10000 + G * 0x100 + B).toString(16).slice(1)}`;
|
||||
}
|
||||
|
||||
// Criar gradientes 3D para cada cor
|
||||
function create3DGradientColors(colors: string[]): string[] {
|
||||
// Retornar cores com sombra 3D aplicada (usando cores mais claras e escuras)
|
||||
return colors.map((color) => {
|
||||
// Criar gradiente simulando 3D usando múltiplas cores
|
||||
return color; // Por enquanto retornar cor original, gradiente será aplicado via plugin
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Preparar dados com cores 3D
|
||||
const processedData = {
|
||||
labels: data.labels,
|
||||
datasets: data.datasets.map((dataset) => {
|
||||
// Processar cores de background
|
||||
let backgroundColor: string[];
|
||||
if (Array.isArray(dataset.backgroundColor)) {
|
||||
backgroundColor = dataset.backgroundColor;
|
||||
} else if (dataset.backgroundColor) {
|
||||
backgroundColor = data.labels.map(() => dataset.backgroundColor as string);
|
||||
} else {
|
||||
backgroundColor = data.labels.map(() => '#3b82f6');
|
||||
}
|
||||
|
||||
// Processar cores de borda
|
||||
let borderColor: string[];
|
||||
if (Array.isArray(dataset.borderColor)) {
|
||||
borderColor = dataset.borderColor;
|
||||
} else if (dataset.borderColor) {
|
||||
borderColor = data.labels.map(() => dataset.borderColor as string);
|
||||
} else {
|
||||
borderColor = backgroundColor.map((color) => darkenColor(color, 15));
|
||||
}
|
||||
|
||||
return {
|
||||
...dataset,
|
||||
backgroundColor,
|
||||
borderColor,
|
||||
borderWidth: dataset.borderWidth || 2,
|
||||
borderRadius: {
|
||||
topLeft: 10,
|
||||
topRight: 10,
|
||||
bottomLeft: 10,
|
||||
bottomRight: 10
|
||||
},
|
||||
borderSkipped: false,
|
||||
barThickness: 'flex',
|
||||
maxBarThickness: 60
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: processedData,
|
||||
options: {
|
||||
indexAxis: 'x',
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: {
|
||||
padding: {
|
||||
top: 15,
|
||||
right: 15,
|
||||
bottom: 15,
|
||||
left: 15
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#374151', // Cinza escuro para melhor legibilidade
|
||||
font: {
|
||||
size: 13,
|
||||
family: "'Inter', sans-serif",
|
||||
weight: '600'
|
||||
},
|
||||
usePointStyle: false,
|
||||
padding: 18,
|
||||
boxWidth: 18,
|
||||
boxHeight: 14,
|
||||
generateLabels: function (chart: any) {
|
||||
const datasets = chart.data.datasets;
|
||||
return datasets.map((dataset: any, datasetIndex: number) => {
|
||||
// Priorizar cor da legenda se disponível, senão usar a cor do background
|
||||
let backgroundColor: string;
|
||||
|
||||
if (dataset.legendColor) {
|
||||
// Se há uma cor específica para a legenda, usar ela
|
||||
backgroundColor = dataset.legendColor;
|
||||
} else if (Array.isArray(dataset.backgroundColor)) {
|
||||
// Se todas as cores são iguais, usar a primeira
|
||||
const firstColor = dataset.backgroundColor[0];
|
||||
if (dataset.backgroundColor.every((c: string) => c === firstColor)) {
|
||||
backgroundColor = firstColor;
|
||||
} else {
|
||||
// Para múltiplas cores diferentes, usar a primeira como representativa
|
||||
backgroundColor = firstColor;
|
||||
}
|
||||
} else {
|
||||
backgroundColor = dataset.backgroundColor || '#3b82f6';
|
||||
}
|
||||
|
||||
// Cor da borda para a legenda
|
||||
let borderColor: string;
|
||||
if (Array.isArray(dataset.borderColor)) {
|
||||
borderColor = dataset.borderColor[0] || backgroundColor;
|
||||
} else {
|
||||
borderColor = dataset.borderColor || backgroundColor;
|
||||
}
|
||||
|
||||
return {
|
||||
text: dataset.label || `Dataset ${datasetIndex + 1}`,
|
||||
fillStyle: backgroundColor,
|
||||
strokeStyle: borderColor,
|
||||
lineWidth: dataset.borderWidth || 2,
|
||||
hidden: !chart.isDatasetVisible(datasetIndex),
|
||||
index: datasetIndex
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
title: {
|
||||
display: !!title,
|
||||
text: title,
|
||||
color: '#1f2937',
|
||||
font: {
|
||||
size: 18,
|
||||
weight: 'bold',
|
||||
family: "'Inter', sans-serif"
|
||||
},
|
||||
padding: {
|
||||
top: 10,
|
||||
bottom: 25
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: '#3b82f6',
|
||||
borderWidth: 2,
|
||||
padding: 14,
|
||||
cornerRadius: 10,
|
||||
displayColors: true,
|
||||
titleFont: {
|
||||
size: 14,
|
||||
weight: 'bold',
|
||||
family: "'Inter', sans-serif"
|
||||
},
|
||||
bodyFont: {
|
||||
size: 13,
|
||||
family: "'Inter', sans-serif"
|
||||
},
|
||||
callbacks: {
|
||||
label: function (context: any) {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null && context.parsed.y !== undefined) {
|
||||
label += context.parsed.y.toLocaleString('pt-BR');
|
||||
// Verificar se é número de solicitações ou dias
|
||||
if (label.includes('Solicitações')) {
|
||||
label += ' solicitação(ões)';
|
||||
} else {
|
||||
label += ' dia(s)';
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: stacked,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
font: {
|
||||
size: 12,
|
||||
family: "'Inter', sans-serif",
|
||||
weight: '500'
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 0
|
||||
},
|
||||
border: {
|
||||
display: true,
|
||||
color: '#e5e7eb',
|
||||
width: 2
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: stacked,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.06)',
|
||||
lineWidth: 1,
|
||||
drawBorder: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#6b7280',
|
||||
font: {
|
||||
size: 11,
|
||||
family: "'Inter', sans-serif",
|
||||
weight: '500'
|
||||
},
|
||||
callback: function (value: any) {
|
||||
if (typeof value === 'number') {
|
||||
return value.toLocaleString('pt-BR');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
},
|
||||
border: {
|
||||
display: true,
|
||||
color: '#e5e7eb',
|
||||
width: 2
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 1200,
|
||||
easing: 'easeInOutQuart'
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
// Plugin customizado para aplicar gradiente 3D
|
||||
onHover: (event: any, activeElements: any[]) => {
|
||||
if (event.native) {
|
||||
const target = event.native.target as HTMLElement;
|
||||
if (activeElements.length > 0) {
|
||||
target.style.cursor = 'pointer';
|
||||
} else {
|
||||
target.style.cursor = 'default';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
{
|
||||
id: 'gradient3D',
|
||||
beforeDraw: (chart: any) => {
|
||||
const ctx = chart.ctx;
|
||||
const chartArea = chart.chartArea;
|
||||
|
||||
chart.data.datasets.forEach((dataset: any, datasetIndex: number) => {
|
||||
const meta = chart.getDatasetMeta(datasetIndex);
|
||||
if (!meta || !meta.data) return;
|
||||
|
||||
meta.data.forEach((bar: any, index: number) => {
|
||||
if (!bar || bar.hidden) return;
|
||||
|
||||
const backgroundColor = Array.isArray(dataset.backgroundColor)
|
||||
? dataset.backgroundColor[index]
|
||||
: dataset.backgroundColor;
|
||||
|
||||
if (!backgroundColor || typeof backgroundColor !== 'string') return;
|
||||
|
||||
// Criar gradiente 3D para a barra
|
||||
const gradient = ctx.createLinearGradient(
|
||||
bar.x - bar.width / 2,
|
||||
bar.y,
|
||||
bar.x + bar.width / 2,
|
||||
bar.base
|
||||
);
|
||||
|
||||
// Aplicar gradiente com efeito 3D
|
||||
const lightColor = lightenColor(backgroundColor, 25);
|
||||
const darkColor = darkenColor(backgroundColor, 15);
|
||||
|
||||
gradient.addColorStop(0, lightColor);
|
||||
gradient.addColorStop(0.3, backgroundColor);
|
||||
gradient.addColorStop(0.7, backgroundColor);
|
||||
gradient.addColorStop(1, darkColor);
|
||||
|
||||
// Redesenhar a barra com gradiente
|
||||
ctx.save();
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(
|
||||
bar.x - bar.width / 2,
|
||||
bar.y,
|
||||
bar.width,
|
||||
bar.base - bar.y
|
||||
);
|
||||
ctx.restore();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
]
|
||||
} as any);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart && data) {
|
||||
// Atualizar dados do gráfico
|
||||
chart.data = data;
|
||||
chart.update('active');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="height: {height}px; position: relative;">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
@@ -30,15 +30,25 @@ export function useConvexWithAuth() {
|
||||
const clientWithAuth = client as ConvexClientWithAuth;
|
||||
|
||||
// Configurar token se disponível
|
||||
if (clientWithAuth && typeof clientWithAuth.setAuth === "function" && token) {
|
||||
if (clientWithAuth && token) {
|
||||
try {
|
||||
clientWithAuth.setAuth(token);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("✅ [useConvexWithAuth] Token configurado:", token.substring(0, 20) + "...");
|
||||
// Tentar setAuth se disponível
|
||||
if (typeof clientWithAuth.setAuth === "function") {
|
||||
clientWithAuth.setAuth(token);
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("✅ [useConvexWithAuth] Token configurado via setAuth:", token.substring(0, 20) + "...");
|
||||
}
|
||||
} else {
|
||||
// Se setAuth não estiver disponível, o token deve ser passado via createSvelteAuthClient
|
||||
if (import.meta.env.DEV) {
|
||||
console.log("ℹ️ [useConvexWithAuth] Token disponível, autenticação gerenciada por createSvelteAuthClient");
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("⚠️ [useConvexWithAuth] Erro ao configurar token:", e);
|
||||
}
|
||||
} else if (!token && import.meta.env.DEV) {
|
||||
console.warn("⚠️ [useConvexWithAuth] Token não disponível");
|
||||
}
|
||||
|
||||
return client;
|
||||
|
||||
323
apps/web/src/lib/stores/callStore.ts
Normal file
323
apps/web/src/lib/stores/callStore.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
/**
|
||||
* Store para gerenciar estado das chamadas de áudio/vídeo
|
||||
*/
|
||||
|
||||
import { writable, derived, get } from 'svelte/store';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import type { JitsiApi } from '$lib/types/jitsi';
|
||||
|
||||
export interface ParticipanteChamada {
|
||||
usuarioId: Id<'usuarios'>;
|
||||
nome: string;
|
||||
avatar?: string;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
forcadoPeloAnfitriao?: boolean;
|
||||
participantId?: string; // ID do participante no Jitsi
|
||||
}
|
||||
|
||||
export interface EstadoChamada {
|
||||
chamadaId: Id<'chamadas'> | null;
|
||||
conversaId: Id<'conversas'> | null;
|
||||
tipo: 'audio' | 'video' | null;
|
||||
roomName: string | null;
|
||||
estaConectado: boolean;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
gravando: boolean;
|
||||
ehAnfitriao: boolean;
|
||||
participantes: ParticipanteChamada[];
|
||||
duracaoSegundos: number;
|
||||
dispositivos: {
|
||||
microphoneId: string | null;
|
||||
cameraId: string | null;
|
||||
speakerId: string | null;
|
||||
};
|
||||
jitsiApi: JitsiApi;
|
||||
streamLocal: MediaStream | null;
|
||||
}
|
||||
|
||||
const estadoInicial: EstadoChamada = {
|
||||
chamadaId: null,
|
||||
conversaId: null,
|
||||
tipo: null,
|
||||
roomName: null,
|
||||
estaConectado: false,
|
||||
audioHabilitado: true,
|
||||
videoHabilitado: false,
|
||||
gravando: false,
|
||||
ehAnfitriao: false,
|
||||
participantes: [],
|
||||
duracaoSegundos: 0,
|
||||
dispositivos: {
|
||||
microphoneId: null,
|
||||
cameraId: null,
|
||||
speakerId: null
|
||||
},
|
||||
jitsiApi: null,
|
||||
streamLocal: null
|
||||
};
|
||||
|
||||
// Store principal do estado da chamada
|
||||
export const callState = writable<EstadoChamada>(estadoInicial);
|
||||
|
||||
// Store para indicar se há chamada ativa
|
||||
export const chamadaAtiva = derived(
|
||||
callState,
|
||||
($state) => $state.chamadaId !== null
|
||||
);
|
||||
|
||||
// Store para indicar se está conectado
|
||||
export const estaConectado = derived(
|
||||
callState,
|
||||
($state) => $state.estaConectado
|
||||
);
|
||||
|
||||
// Store para indicar se está gravando
|
||||
export const gravando = derived(
|
||||
callState,
|
||||
($state) => $state.gravando
|
||||
);
|
||||
|
||||
// Funções para atualizar o estado
|
||||
|
||||
/**
|
||||
* Inicializar chamada
|
||||
*/
|
||||
export function inicializarChamada(
|
||||
chamadaId: Id<'chamadas'>,
|
||||
conversaId: Id<'conversas'>,
|
||||
tipo: 'audio' | 'video',
|
||||
roomName: string,
|
||||
ehAnfitriao: boolean,
|
||||
participantes: ParticipanteChamada[]
|
||||
): void {
|
||||
callState.set({
|
||||
...estadoInicial,
|
||||
chamadaId,
|
||||
conversaId,
|
||||
tipo,
|
||||
roomName,
|
||||
ehAnfitriao,
|
||||
participantes,
|
||||
videoHabilitado: tipo === 'video'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalizar chamada e limpar estado
|
||||
*/
|
||||
export function finalizarChamada(): void {
|
||||
const estadoAtual = get(callState);
|
||||
|
||||
// Liberar recursos
|
||||
if (estadoAtual.streamLocal) {
|
||||
estadoAtual.streamLocal.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
callState.set(estadoInicial);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar status de conexão
|
||||
*/
|
||||
export function atualizarStatusConexao(estaConectado: boolean): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
estaConectado
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle áudio
|
||||
*/
|
||||
export function toggleAudio(): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
audioHabilitado: !state.audioHabilitado
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle vídeo
|
||||
*/
|
||||
export function toggleVideo(): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
videoHabilitado: !state.videoHabilitado
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Definir áudio habilitado/desabilitado
|
||||
*/
|
||||
export function setAudioHabilitado(habilitado: boolean): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
audioHabilitado: habilitado
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Definir vídeo habilitado/desabilitado
|
||||
*/
|
||||
export function setVideoHabilitado(habilitado: boolean): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
videoHabilitado: habilitado
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar lista de participantes
|
||||
*/
|
||||
export function atualizarParticipantes(participantes: ParticipanteChamada[]): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
participantes
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adicionar participante
|
||||
*/
|
||||
export function adicionarParticipante(participante: ParticipanteChamada): void {
|
||||
callState.update((state) => {
|
||||
// Verificar se já existe
|
||||
const existe = state.participantes.some(
|
||||
(p) => p.usuarioId === participante.usuarioId
|
||||
);
|
||||
|
||||
if (existe) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
participantes: [...state.participantes, participante]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remover participante
|
||||
*/
|
||||
export function removerParticipante(usuarioId: Id<'usuarios'>): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
participantes: state.participantes.filter(
|
||||
(p) => p.usuarioId !== usuarioId
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar status de áudio/vídeo de participante
|
||||
*/
|
||||
export function atualizarParticipanteMidia(
|
||||
usuarioId: Id<'usuarios'>,
|
||||
audioHabilitado?: boolean,
|
||||
videoHabilitado?: boolean
|
||||
): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
participantes: state.participantes.map((p) =>
|
||||
p.usuarioId === usuarioId
|
||||
? {
|
||||
...p,
|
||||
audioHabilitado: audioHabilitado ?? p.audioHabilitado,
|
||||
videoHabilitado: videoHabilitado ?? p.videoHabilitado
|
||||
}
|
||||
: p
|
||||
)
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Iniciar gravação
|
||||
*/
|
||||
export function iniciarGravacao(): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
gravando: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parar gravação
|
||||
*/
|
||||
export function pararGravacao(): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
gravando: false
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar duração da chamada
|
||||
*/
|
||||
export function atualizarDuracao(segundos: number): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
duracaoSegundos: segundos
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Atualizar dispositivos selecionados
|
||||
*/
|
||||
export function atualizarDispositivos(dispositivos: {
|
||||
microphoneId?: string | null;
|
||||
cameraId?: string | null;
|
||||
speakerId?: string | null;
|
||||
}): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
dispositivos: {
|
||||
...state.dispositivos,
|
||||
...dispositivos
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Definir API Jitsi
|
||||
*/
|
||||
export function setJitsiApi(api: JitsiApi): void {
|
||||
callState.update((state) => ({
|
||||
...state,
|
||||
jitsiApi: api
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Definir stream local
|
||||
*/
|
||||
export function setStreamLocal(stream: MediaStream | null): void {
|
||||
callState.update((state) => {
|
||||
// Parar stream anterior se existir
|
||||
if (state.streamLocal) {
|
||||
state.streamLocal.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
streamLocal: stream
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter estado atual (helper)
|
||||
*/
|
||||
export function obterEstadoAtual(): EstadoChamada {
|
||||
return get(callState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resetar estado (para cleanup)
|
||||
*/
|
||||
export function resetarEstado(): void {
|
||||
finalizarChamada();
|
||||
}
|
||||
|
||||
53
apps/web/src/lib/stores/chamados.ts
Normal file
53
apps/web/src/lib/stores/chamados.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
export type TicketDetalhe = {
|
||||
ticket: Doc<"tickets">;
|
||||
interactions: Doc<"ticketInteractions">[];
|
||||
};
|
||||
|
||||
function createChamadosStore() {
|
||||
const tickets = writable<Array<Doc<"tickets">>>([]);
|
||||
const detalhes = writable<Record<string, TicketDetalhe>>({});
|
||||
const carregando = writable(false);
|
||||
|
||||
function setTickets(lista: Array<Doc<"tickets">>) {
|
||||
tickets.set(lista);
|
||||
}
|
||||
|
||||
function upsertTicket(ticket: Doc<"tickets">) {
|
||||
tickets.update((current) => {
|
||||
const existente = current.findIndex((t) => t._id === ticket._id);
|
||||
if (existente >= 0) {
|
||||
const copia = [...current];
|
||||
copia[existente] = ticket;
|
||||
return copia;
|
||||
}
|
||||
return [ticket, ...current];
|
||||
});
|
||||
}
|
||||
|
||||
function setDetalhe(ticketId: Id<"tickets">, detalhe: TicketDetalhe) {
|
||||
detalhes.update((mapa) => ({
|
||||
...mapa,
|
||||
[ticketId]: detalhe,
|
||||
}));
|
||||
}
|
||||
|
||||
function setCarregando(flag: boolean) {
|
||||
carregando.set(flag);
|
||||
}
|
||||
|
||||
return {
|
||||
tickets,
|
||||
detalhes,
|
||||
carregando,
|
||||
setTickets,
|
||||
upsertTicket,
|
||||
setDetalhe,
|
||||
setCarregando,
|
||||
};
|
||||
}
|
||||
|
||||
export const chamadosStore = createChamadosStore();
|
||||
|
||||
331
apps/web/src/lib/types/jitsi.d.ts
vendored
Normal file
331
apps/web/src/lib/types/jitsi.d.ts
vendored
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Definições de tipo para lib-jitsi-meet
|
||||
* Baseado na documentação oficial do Jitsi Meet
|
||||
*/
|
||||
|
||||
export interface JitsiConnection {
|
||||
connect(): void;
|
||||
disconnect(): void;
|
||||
addEventListener(event: string, handler: (data?: unknown) => void): void;
|
||||
removeEventListener(event: string, handler: (data?: unknown) => void): void;
|
||||
initJitsiConference(roomName: string, options: Record<string, unknown>): JitsiConference;
|
||||
}
|
||||
|
||||
export interface JitsiConference {
|
||||
join(): void;
|
||||
leave(): void;
|
||||
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
removeEventListener(event: string, handler: (...args: unknown[]) => void): void;
|
||||
muteAudio(): void;
|
||||
unmuteAudio(): void;
|
||||
muteVideo(): void;
|
||||
unmuteVideo(): void;
|
||||
getParticipants(): Map<string, JitsiParticipant>;
|
||||
getLocalTracks(): JitsiTrack[];
|
||||
setDisplayName(name: string): void;
|
||||
addTrack(track: JitsiTrack): Promise<void>;
|
||||
removeTrack(track: JitsiTrack): Promise<void>;
|
||||
getLocalVideoTrack(): JitsiTrack | null;
|
||||
getLocalAudioTrack(): JitsiTrack | null;
|
||||
}
|
||||
|
||||
export interface JitsiTrack {
|
||||
getType(): 'audio' | 'video';
|
||||
isMuted(): boolean;
|
||||
mute(): Promise<void>;
|
||||
unmute(): Promise<void>;
|
||||
attach(element: HTMLElement): void;
|
||||
detach(element: HTMLElement): void;
|
||||
dispose(): Promise<void>;
|
||||
getParticipantId(): string;
|
||||
track: MediaStreamTrack;
|
||||
isLocal(): boolean;
|
||||
getVideoType(): 'camera' | 'desktop' | undefined;
|
||||
}
|
||||
|
||||
export interface JitsiParticipant {
|
||||
getId(): string;
|
||||
getDisplayName(): string;
|
||||
isAudioMuted(): boolean;
|
||||
isVideoMuted(): boolean;
|
||||
getRole(): string;
|
||||
}
|
||||
|
||||
export interface JitsiMeetJSLib {
|
||||
JitsiConnection: new (
|
||||
appId: string | null,
|
||||
token: string | null,
|
||||
options: JitsiConnectionOptions
|
||||
) => JitsiConnection;
|
||||
constants: {
|
||||
events: {
|
||||
connection: {
|
||||
CONNECTION_ESTABLISHED: string;
|
||||
CONNECTION_FAILED: string;
|
||||
CONNECTION_DISCONNECTED: string;
|
||||
WRONG_STATE: string;
|
||||
};
|
||||
conference: {
|
||||
USER_JOINED: string;
|
||||
USER_LEFT: string;
|
||||
TRACK_ADDED: string;
|
||||
TRACK_REMOVED: string;
|
||||
TRACK_MUTE_CHANGED: string;
|
||||
CONFERENCE_JOINED: string;
|
||||
CONFERENCE_LEFT: string;
|
||||
CONFERENCE_ERROR: string;
|
||||
DISPLAY_NAME_CHANGED: string;
|
||||
DOMINANT_SPEAKER_CHANGED: string;
|
||||
};
|
||||
};
|
||||
logLevels: {
|
||||
ERROR: number;
|
||||
WARN: number;
|
||||
INFO: number;
|
||||
DEBUG: number;
|
||||
};
|
||||
};
|
||||
init(options: JitsiInitOptions): void;
|
||||
setLogLevel(level: number): void;
|
||||
createLocalTracks(
|
||||
options: MediaStreamConstraints,
|
||||
advancedOptions?: JitsiLocalTrackOptions
|
||||
): Promise<JitsiTrack[]>;
|
||||
mediaDevices: {
|
||||
enumerateDevices(): Promise<MediaDeviceInfo[]>;
|
||||
isDeviceChangeAvailable(type: 'audio' | 'video'): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JitsiConnectionOptions {
|
||||
hosts?: {
|
||||
domain?: string;
|
||||
muc?: string;
|
||||
focus?: string;
|
||||
};
|
||||
serviceUrl?: string;
|
||||
bosh?: string;
|
||||
websocket?: string;
|
||||
clientNode?: string;
|
||||
useStunTurn?: boolean;
|
||||
iceServers?: RTCIceServer[];
|
||||
enableLayerSuspension?: boolean;
|
||||
enableLipSync?: boolean;
|
||||
disableAudioLevels?: boolean;
|
||||
disableSimulcast?: boolean;
|
||||
enableRemb?: boolean;
|
||||
enableTcc?: boolean;
|
||||
useRoomAsSharedDocumentName?: boolean;
|
||||
enableStatsID?: boolean;
|
||||
channelLastN?: number;
|
||||
startBitrate?: number;
|
||||
stereo?: boolean;
|
||||
forcedVideoCodec?: string;
|
||||
preferredVideoCodec?: string;
|
||||
disableH264?: boolean;
|
||||
disableVP8?: boolean;
|
||||
disableVP9?: boolean;
|
||||
enableOpusRed?: boolean;
|
||||
enableDtmf?: boolean;
|
||||
openBridgeChannel?: string | boolean;
|
||||
}
|
||||
|
||||
export interface JitsiInitOptions {
|
||||
disableAudioLevels?: boolean;
|
||||
disableSimulcast?: boolean;
|
||||
enableWindowOnErrorHandler?: boolean;
|
||||
enableRemb?: boolean;
|
||||
enableTcc?: boolean;
|
||||
disableThirdPartyRequests?: boolean;
|
||||
useStunTurn?: boolean;
|
||||
iceServers?: RTCIceServer[];
|
||||
}
|
||||
|
||||
export interface JitsiLocalTrackOptions {
|
||||
devices?: string[];
|
||||
cameraDeviceId?: string;
|
||||
micDeviceId?: string;
|
||||
facingMode?: 'user' | 'environment';
|
||||
resolution?: number;
|
||||
frameRate?: number;
|
||||
}
|
||||
|
||||
// Extensão do Window para BlobBuilder (polyfill)
|
||||
export interface WindowWithBlobBuilder extends Window {
|
||||
BlobBuilder?: {
|
||||
new (): {
|
||||
append(data: Blob | string): void;
|
||||
getBlob(type?: string): Blob;
|
||||
};
|
||||
};
|
||||
webkitBlobBuilder?: {
|
||||
new (): {
|
||||
append(data: Blob | string): void;
|
||||
getBlob(type?: string): Blob;
|
||||
};
|
||||
};
|
||||
MozBlobBuilder?: {
|
||||
new (): {
|
||||
append(data: Blob | string): void;
|
||||
getBlob(type?: string): Blob;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// Tipo para API Jitsi (pode ser Connection ou Conference)
|
||||
export type JitsiApi = JitsiConnection | JitsiConference | null;
|
||||
|
||||
// Declaração de módulo para lib-jitsi-meet
|
||||
declare module 'lib-jitsi-meet' {
|
||||
export interface JitsiConnection {
|
||||
connect(): void;
|
||||
disconnect(): void;
|
||||
addEventListener(event: string, handler: (data?: unknown) => void): void;
|
||||
removeEventListener(event: string, handler: (data?: unknown) => void): void;
|
||||
initJitsiConference(roomName: string, options: Record<string, unknown>): JitsiConference;
|
||||
}
|
||||
|
||||
export interface JitsiConference {
|
||||
join(): void;
|
||||
leave(): void;
|
||||
on(event: string, handler: (...args: unknown[]) => void): void;
|
||||
off(event: string, handler: (...args: unknown[]) => void): void;
|
||||
removeEventListener(event: string, handler: (...args: unknown[]) => void): void;
|
||||
muteAudio(): void;
|
||||
unmuteAudio(): void;
|
||||
muteVideo(): void;
|
||||
unmuteVideo(): void;
|
||||
getParticipants(): Map<string, JitsiParticipant>;
|
||||
getLocalTracks(): JitsiTrack[];
|
||||
setDisplayName(name: string): void;
|
||||
addTrack(track: JitsiTrack): Promise<void>;
|
||||
removeTrack(track: JitsiTrack): Promise<void>;
|
||||
getLocalVideoTrack(): JitsiTrack | null;
|
||||
getLocalAudioTrack(): JitsiTrack | null;
|
||||
}
|
||||
|
||||
export interface JitsiTrack {
|
||||
getType(): 'audio' | 'video';
|
||||
isMuted(): boolean;
|
||||
mute(): Promise<void>;
|
||||
unmute(): Promise<void>;
|
||||
attach(element: HTMLElement): void;
|
||||
detach(element: HTMLElement): void;
|
||||
dispose(): Promise<void>;
|
||||
getParticipantId(): string;
|
||||
track: MediaStreamTrack;
|
||||
isLocal(): boolean;
|
||||
getVideoType(): 'camera' | 'desktop' | undefined;
|
||||
}
|
||||
|
||||
export interface JitsiParticipant {
|
||||
getId(): string;
|
||||
getDisplayName(): string;
|
||||
isAudioMuted(): boolean;
|
||||
isVideoMuted(): boolean;
|
||||
getRole(): string;
|
||||
}
|
||||
|
||||
export interface JitsiMeetJSLib {
|
||||
JitsiConnection: new (
|
||||
appId: string | null,
|
||||
token: string | null,
|
||||
options: JitsiConnectionOptions
|
||||
) => JitsiConnection;
|
||||
constants: {
|
||||
events: {
|
||||
connection: {
|
||||
CONNECTION_ESTABLISHED: string;
|
||||
CONNECTION_FAILED: string;
|
||||
CONNECTION_DISCONNECTED: string;
|
||||
WRONG_STATE: string;
|
||||
};
|
||||
conference: {
|
||||
USER_JOINED: string;
|
||||
USER_LEFT: string;
|
||||
TRACK_ADDED: string;
|
||||
TRACK_REMOVED: string;
|
||||
TRACK_MUTE_CHANGED: string;
|
||||
CONFERENCE_JOINED: string;
|
||||
CONFERENCE_LEFT: string;
|
||||
CONFERENCE_ERROR: string;
|
||||
DISPLAY_NAME_CHANGED: string;
|
||||
DOMINANT_SPEAKER_CHANGED: string;
|
||||
};
|
||||
};
|
||||
logLevels: {
|
||||
ERROR: number;
|
||||
WARN: number;
|
||||
INFO: number;
|
||||
DEBUG: number;
|
||||
};
|
||||
};
|
||||
init(options: JitsiInitOptions): void;
|
||||
setLogLevel(level: number): void;
|
||||
createLocalTracks(
|
||||
options: MediaStreamConstraints,
|
||||
advancedOptions?: JitsiLocalTrackOptions
|
||||
): Promise<JitsiTrack[]>;
|
||||
mediaDevices: {
|
||||
enumerateDevices(): Promise<MediaDeviceInfo[]>;
|
||||
isDeviceChangeAvailable(type: 'audio' | 'video'): Promise<boolean>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface JitsiConnectionOptions {
|
||||
hosts?: {
|
||||
domain?: string;
|
||||
muc?: string;
|
||||
focus?: string;
|
||||
};
|
||||
serviceUrl?: string;
|
||||
bosh?: string;
|
||||
websocket?: string;
|
||||
clientNode?: string;
|
||||
useStunTurn?: boolean;
|
||||
iceServers?: RTCIceServer[];
|
||||
enableLayerSuspension?: boolean;
|
||||
enableLipSync?: boolean;
|
||||
disableAudioLevels?: boolean;
|
||||
disableSimulcast?: boolean;
|
||||
enableRemb?: boolean;
|
||||
enableTcc?: boolean;
|
||||
useRoomAsSharedDocumentName?: boolean;
|
||||
enableStatsID?: boolean;
|
||||
channelLastN?: number;
|
||||
startBitrate?: number;
|
||||
stereo?: boolean;
|
||||
forcedVideoCodec?: string;
|
||||
preferredVideoCodec?: string;
|
||||
disableH264?: boolean;
|
||||
disableVP8?: boolean;
|
||||
disableVP9?: boolean;
|
||||
enableOpusRed?: boolean;
|
||||
enableDtmf?: boolean;
|
||||
openBridgeChannel?: string | boolean;
|
||||
}
|
||||
|
||||
export interface JitsiInitOptions {
|
||||
disableAudioLevels?: boolean;
|
||||
disableSimulcast?: boolean;
|
||||
enableWindowOnErrorHandler?: boolean;
|
||||
enableRemb?: boolean;
|
||||
enableTcc?: boolean;
|
||||
disableThirdPartyRequests?: boolean;
|
||||
useStunTurn?: boolean;
|
||||
iceServers?: RTCIceServer[];
|
||||
}
|
||||
|
||||
export interface JitsiLocalTrackOptions {
|
||||
devices?: string[];
|
||||
cameraDeviceId?: string;
|
||||
micDeviceId?: string;
|
||||
facingMode?: 'user' | 'environment';
|
||||
resolution?: number;
|
||||
frameRate?: number;
|
||||
}
|
||||
|
||||
const JitsiMeetJS: JitsiMeetJSLib;
|
||||
export default JitsiMeetJS;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Mapa de seeds para os 32 avatares
|
||||
const avatarSeeds: Record<string, string> = {
|
||||
// Masculinos (16)
|
||||
"avatar-m-1": "John",
|
||||
"avatar-m-2": "Peter",
|
||||
"avatar-m-3": "Michael",
|
||||
"avatar-m-4": "David",
|
||||
"avatar-m-5": "James",
|
||||
"avatar-m-6": "Robert",
|
||||
"avatar-m-7": "William",
|
||||
"avatar-m-8": "Joseph",
|
||||
"avatar-m-9": "Thomas",
|
||||
"avatar-m-10": "Charles",
|
||||
"avatar-m-11": "Daniel",
|
||||
"avatar-m-12": "Matthew",
|
||||
"avatar-m-13": "Anthony",
|
||||
"avatar-m-14": "Mark",
|
||||
"avatar-m-15": "Donald",
|
||||
"avatar-m-16": "Steven",
|
||||
// Femininos (16)
|
||||
"avatar-f-1": "Maria",
|
||||
"avatar-f-2": "Ana",
|
||||
"avatar-f-3": "Patricia",
|
||||
"avatar-f-4": "Jennifer",
|
||||
"avatar-f-5": "Linda",
|
||||
"avatar-f-6": "Barbara",
|
||||
"avatar-f-7": "Elizabeth",
|
||||
"avatar-f-8": "Jessica",
|
||||
"avatar-f-9": "Sarah",
|
||||
"avatar-f-10": "Karen",
|
||||
"avatar-f-11": "Nancy",
|
||||
"avatar-f-12": "Betty",
|
||||
"avatar-f-13": "Helen",
|
||||
"avatar-f-14": "Sandra",
|
||||
"avatar-f-15": "Ashley",
|
||||
"avatar-f-16": "Kimberly",
|
||||
};
|
||||
|
||||
/**
|
||||
* Gera URL do avatar usando API DiceBear com parâmetros simples
|
||||
*/
|
||||
export function getAvatarUrl(avatarId: string): string {
|
||||
const seed = avatarSeeds[avatarId] || avatarId || "default";
|
||||
|
||||
// Usar avataarstyle do DiceBear com parâmetros mínimos
|
||||
// API v7 suporta apenas parâmetros específicos
|
||||
return `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista todos os IDs de avatares disponíveis
|
||||
*/
|
||||
export function getAllAvatarIds(): string[] {
|
||||
return Object.keys(avatarSeeds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se um avatarId é válido
|
||||
*/
|
||||
export function isValidAvatarId(avatarId: string): boolean {
|
||||
return avatarId in avatarSeeds;
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
// Galeria de avatares inspirados em artistas do cinema
|
||||
// Usando DiceBear API com estilos variados para aparência cinematográfica
|
||||
|
||||
export interface Avatar {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
seed: string;
|
||||
style: string;
|
||||
}
|
||||
|
||||
// Avatares inspirados em artistas do cinema (30 avatares estilizados)
|
||||
const cinemaArtistsAvatars = [
|
||||
// 15 Masculinos - Inspirados em grandes atores
|
||||
{
|
||||
id: 'avatar-male-1',
|
||||
name: 'Leonardo DiCaprio',
|
||||
seed: 'Leonardo',
|
||||
style: 'adventurer',
|
||||
bgColor: 'C5CAE9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-2',
|
||||
name: 'Brad Pitt',
|
||||
seed: 'Bradley',
|
||||
style: 'adventurer',
|
||||
bgColor: 'B2DFDB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-3',
|
||||
name: 'Tom Hanks',
|
||||
seed: 'Thomas',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'DCEDC8',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-4',
|
||||
name: 'Morgan Freeman',
|
||||
seed: 'Morgan',
|
||||
style: 'adventurer',
|
||||
bgColor: 'F0F4C3',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-5',
|
||||
name: 'Robert De Niro',
|
||||
seed: 'Robert',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'E0E0E0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-6',
|
||||
name: 'Al Pacino',
|
||||
seed: 'Alfredo',
|
||||
style: 'adventurer',
|
||||
bgColor: 'FFCCBC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-7',
|
||||
name: 'Johnny Depp',
|
||||
seed: 'John',
|
||||
style: 'adventurer',
|
||||
bgColor: 'D1C4E9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-8',
|
||||
name: 'Denzel Washington',
|
||||
seed: 'Denzel',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'B3E5FC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-9',
|
||||
name: 'Will Smith',
|
||||
seed: 'Willard',
|
||||
style: 'adventurer',
|
||||
bgColor: 'FFF9C4',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-10',
|
||||
name: 'Tom Cruise',
|
||||
seed: 'TomC',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'CFD8DC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-11',
|
||||
name: 'Samuel L Jackson',
|
||||
seed: 'Samuel',
|
||||
style: 'adventurer',
|
||||
bgColor: 'F8BBD0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-12',
|
||||
name: 'Harrison Ford',
|
||||
seed: 'Harrison',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'C8E6C9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-13',
|
||||
name: 'Keanu Reeves',
|
||||
seed: 'Keanu',
|
||||
style: 'adventurer',
|
||||
bgColor: 'BBDEFB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-14',
|
||||
name: 'Matt Damon',
|
||||
seed: 'Matthew',
|
||||
style: 'adventurer-neutral',
|
||||
bgColor: 'FFE0B2',
|
||||
},
|
||||
{
|
||||
id: 'avatar-male-15',
|
||||
name: 'Christian Bale',
|
||||
seed: 'Christian',
|
||||
style: 'adventurer',
|
||||
bgColor: 'E1BEE7',
|
||||
},
|
||||
// 15 Femininos - Inspiradas em grandes atrizes
|
||||
{
|
||||
id: 'avatar-female-1',
|
||||
name: 'Meryl Streep',
|
||||
seed: 'Meryl',
|
||||
style: 'lorelei',
|
||||
bgColor: 'F8BBD0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-2',
|
||||
name: 'Scarlett Johansson',
|
||||
seed: 'Scarlett',
|
||||
style: 'lorelei',
|
||||
bgColor: 'FFCCBC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-3',
|
||||
name: 'Jennifer Lawrence',
|
||||
seed: 'Jennifer',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'E1BEE7',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-4',
|
||||
name: 'Angelina Jolie',
|
||||
seed: 'Angelina',
|
||||
style: 'lorelei',
|
||||
bgColor: 'C5CAE9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-5',
|
||||
name: 'Cate Blanchett',
|
||||
seed: 'Catherine',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'B2DFDB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-6',
|
||||
name: 'Nicole Kidman',
|
||||
seed: 'Nicole',
|
||||
style: 'lorelei',
|
||||
bgColor: 'DCEDC8',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-7',
|
||||
name: 'Julia Roberts',
|
||||
seed: 'Julia',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'FFF9C4',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-8',
|
||||
name: 'Emma Stone',
|
||||
seed: 'Emma',
|
||||
style: 'lorelei',
|
||||
bgColor: 'CFD8DC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-9',
|
||||
name: 'Natalie Portman',
|
||||
seed: 'Natalie',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'F0F4C3',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-10',
|
||||
name: 'Charlize Theron',
|
||||
seed: 'Charlize',
|
||||
style: 'lorelei',
|
||||
bgColor: 'E0E0E0',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-11',
|
||||
name: 'Kate Winslet',
|
||||
seed: 'Kate',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'D1C4E9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-12',
|
||||
name: 'Sandra Bullock',
|
||||
seed: 'Sandra',
|
||||
style: 'lorelei',
|
||||
bgColor: 'B3E5FC',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-13',
|
||||
name: 'Halle Berry',
|
||||
seed: 'Halle',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'C8E6C9',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-14',
|
||||
name: 'Anne Hathaway',
|
||||
seed: 'Anne',
|
||||
style: 'lorelei',
|
||||
bgColor: 'BBDEFB',
|
||||
},
|
||||
{
|
||||
id: 'avatar-female-15',
|
||||
name: 'Amy Adams',
|
||||
seed: 'Amy',
|
||||
style: 'lorelei-neutral',
|
||||
bgColor: 'FFE0B2',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Gera uma galeria de avatares inspirados em artistas do cinema
|
||||
* Usa DiceBear API com estilos cinematográficos
|
||||
* @param count Número de avatares a gerar (padrão: 30)
|
||||
* @returns Array de objetos com id, name, url, seed e style
|
||||
*/
|
||||
export function generateAvatarGallery(count: number = 30): Avatar[] {
|
||||
const avatars: Avatar[] = [];
|
||||
|
||||
for (let i = 0; i < Math.min(count, cinemaArtistsAvatars.length); i++) {
|
||||
const avatar = cinemaArtistsAvatars[i];
|
||||
|
||||
// URL do DiceBear com estilo cinematográfico
|
||||
const url = `https://api.dicebear.com/7.x/${avatar.style}/svg?seed=${encodeURIComponent(avatar.seed)}&backgroundColor=${avatar.bgColor}&radius=50&size=200`;
|
||||
|
||||
avatars.push({
|
||||
id: avatar.id,
|
||||
name: avatar.name,
|
||||
url,
|
||||
seed: avatar.seed,
|
||||
style: avatar.style,
|
||||
});
|
||||
}
|
||||
|
||||
return avatars;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter URL do avatar por ID
|
||||
* @param avatarId ID do avatar (ex: "avatar-male-1")
|
||||
* @returns URL do avatar ou string vazia se não encontrado
|
||||
*/
|
||||
export function getAvatarUrl(avatarId: string): string {
|
||||
const gallery = generateAvatarGallery();
|
||||
const avatar = gallery.find(a => a.id === avatarId);
|
||||
return avatar?.url || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar avatar aleatório da galeria
|
||||
* @returns Avatar aleatório
|
||||
*/
|
||||
export function getRandomAvatar(): Avatar {
|
||||
const gallery = generateAvatarGallery();
|
||||
const randomIndex = Math.floor(Math.random() * gallery.length);
|
||||
return gallery[randomIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar avatar selecionado (retorna o ID para salvar no backend)
|
||||
* @param avatarId ID do avatar selecionado
|
||||
* @returns ID do avatar
|
||||
*/
|
||||
export function saveAvatarSelection(avatarId: string): string {
|
||||
return avatarId;
|
||||
}
|
||||
200
apps/web/src/lib/utils/callWindowManager.ts
Normal file
200
apps/web/src/lib/utils/callWindowManager.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Utilitário para gerenciar abertura de CallWindow em nova janela
|
||||
*/
|
||||
|
||||
export interface CallWindowOptions {
|
||||
width?: number;
|
||||
height?: number;
|
||||
left?: number;
|
||||
top?: number;
|
||||
features?: string;
|
||||
}
|
||||
|
||||
export interface CallWindowData {
|
||||
chamadaId: string;
|
||||
conversaId: string;
|
||||
tipo: 'audio' | 'video';
|
||||
roomName: string;
|
||||
ehAnfitriao: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: Required<CallWindowOptions> = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
left: undefined,
|
||||
top: undefined,
|
||||
features: 'toolbar=no,location=no,status=no,menubar=no,scrollbars=yes,resizable=yes'
|
||||
};
|
||||
|
||||
/**
|
||||
* Calcular posição centralizada da janela
|
||||
*/
|
||||
function calcularPosicaoCentralizada(width: number, height: number): { left: number; top: number } {
|
||||
const left = window.screenX + (window.outerWidth - width) / 2;
|
||||
const top = window.screenY + (window.outerHeight - height) / 2;
|
||||
return { left, top };
|
||||
}
|
||||
|
||||
/**
|
||||
* Abrir CallWindow em nova janela do navegador
|
||||
*/
|
||||
export function abrirCallWindowEmPopup(
|
||||
data: CallWindowData,
|
||||
options: CallWindowOptions = {}
|
||||
): Window | null {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
// Calcular posição se não fornecida
|
||||
let left = opts.left;
|
||||
let top = opts.top;
|
||||
|
||||
if (left === undefined || top === undefined) {
|
||||
const posicao = calcularPosicaoCentralizada(opts.width, opts.height);
|
||||
left = left ?? posicao.left;
|
||||
top = top ?? posicao.top;
|
||||
}
|
||||
|
||||
// Construir features da janela
|
||||
const features = [
|
||||
`width=${opts.width}`,
|
||||
`height=${opts.height}`,
|
||||
`left=${left}`,
|
||||
`top=${top}`,
|
||||
opts.features
|
||||
].join(',');
|
||||
|
||||
// Criar URL com dados da chamada
|
||||
const url = new URL('/call', window.location.origin);
|
||||
url.searchParams.set('chamadaId', data.chamadaId);
|
||||
url.searchParams.set('conversaId', data.conversaId);
|
||||
url.searchParams.set('tipo', data.tipo);
|
||||
url.searchParams.set('roomName', data.roomName);
|
||||
url.searchParams.set('ehAnfitriao', String(data.ehAnfitriao));
|
||||
|
||||
// Abrir janela
|
||||
const popup = window.open(url.toString(), `call-${data.chamadaId}`, features);
|
||||
|
||||
if (!popup) {
|
||||
console.error('Falha ao abrir popup. Verifique se o bloqueador de popups está desabilitado.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Focar na nova janela
|
||||
popup.focus();
|
||||
|
||||
// Configurar comunicação via postMessage
|
||||
configurarComunicacaoPopup(popup, data);
|
||||
|
||||
return popup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurar comunicação entre janelas usando postMessage
|
||||
*/
|
||||
function configurarComunicacaoPopup(popup: Window, data: CallWindowData): void {
|
||||
// Listener para mensagens da janela popup
|
||||
const messageHandler = (event: MessageEvent) => {
|
||||
// Verificar origem
|
||||
if (event.origin !== window.location.origin) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se a mensagem é da janela popup
|
||||
if (event.data?.source === 'call-window-popup') {
|
||||
switch (event.data.type) {
|
||||
case 'ready':
|
||||
console.log('CallWindow popup está pronto');
|
||||
break;
|
||||
case 'closed':
|
||||
console.log('CallWindow popup foi fechado');
|
||||
window.removeEventListener('message', messageHandler);
|
||||
break;
|
||||
case 'error':
|
||||
console.error('Erro na CallWindow popup:', event.data.error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', messageHandler);
|
||||
|
||||
// Detectar quando a janela é fechada
|
||||
const checkClosed = setInterval(() => {
|
||||
if (popup.closed) {
|
||||
clearInterval(checkClosed);
|
||||
window.removeEventListener('message', messageHandler);
|
||||
console.log('CallWindow popup foi fechado pelo usuário');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se popups estão habilitados
|
||||
*/
|
||||
export function verificarSuportePopup(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tentar abrir um popup de teste
|
||||
const testPopup = window.open('about:blank', '_blank', 'width=1,height=1');
|
||||
|
||||
if (!testPopup) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fechar popup de teste
|
||||
testPopup.close();
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter dados da chamada da URL (para uso na página de popup)
|
||||
*/
|
||||
export function obterDadosChamadaDaUrl(): CallWindowData | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const chamadaId = params.get('chamadaId');
|
||||
const conversaId = params.get('conversaId');
|
||||
const tipo = params.get('tipo');
|
||||
const roomName = params.get('roomName');
|
||||
const ehAnfitriao = params.get('ehAnfitriao');
|
||||
|
||||
if (!chamadaId || !conversaId || !tipo || !roomName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tipo !== 'audio' && tipo !== 'video') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
chamadaId,
|
||||
conversaId,
|
||||
tipo,
|
||||
roomName,
|
||||
ehAnfitriao: ehAnfitriao === 'true'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notificar janela pai sobre eventos
|
||||
*/
|
||||
export function notificarJanelaPai(type: string, data?: unknown): void {
|
||||
if (typeof window === 'undefined' || !window.opener) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.opener.postMessage(
|
||||
{
|
||||
source: 'call-window-popup',
|
||||
type,
|
||||
data
|
||||
},
|
||||
window.location.origin
|
||||
);
|
||||
}
|
||||
|
||||
123
apps/web/src/lib/utils/chamados.ts
Normal file
123
apps/web/src/lib/utils/chamados.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
type TicketStatus = Ticket["status"];
|
||||
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
|
||||
|
||||
const UM_DIA_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const statusConfig: Record<
|
||||
TicketStatus,
|
||||
{
|
||||
label: string;
|
||||
badge: string;
|
||||
description: string;
|
||||
}
|
||||
> = {
|
||||
aberto: {
|
||||
label: "Aberto",
|
||||
badge: "badge badge-info badge-outline",
|
||||
description: "Chamado recebido e aguardando triagem.",
|
||||
},
|
||||
em_andamento: {
|
||||
label: "Em andamento",
|
||||
badge: "badge badge-primary",
|
||||
description: "Equipe de TI trabalhando no chamado.",
|
||||
},
|
||||
aguardando_usuario: {
|
||||
label: "Aguardando usuário",
|
||||
badge: "badge badge-warning",
|
||||
description: "Aguardando retorno ou aprovação do solicitante.",
|
||||
},
|
||||
resolvido: {
|
||||
label: "Resolvido",
|
||||
badge: "badge badge-success badge-outline",
|
||||
description: "Solução aplicada, aguardando confirmação.",
|
||||
},
|
||||
encerrado: {
|
||||
label: "Encerrado",
|
||||
badge: "badge badge-success",
|
||||
description: "Chamado finalizado.",
|
||||
},
|
||||
cancelado: {
|
||||
label: "Cancelado",
|
||||
badge: "badge badge-neutral",
|
||||
description: "Chamado cancelado.",
|
||||
},
|
||||
};
|
||||
|
||||
export function getStatusLabel(status: TicketStatus): string {
|
||||
return statusConfig[status]?.label ?? status;
|
||||
}
|
||||
|
||||
export function getStatusBadge(status: TicketStatus): string {
|
||||
return statusConfig[status]?.badge ?? "badge";
|
||||
}
|
||||
|
||||
export function getStatusDescription(status: TicketStatus): string {
|
||||
return statusConfig[status]?.description ?? "";
|
||||
}
|
||||
|
||||
export function formatarData(timestamp?: number | null) {
|
||||
if (!timestamp) return "--";
|
||||
return new Date(timestamp).toLocaleString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function prazoRestante(timestamp?: number | null) {
|
||||
if (!timestamp) return null;
|
||||
const diff = timestamp - Date.now();
|
||||
const dias = Math.floor(diff / UM_DIA_MS);
|
||||
const horas = Math.floor((diff % UM_DIA_MS) / (60 * 60 * 1000));
|
||||
|
||||
if (diff < 0) {
|
||||
return `Vencido há ${Math.abs(dias)}d ${Math.abs(horas)}h`;
|
||||
}
|
||||
|
||||
if (dias === 0 && horas >= 0) {
|
||||
return `Vence em ${horas}h`;
|
||||
}
|
||||
|
||||
return `Vence em ${dias}d ${Math.abs(horas)}h`;
|
||||
}
|
||||
|
||||
export function corPrazo(timestamp?: number | null) {
|
||||
if (!timestamp) return "info";
|
||||
const diff = timestamp - Date.now();
|
||||
if (diff < 0) return "error";
|
||||
if (diff <= UM_DIA_MS) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
export function timelineStatus(entry: TimelineEntry) {
|
||||
if (entry.status === "concluido") {
|
||||
return "success";
|
||||
}
|
||||
if (!entry.prazo) {
|
||||
return "info";
|
||||
}
|
||||
const diff = entry.prazo - Date.now();
|
||||
if (diff < 0) {
|
||||
return "error";
|
||||
}
|
||||
if (diff <= UM_DIA_MS) {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
export function formatarTimelineEtapa(etapa: string) {
|
||||
const mapa: Record<string, string> = {
|
||||
abertura: "Registro",
|
||||
resposta_inicial: "Resposta inicial",
|
||||
conclusao: "Conclusão",
|
||||
encerramento: "Encerramento",
|
||||
};
|
||||
|
||||
return mapa[etapa] ?? etapa;
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function gerarDeclaracaoAcumulacaoCargo(funcionario: Funcionario):
|
||||
// Rodapé
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
|
||||
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
|
||||
|
||||
return doc.output('blob');
|
||||
}
|
||||
@@ -260,7 +260,7 @@ export async function gerarDeclaracaoDependentesIR(funcionario: Funcionario): Pr
|
||||
// Rodapé
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
|
||||
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
|
||||
|
||||
return doc.output('blob');
|
||||
}
|
||||
@@ -341,7 +341,7 @@ export async function gerarDeclaracaoIdoneidade(funcionario: Funcionario): Promi
|
||||
// Rodapé
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
|
||||
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
|
||||
|
||||
return doc.output('blob');
|
||||
}
|
||||
@@ -440,7 +440,7 @@ export async function gerarTermoNepotismo(funcionario: Funcionario): Promise<Blo
|
||||
// Rodapé
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
|
||||
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
|
||||
|
||||
return doc.output('blob');
|
||||
}
|
||||
@@ -562,7 +562,7 @@ export async function gerarTermoOpcaoRemuneracao(funcionario: Funcionario): Prom
|
||||
// Rodapé
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, { align: 'center' });
|
||||
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, { align: 'center' });
|
||||
|
||||
return doc.output('blob');
|
||||
}
|
||||
|
||||
877
apps/web/src/lib/utils/deviceInfo.ts
Normal file
877
apps/web/src/lib/utils/deviceInfo.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
import { getLocalIP } from './browserInfo';
|
||||
|
||||
export interface DadosAcelerometro {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
movimentoDetectado: boolean;
|
||||
magnitude: number;
|
||||
variacao: number; // Variância entre leituras
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface DadosGiroscopio {
|
||||
alpha: number;
|
||||
beta: number;
|
||||
gamma: number;
|
||||
}
|
||||
|
||||
export interface InformacoesDispositivo {
|
||||
ipAddress?: string;
|
||||
ipPublico?: string;
|
||||
ipLocal?: string;
|
||||
userAgent?: string;
|
||||
browser?: string;
|
||||
browserVersion?: string;
|
||||
engine?: string;
|
||||
sistemaOperacional?: string;
|
||||
osVersion?: string;
|
||||
arquitetura?: string;
|
||||
plataforma?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
confiabilidadeGPS?: number; // 0-1
|
||||
suspeitaSpoofing?: boolean;
|
||||
motivoSuspeita?: string;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
timezone?: string;
|
||||
deviceType?: string;
|
||||
deviceModel?: string;
|
||||
screenResolution?: string;
|
||||
coresTela?: number;
|
||||
idioma?: string;
|
||||
isMobile?: boolean;
|
||||
isTablet?: boolean;
|
||||
isDesktop?: boolean;
|
||||
connectionType?: string;
|
||||
memoryInfo?: string;
|
||||
acelerometro?: DadosAcelerometro;
|
||||
giroscopio?: DadosGiroscopio;
|
||||
sensorDisponivel?: boolean; // Indica se o sensor está disponível no dispositivo
|
||||
permissaoNegada?: boolean; // Indica se a permissão foi negada pelo usuário
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta informações do navegador
|
||||
*/
|
||||
function detectarNavegador(): { browser: string; browserVersion: string; engine: string } {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return { browser: 'Desconhecido', browserVersion: '', engine: '' };
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
let browser = 'Desconhecido';
|
||||
let browserVersion = '';
|
||||
let engine = '';
|
||||
|
||||
// Detectar engine
|
||||
if (ua.includes('Edg/')) {
|
||||
engine = 'EdgeHTML';
|
||||
} else if (ua.includes('Chrome/')) {
|
||||
engine = 'Blink';
|
||||
} else if (ua.includes('Firefox/')) {
|
||||
engine = 'Gecko';
|
||||
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
|
||||
engine = 'WebKit';
|
||||
}
|
||||
|
||||
// Detectar navegador
|
||||
if (ua.includes('Edg/')) {
|
||||
browser = 'Edge';
|
||||
const match = ua.match(/Edg\/(\d+)/);
|
||||
browserVersion = match ? match[1]! : '';
|
||||
} else if (ua.includes('Chrome/') && !ua.includes('Edg/')) {
|
||||
browser = 'Chrome';
|
||||
const match = ua.match(/Chrome\/(\d+)/);
|
||||
browserVersion = match ? match[1]! : '';
|
||||
} else if (ua.includes('Firefox/')) {
|
||||
browser = 'Firefox';
|
||||
const match = ua.match(/Firefox\/(\d+)/);
|
||||
browserVersion = match ? match[1]! : '';
|
||||
} else if (ua.includes('Safari/') && !ua.includes('Chrome/')) {
|
||||
browser = 'Safari';
|
||||
const match = ua.match(/Version\/(\d+)/);
|
||||
browserVersion = match ? match[1]! : '';
|
||||
} else if (ua.includes('Opera/') || ua.includes('OPR/')) {
|
||||
browser = 'Opera';
|
||||
const match = ua.match(/(?:Opera|OPR)\/(\d+)/);
|
||||
browserVersion = match ? match[1]! : '';
|
||||
}
|
||||
|
||||
return { browser, browserVersion, engine };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta informações do sistema operacional
|
||||
*/
|
||||
function detectarSistemaOperacional(): {
|
||||
sistemaOperacional: string;
|
||||
osVersion: string;
|
||||
arquitetura: string;
|
||||
plataforma: string;
|
||||
} {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return {
|
||||
sistemaOperacional: 'Desconhecido',
|
||||
osVersion: '',
|
||||
arquitetura: '',
|
||||
plataforma: '',
|
||||
};
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
const platform = navigator.platform || '';
|
||||
let sistemaOperacional = 'Desconhecido';
|
||||
let osVersion = '';
|
||||
let arquitetura = '';
|
||||
const plataforma = platform;
|
||||
|
||||
// Detectar OS
|
||||
if (ua.includes('Windows NT')) {
|
||||
sistemaOperacional = 'Windows';
|
||||
const match = ua.match(/Windows NT (\d+\.\d+)/);
|
||||
if (match) {
|
||||
const version = match[1]!;
|
||||
const versions: Record<string, string> = {
|
||||
'10.0': '10/11',
|
||||
'6.3': '8.1',
|
||||
'6.2': '8',
|
||||
'6.1': '7',
|
||||
};
|
||||
osVersion = versions[version] || version;
|
||||
}
|
||||
} else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) {
|
||||
sistemaOperacional = 'macOS';
|
||||
const match = ua.match(/Mac OS X (\d+[._]\d+)/);
|
||||
if (match) {
|
||||
osVersion = match[1]!.replace('_', '.');
|
||||
}
|
||||
} else if (ua.includes('Linux')) {
|
||||
sistemaOperacional = 'Linux';
|
||||
osVersion = 'Linux';
|
||||
} else if (ua.includes('Android')) {
|
||||
sistemaOperacional = 'Android';
|
||||
const match = ua.match(/Android (\d+(?:\.\d+)?)/);
|
||||
osVersion = match ? match[1]! : '';
|
||||
} else if (ua.includes('iPhone') || ua.includes('iPad')) {
|
||||
sistemaOperacional = 'iOS';
|
||||
const match = ua.match(/OS (\d+[._]\d+)/);
|
||||
if (match) {
|
||||
osVersion = match[1]!.replace('_', '.');
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar arquitetura (se disponível)
|
||||
if ('cpuClass' in navigator) {
|
||||
arquitetura = (navigator as unknown as { cpuClass: string }).cpuClass;
|
||||
}
|
||||
|
||||
return { sistemaOperacional, osVersion, arquitetura, plataforma };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta tipo de dispositivo
|
||||
*/
|
||||
function detectarTipoDispositivo(): {
|
||||
deviceType: string;
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
} {
|
||||
if (typeof navigator === 'undefined') {
|
||||
return {
|
||||
deviceType: 'Desconhecido',
|
||||
isMobile: false,
|
||||
isTablet: false,
|
||||
isDesktop: true,
|
||||
};
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent;
|
||||
const isMobile = /Mobile|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
|
||||
const isTablet = /iPad|Android(?!.*Mobile)|Tablet/i.test(ua);
|
||||
const isDesktop = !isMobile && !isTablet;
|
||||
|
||||
let deviceType = 'Desktop';
|
||||
if (isTablet) {
|
||||
deviceType = 'Tablet';
|
||||
} else if (isMobile) {
|
||||
deviceType = 'Mobile';
|
||||
}
|
||||
|
||||
return { deviceType, isMobile, isTablet, isDesktop };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações da tela
|
||||
*/
|
||||
function obterInformacoesTela(): { screenResolution: string; coresTela: number } {
|
||||
if (typeof screen === 'undefined') {
|
||||
return { screenResolution: 'Desconhecido', coresTela: 0 };
|
||||
}
|
||||
|
||||
const screenResolution = `${screen.width}x${screen.height}`;
|
||||
const coresTela = screen.colorDepth || 24;
|
||||
|
||||
return { screenResolution, coresTela };
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações de conexão
|
||||
*/
|
||||
async function obterInformacoesConexao(): Promise<string> {
|
||||
if (typeof navigator === 'undefined' || !('connection' in navigator)) {
|
||||
return 'Desconhecido';
|
||||
}
|
||||
|
||||
const connection = (navigator as unknown as { connection?: { effectiveType?: string } }).connection;
|
||||
if (connection?.effectiveType) {
|
||||
return connection.effectiveType;
|
||||
}
|
||||
|
||||
return 'Desconhecido';
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém informações de memória (se disponível)
|
||||
*/
|
||||
function obterInformacoesMemoria(): string {
|
||||
if (typeof navigator === 'undefined' || !('deviceMemory' in navigator)) {
|
||||
return 'Desconhecido';
|
||||
}
|
||||
|
||||
const deviceMemory = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
|
||||
if (deviceMemory) {
|
||||
return `${deviceMemory} GB`;
|
||||
}
|
||||
|
||||
return 'Desconhecido';
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula distância entre duas coordenadas (fórmula de Haversine)
|
||||
* Retorna distância em metros
|
||||
*/
|
||||
function calcularDistancia(
|
||||
lat1: number,
|
||||
lon1: number,
|
||||
lat2: number,
|
||||
lon2: number
|
||||
): number {
|
||||
const R = 6371000; // Raio da Terra em metros
|
||||
const dLat = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const dLon = ((lon2 - lon1) * Math.PI) / 180;
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos((lat1 * Math.PI) / 180) *
|
||||
Math.cos((lat2 * Math.PI) / 180) *
|
||||
Math.sin(dLon / 2) *
|
||||
Math.sin(dLon / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
return R * c;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém timezone aproximado por coordenadas
|
||||
*/
|
||||
function obterTimezonePorCoordenadas(latitude: number, longitude: number): string {
|
||||
// Pernambuco está em UTC-3 (America/Recife)
|
||||
if (longitude >= -45 && longitude <= -30 && latitude >= -10 && latitude <= 5) {
|
||||
return 'America/Recife'; // UTC-3
|
||||
}
|
||||
|
||||
// Fallback: usar timezone do sistema
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return 'America/Recife'; // Default
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captura uma única leitura de localização com todas as propriedades disponíveis
|
||||
*/
|
||||
async function capturarLocalizacaoUnica(
|
||||
enableHighAccuracy: boolean = true,
|
||||
timeout: number = 10000
|
||||
): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
timestamp?: number;
|
||||
confiabilidade: number; // 0-1
|
||||
}> {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
resolve({ confiabilidade: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
resolve({ confiabilidade: 0 });
|
||||
}, timeout + 1000);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
clearTimeout(timeoutId);
|
||||
const coords = position.coords;
|
||||
const { latitude, longitude, accuracy } = coords;
|
||||
|
||||
// Validar coordenadas básicas
|
||||
if (
|
||||
isNaN(latitude) ||
|
||||
isNaN(longitude) ||
|
||||
latitude === 0 ||
|
||||
longitude === 0 ||
|
||||
latitude < -90 ||
|
||||
latitude > 90 ||
|
||||
longitude < -180 ||
|
||||
longitude > 180
|
||||
) {
|
||||
resolve({ confiabilidade: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
// Calcular score de confiabilidade baseado em propriedades do GPS real
|
||||
const sinaisGPSReal = {
|
||||
temAltitude: coords.altitude !== null && coords.altitude !== 0,
|
||||
temAltitudeAccuracy: coords.altitudeAccuracy !== null && coords.altitudeAccuracy > 0,
|
||||
temHeading: coords.heading !== null && !isNaN(coords.heading),
|
||||
temSpeed: coords.speed !== null && !isNaN(coords.speed),
|
||||
precisaoBoa: accuracy < 20, // GPS real geralmente < 20m
|
||||
precisaoMedia: accuracy >= 20 && accuracy < 100,
|
||||
timestampPreciso: position.timestamp > 0
|
||||
};
|
||||
|
||||
// Calcular confiabilidade: cada sinal adiciona pontos
|
||||
let pontos = 0;
|
||||
const maxPontos = 7;
|
||||
|
||||
if (sinaisGPSReal.temAltitude) pontos += 1;
|
||||
if (sinaisGPSReal.temAltitudeAccuracy) pontos += 1;
|
||||
if (sinaisGPSReal.temHeading) pontos += 0.5;
|
||||
if (sinaisGPSReal.temSpeed) pontos += 0.5;
|
||||
if (sinaisGPSReal.precisaoBoa) pontos += 2;
|
||||
if (sinaisGPSReal.precisaoMedia) pontos += 1;
|
||||
if (sinaisGPSReal.timestampPreciso) pontos += 1;
|
||||
|
||||
const confiabilidade = Math.min(pontos / maxPontos, 1);
|
||||
|
||||
resolve({
|
||||
latitude,
|
||||
longitude,
|
||||
precisao: accuracy,
|
||||
altitude: coords.altitude ?? null,
|
||||
altitudeAccuracy: coords.altitudeAccuracy ?? null,
|
||||
heading: coords.heading ?? null,
|
||||
speed: coords.speed ?? null,
|
||||
timestamp: position.timestamp,
|
||||
confiabilidade
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeoutId);
|
||||
console.warn('Erro ao obter localização:', error.code, error.message);
|
||||
resolve({ confiabilidade: 0 });
|
||||
},
|
||||
{
|
||||
enableHighAccuracy,
|
||||
timeout,
|
||||
maximumAge: 0 // Sempre obter nova leitura
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém localização via GPS com múltiplas leituras para detectar spoofing
|
||||
* Apps de spoofing geralmente retornam valores idênticos em todas as leituras
|
||||
*/
|
||||
async function obterLocalizacaoMultipla(): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
confiabilidade: number; // 0-1
|
||||
suspeitaSpoofing: boolean;
|
||||
motivoSuspeita?: string;
|
||||
}> {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Geolocalização não suportada' };
|
||||
}
|
||||
|
||||
// Capturar 3 leituras com intervalo de 2 segundos entre elas
|
||||
const leituras: Array<{
|
||||
lat: number;
|
||||
lon: number;
|
||||
precisao: number;
|
||||
altitude: number | null;
|
||||
confiabilidade: number;
|
||||
}> = [];
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const leitura = await capturarLocalizacaoUnica(true, 8000);
|
||||
|
||||
if (leitura.latitude && leitura.longitude && leitura.confiabilidade > 0) {
|
||||
leituras.push({
|
||||
lat: leitura.latitude,
|
||||
lon: leitura.longitude,
|
||||
precisao: leitura.precisao || 999,
|
||||
altitude: leitura.altitude ?? null,
|
||||
confiabilidade: leitura.confiabilidade
|
||||
});
|
||||
}
|
||||
|
||||
// Aguardar 2 segundos entre leituras (exceto na última)
|
||||
if (i < 2) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (leituras.length === 0) {
|
||||
return { confiabilidade: 0, suspeitaSpoofing: true, motivoSuspeita: 'Não foi possível obter localização' };
|
||||
}
|
||||
|
||||
// Se tivermos menos de 2 leituras, usar única leitura com baixa confiança
|
||||
if (leituras.length < 2) {
|
||||
const unica = leituras[0];
|
||||
return {
|
||||
latitude: unica.lat,
|
||||
longitude: unica.lon,
|
||||
precisao: unica.precisao,
|
||||
altitude: unica.altitude,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
confiabilidade: unica.confiabilidade * 0.5, // Reduzir confiança por ter apenas 1 leitura
|
||||
suspeitaSpoofing: true,
|
||||
motivoSuspeita: 'Apenas uma leitura obtida'
|
||||
};
|
||||
}
|
||||
|
||||
// Verificar se todas as leituras são idênticas (suspeito de spoofing)
|
||||
const primeiraLeitura = leituras[0];
|
||||
const todasIguais = leituras.every(
|
||||
(l) =>
|
||||
Math.abs(l.lat - primeiraLeitura.lat) < 0.00001 && // ~1 metro
|
||||
Math.abs(l.lon - primeiraLeitura.lon) < 0.00001
|
||||
);
|
||||
|
||||
if (todasIguais && leituras.length === 3) {
|
||||
// GPS real varia alguns metros, se todas são idênticas pode ser spoofing
|
||||
return {
|
||||
latitude: primeiraLeitura.lat,
|
||||
longitude: primeiraLeitura.lon,
|
||||
precisao: primeiraLeitura.precisao,
|
||||
altitude: primeiraLeitura.altitude,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
confiabilidade: primeiraLeitura.confiabilidade * 0.4, // Reduzir drasticamente confiança
|
||||
suspeitaSpoofing: true,
|
||||
motivoSuspeita: 'Todas as leituras são idênticas (GPS real varia alguns metros)'
|
||||
};
|
||||
}
|
||||
|
||||
// Calcular média das leituras e variância
|
||||
const mediaLat = leituras.reduce((sum, l) => sum + l.lat, 0) / leituras.length;
|
||||
const mediaLon = leituras.reduce((sum, l) => sum + l.lon, 0) / leituras.length;
|
||||
const mediaConfianca = leituras.reduce((sum, l) => sum + l.confiabilidade, 0) / leituras.length;
|
||||
|
||||
// Calcular distância máxima entre leituras
|
||||
let distanciaMaxima = 0;
|
||||
for (let i = 0; i < leituras.length; i++) {
|
||||
for (let j = i + 1; j < leituras.length; j++) {
|
||||
const dist = calcularDistancia(
|
||||
leituras[i].lat,
|
||||
leituras[i].lon,
|
||||
leituras[j].lat,
|
||||
leituras[j].lon
|
||||
);
|
||||
distanciaMaxima = Math.max(distanciaMaxima, dist);
|
||||
}
|
||||
}
|
||||
|
||||
// Se distância máxima for muito grande (> 100m), pode indicar problemas
|
||||
const suspeitoPorDistancia = distanciaMaxima > 100;
|
||||
|
||||
return {
|
||||
latitude: mediaLat,
|
||||
longitude: mediaLon,
|
||||
precisao: primeiraLeitura.precisao,
|
||||
altitude: primeiraLeitura.altitude,
|
||||
altitudeAccuracy: null,
|
||||
heading: null,
|
||||
speed: null,
|
||||
confiabilidade: suspeitoPorDistancia ? mediaConfianca * 0.6 : mediaConfianca,
|
||||
suspeitaSpoofing: suspeitoPorDistancia,
|
||||
motivoSuspeita: suspeitoPorDistancia
|
||||
? `Variação muito grande entre leituras (${Math.round(distanciaMaxima)}m)`
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém localização via GPS com múltiplas tentativas e validações anti-spoofing
|
||||
*/
|
||||
async function obterLocalizacao(): Promise<{
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
precisao?: number;
|
||||
altitude?: number | null;
|
||||
altitudeAccuracy?: number | null;
|
||||
heading?: number | null;
|
||||
speed?: number | null;
|
||||
confiabilidadeGPS?: number;
|
||||
suspeitaSpoofing?: boolean;
|
||||
motivoSuspeita?: string;
|
||||
endereco?: string;
|
||||
cidade?: string;
|
||||
estado?: string;
|
||||
pais?: string;
|
||||
}> {
|
||||
if (typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
console.warn('Geolocalização não suportada');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Usar múltiplas leituras para detectar spoofing
|
||||
const localizacaoMultipla = await obterLocalizacaoMultipla();
|
||||
|
||||
if (!localizacaoMultipla.latitude || !localizacaoMultipla.longitude) {
|
||||
console.warn('Não foi possível obter localização');
|
||||
return {
|
||||
confiabilidadeGPS: 0,
|
||||
suspeitaSpoofing: true,
|
||||
motivoSuspeita: 'Não foi possível obter localização'
|
||||
};
|
||||
}
|
||||
|
||||
const { latitude, longitude, precisao, altitude, altitudeAccuracy, heading, speed, confiabilidade, suspeitaSpoofing, motivoSuspeita } = localizacaoMultipla;
|
||||
|
||||
// Tentar obter endereço via reverse geocoding
|
||||
let endereco = '';
|
||||
let cidade = '';
|
||||
let estado = '';
|
||||
let pais = '';
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?format=json&lat=${latitude}&lon=${longitude}&zoom=18&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'SGSE-App/1.0'
|
||||
}
|
||||
}
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
address?: {
|
||||
road?: string;
|
||||
house_number?: string;
|
||||
city?: string;
|
||||
town?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
};
|
||||
};
|
||||
if (data.address) {
|
||||
const addr = data.address;
|
||||
if (addr.road) {
|
||||
endereco = `${addr.road}${addr.house_number ? `, ${addr.house_number}` : ''}`;
|
||||
}
|
||||
cidade = addr.city || addr.town || '';
|
||||
estado = addr.state || '';
|
||||
pais = addr.country || '';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter endereço:', error);
|
||||
}
|
||||
|
||||
// Validar timezone vs localização
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const timezoneAtual = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
const timezoneEsperado = obterTimezonePorCoordenadas(latitude, longitude);
|
||||
|
||||
// Se timezone é muito diferente, pode ser suspeito
|
||||
if (timezoneAtual !== timezoneEsperado && timezoneAtual !== 'America/Recife' && timezoneEsperado !== 'America/Recife') {
|
||||
console.warn(`Timezone inconsistente: esperado ${timezoneEsperado}, atual ${timezoneAtual}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Localização obtida com validações:', {
|
||||
latitude,
|
||||
longitude,
|
||||
confiabilidade: confiabilidade.toFixed(2),
|
||||
suspeitaSpoofing,
|
||||
motivoSuspeita
|
||||
});
|
||||
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
precisao,
|
||||
altitude,
|
||||
altitudeAccuracy,
|
||||
heading,
|
||||
speed,
|
||||
confiabilidadeGPS: confiabilidade,
|
||||
suspeitaSpoofing: suspeitaSpoofing || false,
|
||||
motivoSuspeita,
|
||||
endereco,
|
||||
cidade,
|
||||
estado,
|
||||
pais
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém IP público
|
||||
*/
|
||||
async function obterIPPublico(): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await fetch('https://api.ipify.org?format=json');
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { ip: string };
|
||||
return data.ip;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter IP público:', error);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita permissão para acesso aos sensores de movimento (iOS 13+)
|
||||
*/
|
||||
async function solicitarPermissaoSensor(): Promise<PermissionState> {
|
||||
if (typeof DeviceMotionEvent === 'undefined' || typeof (DeviceMotionEvent as { requestPermission?: () => Promise<PermissionState> }).requestPermission !== 'function') {
|
||||
// Permissão não necessária ou já concedida (navegadores modernos)
|
||||
return 'granted';
|
||||
}
|
||||
|
||||
try {
|
||||
const requestPermission = (DeviceMotionEvent as { requestPermission: () => Promise<PermissionState> }).requestPermission;
|
||||
const resultado = await requestPermission();
|
||||
return resultado;
|
||||
} catch (error) {
|
||||
console.warn('Erro ao solicitar permissão de sensor:', error);
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém dados de acelerômetro e giroscópio durante um período
|
||||
* @param duracaoMs Duração da coleta em milissegundos (padrão: 5000ms = 5 segundos)
|
||||
*/
|
||||
async function obterDadosAcelerometro(duracaoMs: number = 5000): Promise<{
|
||||
acelerometro?: DadosAcelerometro;
|
||||
giroscopio?: DadosGiroscopio;
|
||||
sensorDisponivel: boolean;
|
||||
permissaoNegada: boolean;
|
||||
}> {
|
||||
// Verificar se DeviceMotionEvent está disponível
|
||||
if (typeof DeviceMotionEvent === 'undefined' || typeof DeviceOrientationEvent === 'undefined') {
|
||||
return {
|
||||
sensorDisponivel: false,
|
||||
permissaoNegada: false
|
||||
};
|
||||
}
|
||||
|
||||
// Solicitar permissão (especialmente necessário no iOS 13+)
|
||||
const permissao = await solicitarPermissaoSensor();
|
||||
|
||||
if (permissao === 'denied') {
|
||||
return {
|
||||
sensorDisponivel: true,
|
||||
permissaoNegada: true
|
||||
};
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const leiturasAcelerometro: Array<{ x: number; y: number; z: number; timestamp: number }> = [];
|
||||
const leiturasGiroscopio: Array<{ alpha: number; beta: number; gamma: number; timestamp: number }> = [];
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
window.removeEventListener('devicemotion', handleDeviceMotion);
|
||||
window.removeEventListener('deviceorientation', handleDeviceOrientation);
|
||||
|
||||
// Processar dados de acelerômetro
|
||||
let acelerometro: DadosAcelerometro | undefined;
|
||||
if (leiturasAcelerometro.length > 0) {
|
||||
const ultimaLeitura = leiturasAcelerometro[leiturasAcelerometro.length - 1]!;
|
||||
|
||||
// Calcular magnitude média
|
||||
const magnitudes = leiturasAcelerometro.map(l =>
|
||||
Math.sqrt(l.x * l.x + l.y * l.y + l.z * l.z)
|
||||
);
|
||||
const magnitude = magnitudes.reduce((sum, m) => sum + m, 0) / magnitudes.length;
|
||||
|
||||
// Calcular variância para detectar movimento
|
||||
const mediaX = leiturasAcelerometro.reduce((sum, l) => sum + l.x, 0) / leiturasAcelerometro.length;
|
||||
const mediaY = leiturasAcelerometro.reduce((sum, l) => sum + l.y, 0) / leiturasAcelerometro.length;
|
||||
const mediaZ = leiturasAcelerometro.reduce((sum, l) => sum + l.z, 0) / leiturasAcelerometro.length;
|
||||
|
||||
const variacoes = leiturasAcelerometro.map(l =>
|
||||
Math.pow(l.x - mediaX, 2) + Math.pow(l.y - mediaY, 2) + Math.pow(l.z - mediaZ, 2)
|
||||
);
|
||||
const variacao = variacoes.reduce((sum, v) => sum + v, 0) / variacoes.length;
|
||||
|
||||
// Detectar movimento: se variância > 0.01, há movimento
|
||||
const movimentoDetectado = variacao > 0.01;
|
||||
|
||||
acelerometro = {
|
||||
x: ultimaLeitura.x,
|
||||
y: ultimaLeitura.y,
|
||||
z: ultimaLeitura.z,
|
||||
movimentoDetectado,
|
||||
magnitude,
|
||||
variacao,
|
||||
timestamp: ultimaLeitura.timestamp
|
||||
};
|
||||
}
|
||||
|
||||
// Processar dados de giroscópio
|
||||
let giroscopio: DadosGiroscopio | undefined;
|
||||
if (leiturasGiroscopio.length > 0) {
|
||||
const ultimaLeitura = leiturasGiroscopio[leiturasGiroscopio.length - 1]!;
|
||||
giroscopio = {
|
||||
alpha: ultimaLeitura.alpha || 0,
|
||||
beta: ultimaLeitura.beta || 0,
|
||||
gamma: ultimaLeitura.gamma || 0
|
||||
};
|
||||
}
|
||||
|
||||
resolve({
|
||||
acelerometro,
|
||||
giroscopio,
|
||||
sensorDisponivel: true,
|
||||
permissaoNegada: false
|
||||
});
|
||||
}, duracaoMs);
|
||||
|
||||
function handleDeviceMotion(event: DeviceMotionEvent) {
|
||||
if (event.accelerationIncludingGravity) {
|
||||
const acc = event.accelerationIncludingGravity;
|
||||
if (acc.x !== null && acc.y !== null && acc.z !== null) {
|
||||
leiturasAcelerometro.push({
|
||||
x: acc.x,
|
||||
y: acc.y,
|
||||
z: acc.z,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeviceOrientation(event: DeviceOrientationEvent) {
|
||||
if (event.alpha !== null && event.beta !== null && event.gamma !== null) {
|
||||
leiturasGiroscopio.push({
|
||||
alpha: event.alpha,
|
||||
beta: event.beta,
|
||||
gamma: event.gamma,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('devicemotion', handleDeviceMotion);
|
||||
window.addEventListener('deviceorientation', handleDeviceOrientation);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém todas as informações do dispositivo
|
||||
*/
|
||||
export async function obterInformacoesDispositivo(): Promise<InformacoesDispositivo> {
|
||||
const informacoes: InformacoesDispositivo = {};
|
||||
|
||||
// Informações básicas
|
||||
if (typeof navigator !== 'undefined') {
|
||||
informacoes.userAgent = navigator.userAgent;
|
||||
informacoes.idioma = navigator.language || navigator.languages?.[0];
|
||||
informacoes.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
}
|
||||
|
||||
// Informações do navegador
|
||||
const navegador = detectarNavegador();
|
||||
informacoes.browser = navegador.browser;
|
||||
informacoes.browserVersion = navegador.browserVersion;
|
||||
informacoes.engine = navegador.engine;
|
||||
|
||||
// Informações do sistema
|
||||
const sistema = detectarSistemaOperacional();
|
||||
informacoes.sistemaOperacional = sistema.sistemaOperacional;
|
||||
informacoes.osVersion = sistema.osVersion;
|
||||
informacoes.arquitetura = sistema.arquitetura;
|
||||
informacoes.plataforma = sistema.plataforma;
|
||||
|
||||
// Tipo de dispositivo
|
||||
const dispositivo = detectarTipoDispositivo();
|
||||
informacoes.deviceType = dispositivo.deviceType;
|
||||
informacoes.isMobile = dispositivo.isMobile;
|
||||
informacoes.isTablet = dispositivo.isTablet;
|
||||
informacoes.isDesktop = dispositivo.isDesktop;
|
||||
|
||||
// Informações da tela
|
||||
const tela = obterInformacoesTela();
|
||||
informacoes.screenResolution = tela.screenResolution;
|
||||
informacoes.coresTela = tela.coresTela;
|
||||
|
||||
// Informações de conexão, memória e localização (assíncronas)
|
||||
const [connectionType, memoryInfo, ipPublico, ipLocal, localizacao, dadosSensores] = await Promise.all([
|
||||
obterInformacoesConexao(),
|
||||
Promise.resolve(obterInformacoesMemoria()),
|
||||
obterIPPublico(),
|
||||
getLocalIP(),
|
||||
obterLocalizacao(),
|
||||
obterDadosAcelerometro(5000), // Coletar dados por 5 segundos
|
||||
]);
|
||||
|
||||
informacoes.connectionType = connectionType;
|
||||
informacoes.memoryInfo = memoryInfo;
|
||||
informacoes.ipPublico = ipPublico;
|
||||
informacoes.ipLocal = ipLocal;
|
||||
informacoes.latitude = localizacao.latitude;
|
||||
informacoes.longitude = localizacao.longitude;
|
||||
informacoes.precisao = localizacao.precisao;
|
||||
informacoes.altitude = localizacao.altitude ?? null;
|
||||
informacoes.altitudeAccuracy = localizacao.altitudeAccuracy ?? null;
|
||||
informacoes.heading = localizacao.heading ?? null;
|
||||
informacoes.speed = localizacao.speed ?? null;
|
||||
informacoes.confiabilidadeGPS = localizacao.confiabilidadeGPS;
|
||||
informacoes.suspeitaSpoofing = localizacao.suspeitaSpoofing;
|
||||
informacoes.motivoSuspeita = localizacao.motivoSuspeita;
|
||||
informacoes.endereco = localizacao.endereco;
|
||||
informacoes.cidade = localizacao.cidade;
|
||||
informacoes.estado = localizacao.estado;
|
||||
informacoes.pais = localizacao.pais;
|
||||
|
||||
// Dados de sensores
|
||||
informacoes.acelerometro = dadosSensores.acelerometro;
|
||||
informacoes.giroscopio = dadosSensores.giroscopio;
|
||||
informacoes.sensorDisponivel = dadosSensores.sensorDisponivel;
|
||||
informacoes.permissaoNegada = dadosSensores.permissaoNegada;
|
||||
|
||||
// IP address (usar público se disponível, senão local)
|
||||
informacoes.ipAddress = ipPublico || ipLocal;
|
||||
|
||||
return informacoes;
|
||||
}
|
||||
|
||||
129
apps/web/src/lib/utils/erroHelpers.ts
Normal file
129
apps/web/src/lib/utils/erroHelpers.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Utilitários para tratar e traduzir erros técnicos em mensagens amigáveis ao usuário
|
||||
*/
|
||||
|
||||
export interface MensagemErro {
|
||||
titulo: string;
|
||||
mensagem: string;
|
||||
instrucoes?: string;
|
||||
mostrarDetalhesTecnicos?: boolean;
|
||||
detalhesTecnicos?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Traduzir erros técnicos para mensagens amigáveis ao usuário
|
||||
*/
|
||||
export function traduzirErro(error: unknown): MensagemErro {
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
titulo: 'Erro inesperado',
|
||||
mensagem: 'Ocorreu um erro inesperado. Por favor, tente novamente em alguns instantes.',
|
||||
instrucoes: 'Se o problema persistir, entre em contato com o suporte técnico.',
|
||||
detalhesTecnicos: String(error)
|
||||
};
|
||||
}
|
||||
|
||||
const mensagemErro = error.message.toLowerCase();
|
||||
const erroCompleto = error.message;
|
||||
|
||||
// Erro: Função do Convex não encontrada (servidor não sincronizado)
|
||||
if (mensagemErro.includes('could not find public function')) {
|
||||
return {
|
||||
titulo: 'Servidor em atualização',
|
||||
mensagem: 'O sistema está sendo atualizado no momento. Isso geralmente leva apenas alguns segundos.',
|
||||
instrucoes: 'Por favor, aguarde de 10 a 30 segundos e tente iniciar a chamada novamente. Se o problema persistir, recarregue a página (F5).',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
|
||||
// Erro: Não autenticado
|
||||
if (mensagemErro.includes('não autenticado') || mensagemErro.includes('não autenticado')) {
|
||||
return {
|
||||
titulo: 'Sessão expirada',
|
||||
mensagem: 'Sua sessão expirou. É necessário fazer login novamente para continuar.',
|
||||
instrucoes: 'Você será redirecionado para a tela de login em alguns instantes.',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
|
||||
// Erro: Permissão negada
|
||||
if (
|
||||
mensagemErro.includes('permissão') ||
|
||||
mensagemErro.includes('não tem permissão') ||
|
||||
mensagemErro.includes('não participa')
|
||||
) {
|
||||
return {
|
||||
titulo: 'Acesso negado',
|
||||
mensagem: 'Você não tem permissão para realizar esta ação.',
|
||||
instrucoes: 'Verifique se você faz parte desta conversa ou se possui as permissões necessárias.',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
|
||||
// Erro: Recurso já existe
|
||||
if (
|
||||
mensagemErro.includes('já existe') ||
|
||||
mensagemErro.includes('já está') ||
|
||||
mensagemErro.includes('ativo')
|
||||
) {
|
||||
return {
|
||||
titulo: 'Ação não disponível',
|
||||
mensagem: erroCompleto,
|
||||
instrucoes: 'Verifique o status atual e tente novamente.',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
|
||||
// Erro: Conexão
|
||||
if (
|
||||
mensagemErro.includes('connection') ||
|
||||
mensagemErro.includes('network') ||
|
||||
mensagemErro.includes('conexão') ||
|
||||
mensagemErro.includes('timeout')
|
||||
) {
|
||||
return {
|
||||
titulo: 'Problema de conexão',
|
||||
mensagem: 'Não foi possível conectar com o servidor. Verifique sua conexão com a internet.',
|
||||
instrucoes: 'Verifique se você está conectado à internet e tente novamente. Se o problema persistir, recarregue a página (F5).',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
|
||||
// Erro: Não encontrado
|
||||
if (mensagemErro.includes('não encontrado') || mensagemErro.includes('not found')) {
|
||||
return {
|
||||
titulo: 'Recurso não encontrado',
|
||||
mensagem: 'O item que você está tentando acessar não foi encontrado.',
|
||||
instrucoes: 'Verifique se o item ainda existe ou se foi removido. Recarregue a página (F5) para atualizar a lista.',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
|
||||
// Erro genérico com mensagem do sistema
|
||||
if (erroCompleto && erroCompleto.length < 200) {
|
||||
// Se a mensagem for curta e compreensível, usá-la diretamente
|
||||
const mensagemLimpa = erroCompleto
|
||||
.replace(/\[.*?\]/g, '') // Remove tags como [CONVEX M(...)]
|
||||
.replace(/Request ID:.*/i, '') // Remove Request IDs
|
||||
.trim();
|
||||
|
||||
if (mensagemLimpa.length > 0) {
|
||||
return {
|
||||
titulo: 'Erro ao processar ação',
|
||||
mensagem: mensagemLimpa,
|
||||
instrucoes: 'Por favor, tente novamente. Se o problema persistir, recarregue a página (F5) ou entre em contato com o suporte.',
|
||||
mostrarDetalhesTecnicos: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Erro genérico desconhecido
|
||||
return {
|
||||
titulo: 'Erro ao processar ação',
|
||||
mensagem: 'Ocorreu um erro ao processar sua solicitação. Por favor, tente novamente.',
|
||||
instrucoes: 'Se o problema persistir:\n1. Recarregue a página (pressione F5)\n2. Aguarde alguns instantes e tente novamente\n3. Entre em contato com o suporte técnico se o erro continuar',
|
||||
mostrarDetalhesTecnicos: true,
|
||||
detalhesTecnicos: erroCompleto
|
||||
};
|
||||
}
|
||||
|
||||
397
apps/web/src/lib/utils/floatingWindow.ts
Normal file
397
apps/web/src/lib/utils/floatingWindow.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Utilitários para criar janela flutuante redimensionável e arrastável
|
||||
*/
|
||||
|
||||
export interface PosicaoJanela {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface LimitesJanela {
|
||||
minWidth: number;
|
||||
minHeight: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
}
|
||||
|
||||
function getDefaultLimits(): LimitesJanela {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
maxWidth: 1920,
|
||||
maxHeight: 1080
|
||||
};
|
||||
}
|
||||
return {
|
||||
minWidth: 400,
|
||||
minHeight: 300,
|
||||
maxWidth: window.innerWidth,
|
||||
maxHeight: window.innerHeight
|
||||
};
|
||||
}
|
||||
|
||||
const DEFAULT_LIMITS: LimitesJanela = getDefaultLimits();
|
||||
|
||||
/**
|
||||
* Salvar posição da janela no localStorage
|
||||
*/
|
||||
export function salvarPosicaoJanela(
|
||||
id: string,
|
||||
posicao: PosicaoJanela
|
||||
): void {
|
||||
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const key = `floating-window-${id}`;
|
||||
localStorage.setItem(key, JSON.stringify(posicao));
|
||||
} catch (error) {
|
||||
console.warn('Erro ao salvar posição da janela:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaurar posição da janela do localStorage
|
||||
*/
|
||||
export function restaurarPosicaoJanela(id: string): PosicaoJanela | null {
|
||||
if (typeof window === 'undefined' || typeof localStorage === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const key = `floating-window-${id}`;
|
||||
const saved = localStorage.getItem(key);
|
||||
if (!saved) return null;
|
||||
|
||||
const posicao = JSON.parse(saved) as PosicaoJanela;
|
||||
|
||||
// Validar se a posição ainda é válida (dentro da tela)
|
||||
if (
|
||||
posicao.x >= 0 &&
|
||||
posicao.y >= 0 &&
|
||||
posicao.x + posicao.width <= window.innerWidth + 100 &&
|
||||
posicao.y + posicao.height <= window.innerHeight + 100 &&
|
||||
posicao.width >= DEFAULT_LIMITS.minWidth &&
|
||||
posicao.height >= DEFAULT_LIMITS.minHeight
|
||||
) {
|
||||
return posicao;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn('Erro ao restaurar posição da janela:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter posição inicial da janela (centralizada)
|
||||
*/
|
||||
export function obterPosicaoInicial(
|
||||
width: number = 800,
|
||||
height: number = 600
|
||||
): PosicaoJanela {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: (window.innerWidth - width) / 2,
|
||||
y: (window.innerHeight - height) / 2,
|
||||
width,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar handler de arrastar para janela
|
||||
*/
|
||||
export function criarDragHandler(
|
||||
element: HTMLElement,
|
||||
handle: HTMLElement,
|
||||
onPositionChange?: (x: number, y: number) => void
|
||||
): () => void {
|
||||
let isDragging = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let initialX = 0;
|
||||
let initialY = 0;
|
||||
|
||||
function handleMouseDown(e: MouseEvent): void {
|
||||
if (e.button !== 0) return; // Apenas botão esquerdo
|
||||
|
||||
isDragging = true;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
initialX = rect.left;
|
||||
initialY = rect.top;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isDragging) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
let newX = initialX + deltaX;
|
||||
let newY = initialY + deltaY;
|
||||
|
||||
// Limitar movimento dentro da tela
|
||||
if (typeof window === 'undefined') return;
|
||||
const maxX = window.innerWidth - element.offsetWidth;
|
||||
const maxY = window.innerHeight - element.offsetHeight;
|
||||
|
||||
newX = Math.max(0, Math.min(newX, maxX));
|
||||
newY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
element.style.left = `${newX}px`;
|
||||
element.style.top = `${newY}px`;
|
||||
|
||||
if (onPositionChange) {
|
||||
onPositionChange(newX, newY);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp(): void {
|
||||
isDragging = false;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
// Suporte para touch (mobile)
|
||||
function handleTouchStart(e: TouchEvent): void {
|
||||
if (e.touches.length !== 1) return;
|
||||
|
||||
isDragging = true;
|
||||
const touch = e.touches[0];
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
initialX = rect.left;
|
||||
initialY = rect.top;
|
||||
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent): void {
|
||||
if (!isDragging || e.touches.length !== 1) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const deltaX = touch.clientX - startX;
|
||||
const deltaY = touch.clientY - startY;
|
||||
|
||||
let newX = initialX + deltaX;
|
||||
let newY = initialY + deltaY;
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
const maxX = window.innerWidth - element.offsetWidth;
|
||||
const maxY = window.innerHeight - element.offsetHeight;
|
||||
|
||||
newX = Math.max(0, Math.min(newX, maxX));
|
||||
newY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
element.style.left = `${newX}px`;
|
||||
element.style.top = `${newY}px`;
|
||||
|
||||
if (onPositionChange) {
|
||||
onPositionChange(newX, newY);
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handleTouchEnd(): void {
|
||||
isDragging = false;
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
}
|
||||
|
||||
handle.addEventListener('mousedown', handleMouseDown);
|
||||
handle.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||||
|
||||
// Retornar função de cleanup
|
||||
return () => {
|
||||
handle.removeEventListener('mousedown', handleMouseDown);
|
||||
handle.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Criar handler de redimensionar para janela
|
||||
*/
|
||||
export function criarResizeHandler(
|
||||
element: HTMLElement,
|
||||
handles: HTMLElement[],
|
||||
limites: LimitesJanela = DEFAULT_LIMITS,
|
||||
onSizeChange?: (width: number, height: number) => void
|
||||
): () => void {
|
||||
let isResizing = false;
|
||||
let currentHandle: HTMLElement | null = null;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let startWidth = 0;
|
||||
let startHeight = 0;
|
||||
let startLeft = 0;
|
||||
let startTop = 0;
|
||||
|
||||
function handleMouseDown(e: MouseEvent, handle: HTMLElement): void {
|
||||
if (e.button !== 0) return;
|
||||
|
||||
isResizing = true;
|
||||
currentHandle = handle;
|
||||
startX = e.clientX;
|
||||
startY = e.clientY;
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
startWidth = rect.width;
|
||||
startHeight = rect.height;
|
||||
startLeft = rect.left;
|
||||
startTop = rect.top;
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent): void {
|
||||
if (!isResizing || !currentHandle) return;
|
||||
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
|
||||
let newWidth = startWidth;
|
||||
let newHeight = startHeight;
|
||||
let newLeft = startLeft;
|
||||
let newTop = startTop;
|
||||
|
||||
// Determinar direção do resize baseado na classe do handle
|
||||
const classes = currentHandle.className;
|
||||
|
||||
// Right
|
||||
if (classes.includes('resize-right') || classes.includes('resize-e')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
}
|
||||
|
||||
// Bottom
|
||||
if (classes.includes('resize-bottom') || classes.includes('resize-s')) {
|
||||
newHeight = startHeight + deltaY;
|
||||
}
|
||||
|
||||
// Left
|
||||
if (classes.includes('resize-left') || classes.includes('resize-w')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
newLeft = startLeft + deltaX;
|
||||
}
|
||||
|
||||
// Top
|
||||
if (classes.includes('resize-top') || classes.includes('resize-n')) {
|
||||
newHeight = startHeight - deltaY;
|
||||
newTop = startTop + deltaY;
|
||||
}
|
||||
|
||||
// Corner handles
|
||||
if (classes.includes('resize-se')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
newHeight = startHeight + deltaY;
|
||||
}
|
||||
if (classes.includes('resize-sw')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
newHeight = startHeight + deltaY;
|
||||
newLeft = startLeft + deltaX;
|
||||
}
|
||||
if (classes.includes('resize-ne')) {
|
||||
newWidth = startWidth + deltaX;
|
||||
newHeight = startHeight - deltaY;
|
||||
newTop = startTop + deltaY;
|
||||
}
|
||||
if (classes.includes('resize-nw')) {
|
||||
newWidth = startWidth - deltaX;
|
||||
newHeight = startHeight - deltaY;
|
||||
newLeft = startLeft + deltaX;
|
||||
newTop = startTop + deltaY;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Aplicar limites
|
||||
const maxWidth = limites.maxWidth || window.innerWidth - newLeft;
|
||||
const maxHeight = limites.maxHeight || window.innerHeight - newTop;
|
||||
|
||||
newWidth = Math.max(limites.minWidth, Math.min(newWidth, maxWidth));
|
||||
newHeight = Math.max(limites.minHeight, Math.min(newHeight, maxHeight));
|
||||
|
||||
// Ajustar posição se necessário
|
||||
if (newLeft + newWidth > window.innerWidth) {
|
||||
newLeft = window.innerWidth - newWidth;
|
||||
}
|
||||
if (newTop + newHeight > window.innerHeight) {
|
||||
newTop = window.innerHeight - newHeight;
|
||||
}
|
||||
|
||||
if (newLeft < 0) {
|
||||
newLeft = 0;
|
||||
newWidth = Math.min(newWidth, window.innerWidth);
|
||||
}
|
||||
if (newTop < 0) {
|
||||
newTop = 0;
|
||||
newHeight = Math.min(newHeight, window.innerHeight);
|
||||
}
|
||||
|
||||
element.style.width = `${newWidth}px`;
|
||||
element.style.height = `${newHeight}px`;
|
||||
element.style.left = `${newLeft}px`;
|
||||
element.style.top = `${newTop}px`;
|
||||
|
||||
if (onSizeChange) {
|
||||
onSizeChange(newWidth, newHeight);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseUp(): void {
|
||||
isResizing = false;
|
||||
currentHandle = null;
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
}
|
||||
|
||||
const cleanupFunctions: (() => void)[] = [];
|
||||
|
||||
// Adicionar listeners para cada handle
|
||||
for (const handle of handles) {
|
||||
const handler = (e: MouseEvent) => handleMouseDown(e, handle);
|
||||
handle.addEventListener('mousedown', handler);
|
||||
cleanupFunctions.push(() => handle.removeEventListener('mousedown', handler));
|
||||
}
|
||||
|
||||
// Retornar função de cleanup
|
||||
return () => {
|
||||
cleanupFunctions.forEach((cleanup) => cleanup());
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
335
apps/web/src/lib/utils/jitsi.ts
Normal file
335
apps/web/src/lib/utils/jitsi.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* Utilitários para integração com Jitsi Meet
|
||||
*/
|
||||
|
||||
export interface ConfiguracaoJitsi {
|
||||
domain: string;
|
||||
appId: string;
|
||||
roomPrefix: string;
|
||||
useHttps: boolean;
|
||||
acceptSelfSignedCert?: boolean;
|
||||
}
|
||||
|
||||
export interface DispositivoMedia {
|
||||
deviceId: string;
|
||||
label: string;
|
||||
kind: 'audioinput' | 'audiooutput' | 'videoinput';
|
||||
}
|
||||
|
||||
export interface DispositivosDisponiveis {
|
||||
microphones: DispositivoMedia[];
|
||||
speakers: DispositivoMedia[];
|
||||
cameras: DispositivoMedia[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter configuração do Jitsi do backend ou variáveis de ambiente (fallback)
|
||||
*
|
||||
* @param configBackend - Configuração do backend (opcional). Se fornecida, será usada.
|
||||
* @returns Configuração do Jitsi
|
||||
*/
|
||||
export function obterConfiguracaoJitsi(configBackend?: {
|
||||
domain: string;
|
||||
appId: string;
|
||||
roomPrefix: string;
|
||||
useHttps: boolean;
|
||||
acceptSelfSignedCert?: boolean;
|
||||
} | null): ConfiguracaoJitsi {
|
||||
// Se há configuração do backend e está ativa, usar ela
|
||||
if (configBackend) {
|
||||
return {
|
||||
domain: configBackend.domain,
|
||||
appId: configBackend.appId,
|
||||
roomPrefix: configBackend.roomPrefix,
|
||||
useHttps: configBackend.useHttps,
|
||||
acceptSelfSignedCert: configBackend.acceptSelfSignedCert || false
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback para variáveis de ambiente
|
||||
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
|
||||
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
|
||||
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
|
||||
const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true' || domain.includes(':8443');
|
||||
const acceptSelfSignedCert = import.meta.env.VITE_JITSI_ACCEPT_SELF_SIGNED === 'true';
|
||||
|
||||
return {
|
||||
domain,
|
||||
appId,
|
||||
roomPrefix,
|
||||
useHttps,
|
||||
acceptSelfSignedCert
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter configuração do Jitsi de forma síncrona (apenas variáveis de ambiente)
|
||||
* Use esta função quando não houver acesso ao Convex client
|
||||
*/
|
||||
export function obterConfiguracaoJitsiSync(): ConfiguracaoJitsi {
|
||||
const domain = import.meta.env.VITE_JITSI_DOMAIN || 'localhost:8443';
|
||||
const appId = import.meta.env.VITE_JITSI_APP_ID || 'sgse-app';
|
||||
const roomPrefix = import.meta.env.VITE_JITSI_ROOM_PREFIX || 'sgse';
|
||||
const useHttps = import.meta.env.VITE_JITSI_USE_HTTPS === 'true' || domain.includes(':8443');
|
||||
|
||||
return {
|
||||
domain,
|
||||
appId,
|
||||
roomPrefix,
|
||||
useHttps
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter host e porta separados do domínio
|
||||
*/
|
||||
export function obterHostEPorta(domain: string): { host: string; porta: number } {
|
||||
const [host, portaStr] = domain.split(':');
|
||||
const porta = portaStr ? parseInt(portaStr, 10) : (domain.includes('8443') ? 8443 : 443);
|
||||
return { host: host || 'localhost', porta };
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar nome único para a sala Jitsi
|
||||
*
|
||||
* @param conversaId - ID da conversa
|
||||
* @param tipo - Tipo de chamada ('audio' ou 'video')
|
||||
* @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback.
|
||||
*/
|
||||
export function gerarRoomName(
|
||||
conversaId: string,
|
||||
tipo: 'audio' | 'video',
|
||||
configBackend?: {
|
||||
roomPrefix: string;
|
||||
} | null
|
||||
): string {
|
||||
const config = obterConfiguracaoJitsi(configBackend || undefined);
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 9);
|
||||
const conversaHash = conversaId.replace(/[^a-zA-Z0-9]/g, '').substring(0, 10);
|
||||
|
||||
return `${config.roomPrefix}-${tipo}-${conversaHash}-${timestamp}-${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter URL completa da sala Jitsi
|
||||
*
|
||||
* @param roomName - Nome da sala Jitsi
|
||||
* @param configBackend - Configuração do backend (opcional). Se não fornecida, usa fallback.
|
||||
*/
|
||||
export function obterUrlSala(
|
||||
roomName: string,
|
||||
configBackend?: {
|
||||
domain: string;
|
||||
useHttps: boolean;
|
||||
} | null
|
||||
): string {
|
||||
const config = obterConfiguracaoJitsi(configBackend || undefined);
|
||||
const protocol = config.useHttps ? 'https' : 'http';
|
||||
return `${protocol}://${config.domain}/${roomName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validar se dispositivos de mídia estão disponíveis
|
||||
*/
|
||||
export async function validarDispositivos(): Promise<{
|
||||
microfoneDisponivel: boolean;
|
||||
cameraDisponivel: boolean;
|
||||
}> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
microfoneDisponivel: false,
|
||||
cameraDisponivel: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
const microfoneDisponivel = devices.some(
|
||||
(device) => device.kind === 'audioinput'
|
||||
);
|
||||
const cameraDisponivel = devices.some(
|
||||
(device) => device.kind === 'videoinput'
|
||||
);
|
||||
|
||||
return {
|
||||
microfoneDisponivel,
|
||||
cameraDisponivel
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao validar dispositivos:', error);
|
||||
return {
|
||||
microfoneDisponivel: false,
|
||||
cameraDisponivel: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicitar permissão de acesso aos dispositivos de mídia
|
||||
*/
|
||||
export async function solicitarPermissaoMidia(
|
||||
audio: boolean = true,
|
||||
video: boolean = false
|
||||
): Promise<MediaStream | null> {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio,
|
||||
video: video ? { facingMode: 'user' } : false
|
||||
});
|
||||
return stream;
|
||||
} catch (error) {
|
||||
console.error('Erro ao solicitar permissão de mídia:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter lista de dispositivos de mídia disponíveis
|
||||
*/
|
||||
export async function obterDispositivosDisponiveis(): Promise<DispositivosDisponiveis> {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
microphones: [],
|
||||
speakers: [],
|
||||
cameras: []
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Solicitar permissão primeiro para obter labels dos dispositivos
|
||||
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
|
||||
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
|
||||
const microphones: DispositivoMedia[] = devices
|
||||
.filter((device) => device.kind === 'audioinput')
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Microfone ${device.deviceId.substring(0, 8)}`,
|
||||
kind: 'audioinput' as const
|
||||
}));
|
||||
|
||||
const speakers: DispositivoMedia[] = devices
|
||||
.filter((device) => device.kind === 'audiooutput')
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Alto-falante ${device.deviceId.substring(0, 8)}`,
|
||||
kind: 'audiooutput' as const
|
||||
}));
|
||||
|
||||
const cameras: DispositivoMedia[] = devices
|
||||
.filter((device) => device.kind === 'videoinput')
|
||||
.map((device) => ({
|
||||
deviceId: device.deviceId,
|
||||
label: device.label || `Câmera ${device.deviceId.substring(0, 8)}`,
|
||||
kind: 'videoinput' as const
|
||||
}));
|
||||
|
||||
return {
|
||||
microphones,
|
||||
speakers,
|
||||
cameras
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter dispositivos disponíveis:', error);
|
||||
return {
|
||||
microphones: [],
|
||||
speakers: [],
|
||||
cameras: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configurar dispositivo de áudio de saída (alto-falante)
|
||||
*/
|
||||
export async function configurarAltoFalante(
|
||||
deviceId: string,
|
||||
audioElement: HTMLAudioElement
|
||||
): Promise<boolean> {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - setSinkId pode não estar disponível em todos os navegadores
|
||||
if (audioElement.setSinkId && typeof audioElement.setSinkId === 'function') {
|
||||
await audioElement.setSinkId(deviceId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Erro ao configurar alto-falante:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se WebRTC está disponível no navegador
|
||||
*/
|
||||
export function verificarSuporteWebRTC(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !!(
|
||||
navigator.mediaDevices &&
|
||||
navigator.mediaDevices.getUserMedia &&
|
||||
window.RTCPeerConnection
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter informações do navegador para debug
|
||||
*/
|
||||
export function obterInfoNavegador(): {
|
||||
navegador: string;
|
||||
versao: string;
|
||||
webrtcSuportado: boolean;
|
||||
mediaDevicesDisponivel: boolean;
|
||||
} {
|
||||
if (typeof window === 'undefined') {
|
||||
return {
|
||||
navegador: 'Servidor',
|
||||
versao: 'N/A',
|
||||
webrtcSuportado: false,
|
||||
mediaDevicesDisponivel: false
|
||||
};
|
||||
}
|
||||
|
||||
const userAgent = navigator.userAgent;
|
||||
let navegador = 'Desconhecido';
|
||||
let versao = 'Desconhecida';
|
||||
|
||||
if (userAgent.indexOf('Chrome') > -1) {
|
||||
navegador = 'Chrome';
|
||||
const match = userAgent.match(/Chrome\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
} else if (userAgent.indexOf('Firefox') > -1) {
|
||||
navegador = 'Firefox';
|
||||
const match = userAgent.match(/Firefox\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
} else if (userAgent.indexOf('Safari') > -1) {
|
||||
navegador = 'Safari';
|
||||
const match = userAgent.match(/Version\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
} else if (userAgent.indexOf('Edge') > -1) {
|
||||
navegador = 'Edge';
|
||||
const match = userAgent.match(/Edge\/(\d+)/);
|
||||
versao = match ? match[1] : 'Desconhecida';
|
||||
}
|
||||
|
||||
return {
|
||||
navegador,
|
||||
versao,
|
||||
webrtcSuportado: verificarSuporteWebRTC(),
|
||||
mediaDevicesDisponivel: !!navigator.mediaDevices
|
||||
};
|
||||
}
|
||||
|
||||
82
apps/web/src/lib/utils/jitsiPolyfill.ts
Normal file
82
apps/web/src/lib/utils/jitsiPolyfill.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Polyfill global para BlobBuilder
|
||||
* Deve ser executado ANTES de qualquer import de lib-jitsi-meet
|
||||
*
|
||||
* BlobBuilder é uma API antiga dos navegadores que foi substituída pelo construtor Blob
|
||||
* A biblioteca lib-jitsi-meet pode tentar usar BlobBuilder em navegadores modernos
|
||||
*/
|
||||
|
||||
export function adicionarBlobBuilderPolyfill(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Verificar se já foi adicionado (evitar múltiplas execuções)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
if ((window as any).__blobBuilderPolyfillAdded) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Implementar BlobBuilder usando Blob moderno
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const BlobBuilderClass = class BlobBuilder {
|
||||
private parts: BlobPart[] = [];
|
||||
|
||||
append(data: BlobPart): void {
|
||||
this.parts.push(data);
|
||||
}
|
||||
|
||||
getBlob(contentType?: string): Blob {
|
||||
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
// Adicionar em todos os possíveis locais onde a biblioteca pode procurar
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const win = window as any;
|
||||
|
||||
// Definir BlobBuilder se não existir
|
||||
if (typeof win.BlobBuilder === 'undefined') {
|
||||
win.BlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
|
||||
// Variantes de navegadores antigos
|
||||
if (typeof win.WebKitBlobBuilder === 'undefined') {
|
||||
win.WebKitBlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
|
||||
if (typeof win.MozBlobBuilder === 'undefined') {
|
||||
win.MozBlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
|
||||
if (typeof win.MSBlobBuilder === 'undefined') {
|
||||
win.MSBlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
|
||||
// Adicionar no global scope
|
||||
if (typeof globalThis !== 'undefined') {
|
||||
if (typeof (globalThis as any).BlobBuilder === 'undefined') {
|
||||
(globalThis as any).BlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
if (typeof (globalThis as any).WebKitBlobBuilder === 'undefined') {
|
||||
(globalThis as any).WebKitBlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
if (typeof (globalThis as any).MozBlobBuilder === 'undefined') {
|
||||
(globalThis as any).MozBlobBuilder = BlobBuilderClass;
|
||||
}
|
||||
}
|
||||
|
||||
// Marcar que o polyfill foi adicionado
|
||||
win.__blobBuilderPolyfillAdded = true;
|
||||
|
||||
console.log('✅ Polyfill BlobBuilder adicionado globalmente');
|
||||
}
|
||||
|
||||
// Executar imediatamente se estiver no browser
|
||||
if (typeof window !== 'undefined') {
|
||||
adicionarBlobBuilderPolyfill();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,16 @@ export const maskCEP = (value: string): string => {
|
||||
return digits.replace(/(\d{5})(\d{1,3})$/, "$1-$2");
|
||||
};
|
||||
|
||||
/** Format CNPJ: 00.000.000/0000-00 */
|
||||
export const maskCNPJ = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 14);
|
||||
return digits
|
||||
.replace(/(\d{2})(\d)/, "$1.$2")
|
||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
||||
.replace(/(\d{3})(\d)/, "$1/$2")
|
||||
.replace(/(\d{4})(\d{1,2})$/, "$1-$2");
|
||||
};
|
||||
|
||||
/** Format phone: (00) 0000-0000 or (00) 00000-0000 */
|
||||
export const maskPhone = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 11);
|
||||
|
||||
332
apps/web/src/lib/utils/mediaRecorder.ts
Normal file
332
apps/web/src/lib/utils/mediaRecorder.ts
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* Utilitários para gravação de mídia usando MediaRecorder API
|
||||
*/
|
||||
|
||||
export interface OpcoesGravacao {
|
||||
audioBitsPerSecond?: number;
|
||||
videoBitsPerSecond?: number;
|
||||
mimeType?: string;
|
||||
}
|
||||
|
||||
export interface ResultadoGravacao {
|
||||
blob: Blob;
|
||||
duracaoSegundos: number;
|
||||
nomeArquivo: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verificar se MediaRecorder está disponível no navegador
|
||||
*/
|
||||
export function verificarSuporteMediaRecorder(): boolean {
|
||||
return typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tipos MIME suportados para gravação
|
||||
*/
|
||||
export function obterTiposMimeSuportados(): {
|
||||
video: string[];
|
||||
audio: string[];
|
||||
} {
|
||||
if (!verificarSuporteMediaRecorder()) {
|
||||
return { video: [], audio: [] };
|
||||
}
|
||||
|
||||
const tiposVideo: string[] = [];
|
||||
const tiposAudio: string[] = [];
|
||||
|
||||
// Tipos comuns de vídeo
|
||||
const tiposVideoComuns = [
|
||||
'video/webm',
|
||||
'video/webm;codecs=vp9',
|
||||
'video/webm;codecs=vp8',
|
||||
'video/webm;codecs=h264',
|
||||
'video/mp4',
|
||||
'video/ogg',
|
||||
'video/x-matroska'
|
||||
];
|
||||
|
||||
// Tipos comuns de áudio
|
||||
const tiposAudioComuns = [
|
||||
'audio/webm',
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/ogg',
|
||||
'audio/mp4',
|
||||
'audio/mpeg'
|
||||
];
|
||||
|
||||
for (const tipo of tiposVideoComuns) {
|
||||
if (MediaRecorder.isTypeSupported(tipo)) {
|
||||
tiposVideo.push(tipo);
|
||||
}
|
||||
}
|
||||
|
||||
for (const tipo of tiposAudioComuns) {
|
||||
if (MediaRecorder.isTypeSupported(tipo)) {
|
||||
tiposAudio.push(tipo);
|
||||
}
|
||||
}
|
||||
|
||||
return { video: tiposVideo, audio: tiposAudio };
|
||||
}
|
||||
|
||||
/**
|
||||
* Iniciar gravação de áudio apenas
|
||||
*/
|
||||
export function iniciarGravacaoAudio(
|
||||
stream: MediaStream,
|
||||
opcoes?: OpcoesGravacao
|
||||
): MediaRecorder | null {
|
||||
if (!verificarSuporteMediaRecorder()) {
|
||||
console.error('MediaRecorder não está disponível neste navegador');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tiposAudio = obterTiposMimeSuportados().audio;
|
||||
const mimeType = opcoes?.mimeType || tiposAudio[0] || 'audio/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: opcoes?.audioBitsPerSecond || 128000
|
||||
});
|
||||
|
||||
return recorder;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação de áudio:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Iniciar gravação de vídeo (áudio + vídeo)
|
||||
*/
|
||||
export function iniciarGravacaoVideo(
|
||||
stream: MediaStream,
|
||||
opcoes?: OpcoesGravacao
|
||||
): MediaRecorder | null {
|
||||
if (!verificarSuporteMediaRecorder()) {
|
||||
console.error('MediaRecorder não está disponível neste navegador');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const tiposVideo = obterTiposMimeSuportados().video;
|
||||
const mimeType = opcoes?.mimeType || tiposVideo[0] || 'video/webm';
|
||||
|
||||
const recorder = new MediaRecorder(stream, {
|
||||
mimeType,
|
||||
audioBitsPerSecond: opcoes?.audioBitsPerSecond || 128000,
|
||||
videoBitsPerSecond: opcoes?.videoBitsPerSecond || 2500000
|
||||
});
|
||||
|
||||
return recorder;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação de vídeo:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parar gravação e retornar blob
|
||||
*/
|
||||
export function pararGravacao(recorder: MediaRecorder): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: BlobPart[] = [];
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
recorder.onstop = () => {
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType });
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
recorder.onerror = (event) => {
|
||||
console.error('Erro na gravação:', event);
|
||||
reject(new Error('Erro ao parar gravação'));
|
||||
};
|
||||
|
||||
if (recorder.state === 'recording') {
|
||||
recorder.stop();
|
||||
} else {
|
||||
reject(new Error('Recorder não está gravando'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Salvar gravação localmente
|
||||
*/
|
||||
export function salvarGravacao(
|
||||
blob: Blob,
|
||||
nomeArquivo: string
|
||||
): void {
|
||||
try {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = nomeArquivo;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar gravação:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gerar nome de arquivo para gravação
|
||||
*/
|
||||
export function gerarNomeArquivo(
|
||||
tipo: 'audio' | 'video',
|
||||
roomName: string,
|
||||
timestamp?: number
|
||||
): string {
|
||||
const agora = timestamp || Date.now();
|
||||
const data = new Date(agora);
|
||||
const dataFormatada = data.toISOString().replace(/[:.]/g, '-').split('T')[0];
|
||||
const horaFormatada = data.toLocaleTimeString('pt-BR', { hour12: false }).replace(/:/g, '-');
|
||||
const extensao = tipo === 'audio' ? 'webm' : 'webm';
|
||||
|
||||
return `gravacao-${tipo}-${roomName}-${dataFormatada}-${horaFormatada}.${extensao}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tamanho do blob em formato legível
|
||||
*/
|
||||
export function formatarTamanhoBlob(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcular duração de gravação (em segundos)
|
||||
*/
|
||||
export function calcularDuracaoGravacao(
|
||||
inicioTimestamp: number,
|
||||
fimTimestamp?: number
|
||||
): number {
|
||||
const fim = fimTimestamp || Date.now();
|
||||
return Math.floor((fim - inicioTimestamp) / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gravar com controle completo
|
||||
*/
|
||||
export class GravadorMedia {
|
||||
private recorder: MediaRecorder | null = null;
|
||||
private stream: MediaStream | null = null;
|
||||
private inicioTimestamp: number = 0;
|
||||
private chunks: BlobPart[] = [];
|
||||
|
||||
constructor(
|
||||
private streamOriginal: MediaStream,
|
||||
private tipo: 'audio' | 'video',
|
||||
private opcoes?: OpcoesGravacao
|
||||
) {
|
||||
this.stream = streamOriginal;
|
||||
}
|
||||
|
||||
iniciar(): boolean {
|
||||
if (this.recorder && this.recorder.state === 'recording') {
|
||||
console.warn('Gravação já está em andamento');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
this.recorder =
|
||||
this.tipo === 'audio'
|
||||
? iniciarGravacaoAudio(this.stream!, this.opcoes)
|
||||
: iniciarGravacaoVideo(this.stream!, this.opcoes);
|
||||
|
||||
if (!this.recorder) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.chunks = [];
|
||||
this.inicioTimestamp = Date.now();
|
||||
|
||||
this.recorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0) {
|
||||
this.chunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
this.recorder.start(1000); // Coletar dados a cada segundo
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar gravação:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
parar(): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.recorder) {
|
||||
reject(new Error('Recorder não foi inicializado'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.recorder.state === 'inactive') {
|
||||
// Se já parou, retornar blob dos chunks
|
||||
if (this.chunks.length > 0) {
|
||||
const blob = new Blob(this.chunks, { type: this.recorder.mimeType });
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Nenhum dado gravado'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.recorder.onstop = () => {
|
||||
const blob = new Blob(this.chunks, { type: this.recorder!.mimeType });
|
||||
resolve(blob);
|
||||
};
|
||||
|
||||
this.recorder.onerror = (event) => {
|
||||
console.error('Erro na gravação:', event);
|
||||
reject(new Error('Erro ao parar gravação'));
|
||||
};
|
||||
|
||||
this.recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
obterDuracaoSegundos(): number {
|
||||
if (this.inicioTimestamp === 0) return 0;
|
||||
return calcularDuracaoGravacao(this.inicioTimestamp);
|
||||
}
|
||||
|
||||
estaGravando(): boolean {
|
||||
return this.recorder?.state === 'recording';
|
||||
}
|
||||
|
||||
liberar(): void {
|
||||
if (this.recorder && this.recorder.state === 'recording') {
|
||||
this.recorder.stop();
|
||||
}
|
||||
|
||||
// Parar todas as tracks do stream
|
||||
if (this.stream) {
|
||||
this.stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
|
||||
this.recorder = null;
|
||||
this.stream = null;
|
||||
this.chunks = [];
|
||||
this.inicioTimestamp = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
158
apps/web/src/lib/utils/ponto.ts
Normal file
158
apps/web/src/lib/utils/ponto.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Formata hora no formato HH:mm
|
||||
*/
|
||||
export function formatarHoraPonto(hora: number, minuto: number): string {
|
||||
return `${hora.toString().padStart(2, '0')}:${minuto.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata data e hora completa
|
||||
*/
|
||||
export function formatarDataHoraCompleta(
|
||||
data: string,
|
||||
hora: number,
|
||||
minuto: number,
|
||||
segundo: number
|
||||
): string {
|
||||
const dataObj = new Date(`${data}T${formatarHoraPonto(hora, minuto)}:${segundo.toString().padStart(2, '0')}`);
|
||||
return dataObj.toLocaleString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula tempo trabalhado entre dois registros
|
||||
*/
|
||||
export function calcularTempoTrabalhado(
|
||||
horaInicio: number,
|
||||
minutoInicio: number,
|
||||
horaFim: number,
|
||||
minutoFim: number
|
||||
): { horas: number; minutos: number } {
|
||||
const minutosInicio = horaInicio * 60 + minutoInicio;
|
||||
const minutosFim = horaFim * 60 + minutoFim;
|
||||
const diferencaMinutos = minutosFim - minutosInicio;
|
||||
|
||||
if (diferencaMinutos < 0) {
|
||||
return { horas: 0, minutos: 0 };
|
||||
}
|
||||
|
||||
const horas = Math.floor(diferencaMinutos / 60);
|
||||
const minutos = diferencaMinutos % 60;
|
||||
|
||||
return { horas, minutos };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica se está dentro do prazo baseado na configuração
|
||||
*/
|
||||
export function verificarDentroDoPrazo(
|
||||
hora: number,
|
||||
minuto: number,
|
||||
horarioConfigurado: string,
|
||||
toleranciaMinutos: number
|
||||
): boolean {
|
||||
const [horaConfig, minutoConfig] = horarioConfigurado.split(':').map(Number);
|
||||
const totalMinutosRegistro = hora * 60 + minuto;
|
||||
const totalMinutosConfigurado = horaConfig * 60 + minutoConfig;
|
||||
const diferenca = totalMinutosRegistro - totalMinutosConfigurado;
|
||||
return diferenca <= toleranciaMinutos && diferenca >= -toleranciaMinutos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém label do tipo de registro
|
||||
* Se config fornecida, usa os nomes personalizados, senão usa os padrões
|
||||
*/
|
||||
export function getTipoRegistroLabel(
|
||||
tipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida',
|
||||
config?: {
|
||||
nomeEntrada?: string;
|
||||
nomeSaidaAlmoco?: string;
|
||||
nomeRetornoAlmoco?: string;
|
||||
nomeSaida?: string;
|
||||
}
|
||||
): string {
|
||||
// Se config fornecida, usar nomes personalizados
|
||||
if (config) {
|
||||
const labels: Record<string, string> = {
|
||||
entrada: config.nomeEntrada || 'Entrada 1',
|
||||
saida_almoco: config.nomeSaidaAlmoco || 'Saída 1',
|
||||
retorno_almoco: config.nomeRetornoAlmoco || 'Entrada 2',
|
||||
saida: config.nomeSaida || 'Saída 2',
|
||||
};
|
||||
return labels[tipo] || tipo;
|
||||
}
|
||||
|
||||
// Valores padrão
|
||||
const labels: Record<string, string> = {
|
||||
entrada: 'Entrada 1',
|
||||
saida_almoco: 'Saída 1',
|
||||
retorno_almoco: 'Entrada 2',
|
||||
saida: 'Saída 2',
|
||||
};
|
||||
return labels[tipo] || tipo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém próximo tipo de registro esperado
|
||||
*/
|
||||
export function getProximoTipoRegistro(
|
||||
ultimoTipo: 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' | null
|
||||
): 'entrada' | 'saida_almoco' | 'retorno_almoco' | 'saida' {
|
||||
if (!ultimoTipo) {
|
||||
return 'entrada';
|
||||
}
|
||||
|
||||
switch (ultimoTipo) {
|
||||
case 'entrada':
|
||||
return 'saida_almoco';
|
||||
case 'saida_almoco':
|
||||
return 'retorno_almoco';
|
||||
case 'retorno_almoco':
|
||||
return 'saida';
|
||||
case 'saida':
|
||||
return 'entrada'; // Novo dia
|
||||
default:
|
||||
return 'entrada';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formata data no formato DD/MM/AAAA
|
||||
* Suporta strings ISO (YYYY-MM-DD), objetos Date, e timestamps
|
||||
*/
|
||||
export function formatarDataDDMMAAAA(data: string | Date | number): string {
|
||||
if (!data) return '';
|
||||
|
||||
let dataObj: Date;
|
||||
|
||||
if (typeof data === 'string') {
|
||||
// Se for string no formato ISO (YYYY-MM-DD), adicionar hora para evitar problemas de timezone
|
||||
if (data.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
dataObj = new Date(data + 'T12:00:00');
|
||||
} else {
|
||||
dataObj = new Date(data);
|
||||
}
|
||||
} else if (typeof data === 'number') {
|
||||
dataObj = new Date(data);
|
||||
} else {
|
||||
dataObj = data;
|
||||
}
|
||||
|
||||
// Verificar se a data é válida
|
||||
if (isNaN(dataObj.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const dia = dataObj.getDate().toString().padStart(2, '0');
|
||||
const mes = (dataObj.getMonth() + 1).toString().padStart(2, '0');
|
||||
const ano = dataObj.getFullYear();
|
||||
|
||||
return `${dia}/${mes}/${ano}`;
|
||||
}
|
||||
|
||||
56
apps/web/src/lib/utils/sincronizacaoTempo.ts
Normal file
56
apps/web/src/lib/utils/sincronizacaoTempo.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { ConvexClient } from 'convex/browser';
|
||||
|
||||
/**
|
||||
* Obtém tempo do servidor (sincronizado)
|
||||
*/
|
||||
export async function obterTempoServidor(client: ConvexClient): Promise<number> {
|
||||
try {
|
||||
// Tentar obter configuração e sincronizar se necessário
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
return resultado.timestamp;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao sincronizar com servidor externo:', error);
|
||||
if (config.fallbackParaPC) {
|
||||
return Date.now();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Usar tempo do servidor Convex
|
||||
const tempoServidor = await client.query(api.configuracaoRelogio.obterTempoServidor, {});
|
||||
return tempoServidor.timestamp;
|
||||
} catch (error) {
|
||||
console.warn('Erro ao obter tempo do servidor, usando tempo local:', error);
|
||||
return Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtém tempo do PC (fallback)
|
||||
*/
|
||||
export function obterTempoPC(): number {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calcula offset entre dois timestamps
|
||||
*/
|
||||
export function calcularOffset(timestampServidor: number, timestampLocal: number): number {
|
||||
return timestampServidor - timestampLocal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica offset a um timestamp
|
||||
*/
|
||||
export function aplicarOffset(timestamp: number, offsetSegundos: number): number {
|
||||
return timestamp + offsetSegundos * 1000;
|
||||
}
|
||||
|
||||
288
apps/web/src/lib/utils/temas.ts
Normal file
288
apps/web/src/lib/utils/temas.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* Utilitário para gerenciamento de temas personalizados do SGSE
|
||||
*/
|
||||
|
||||
export type TemaId =
|
||||
| 'purple'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'orange'
|
||||
| 'red'
|
||||
| 'pink'
|
||||
| 'teal'
|
||||
| 'dark'
|
||||
| 'light'
|
||||
| 'corporate';
|
||||
|
||||
export interface Tema {
|
||||
id: TemaId;
|
||||
nome: string;
|
||||
descricao: string;
|
||||
corPrimaria: string;
|
||||
corSecundaria: string;
|
||||
corGradiente: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista de temas disponíveis
|
||||
*/
|
||||
export const temasDisponiveis: Tema[] = [
|
||||
{
|
||||
id: 'purple',
|
||||
nome: 'Roxo',
|
||||
descricao: 'Tema padrão com cores roxas e azuis',
|
||||
corPrimaria: '#764ba2',
|
||||
corSecundaria: '#667eea',
|
||||
corGradiente: 'from-purple-600 via-blue-600 to-indigo-700'
|
||||
},
|
||||
{
|
||||
id: 'blue',
|
||||
nome: 'Azul',
|
||||
descricao: 'Tema azul clássico e profissional',
|
||||
corPrimaria: '#2563eb',
|
||||
corSecundaria: '#3b82f6',
|
||||
corGradiente: 'from-blue-500 via-blue-600 to-blue-700'
|
||||
},
|
||||
{
|
||||
id: 'green',
|
||||
nome: 'Verde',
|
||||
descricao: 'Tema verde natural e harmonioso',
|
||||
corPrimaria: '#10b981',
|
||||
corSecundaria: '#059669',
|
||||
corGradiente: 'from-green-500 via-emerald-600 to-teal-700'
|
||||
},
|
||||
{
|
||||
id: 'orange',
|
||||
nome: 'Laranja',
|
||||
descricao: 'Tema laranja vibrante e energético',
|
||||
corPrimaria: '#f97316',
|
||||
corSecundaria: '#ea580c',
|
||||
corGradiente: 'from-orange-500 via-amber-600 to-orange-700'
|
||||
},
|
||||
{
|
||||
id: 'red',
|
||||
nome: 'Vermelho',
|
||||
descricao: 'Tema vermelho intenso e impactante',
|
||||
corPrimaria: '#ef4444',
|
||||
corSecundaria: '#dc2626',
|
||||
corGradiente: 'from-red-500 via-rose-600 to-red-700'
|
||||
},
|
||||
{
|
||||
id: 'pink',
|
||||
nome: 'Rosa',
|
||||
descricao: 'Tema rosa suave e elegante',
|
||||
corPrimaria: '#ec4899',
|
||||
corSecundaria: '#db2777',
|
||||
corGradiente: 'from-pink-500 via-rose-600 to-fuchsia-700'
|
||||
},
|
||||
{
|
||||
id: 'teal',
|
||||
nome: 'Verde-água',
|
||||
descricao: 'Tema verde-água refrescante',
|
||||
corPrimaria: '#14b8a6',
|
||||
corSecundaria: '#0d9488',
|
||||
corGradiente: 'from-teal-500 via-cyan-600 to-teal-700'
|
||||
},
|
||||
{
|
||||
id: 'dark',
|
||||
nome: 'Escuro',
|
||||
descricao: 'Tema escuro para uso noturno',
|
||||
corPrimaria: '#1e293b',
|
||||
corSecundaria: '#0f172a',
|
||||
corGradiente: 'from-slate-800 via-gray-900 to-slate-900'
|
||||
},
|
||||
{
|
||||
id: 'light',
|
||||
nome: 'Claro',
|
||||
descricao: 'Tema claro e minimalista',
|
||||
corPrimaria: '#f8fafc',
|
||||
corSecundaria: '#e2e8f0',
|
||||
corGradiente: 'from-gray-100 via-slate-200 to-gray-300'
|
||||
},
|
||||
{
|
||||
id: 'corporate',
|
||||
nome: 'Corporativo',
|
||||
descricao: 'Tema corporativo azul escuro',
|
||||
corPrimaria: '#1e40af',
|
||||
corSecundaria: '#1e3a8a',
|
||||
corGradiente: 'from-blue-800 via-indigo-900 to-blue-900'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Mapeamento de temas para nomes do DaisyUI
|
||||
* Usamos temas nativos do DaisyUI quando disponíveis, ou temas customizados SGSE
|
||||
*/
|
||||
export const temaParaDaisyUI: Record<TemaId, string> = {
|
||||
purple: 'aqua', // Tema padrão atual (roxo/azul) - nativo DaisyUI
|
||||
blue: 'sgse-blue', // Azul - customizado
|
||||
green: 'sgse-green', // Verde - customizado
|
||||
orange: 'sgse-orange', // Laranja - customizado
|
||||
red: 'sgse-red', // Vermelho - customizado
|
||||
pink: 'sgse-pink', // Rosa - customizado
|
||||
teal: 'sgse-teal', // Verde-água - customizado
|
||||
dark: 'dark', // Escuro - nativo DaisyUI
|
||||
light: 'light', // Claro - nativo DaisyUI
|
||||
corporate: 'sgse-corporate' // Corporativo - customizado
|
||||
};
|
||||
|
||||
/**
|
||||
* Obter tema por ID
|
||||
*/
|
||||
export function obterTema(id: TemaId | string | null | undefined): Tema | null {
|
||||
if (!id) return null;
|
||||
return temasDisponiveis.find((t) => t.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter nome do tema DaisyUI correspondente
|
||||
*/
|
||||
export function obterNomeDaisyUI(id: TemaId | string | null | undefined): string {
|
||||
if (!id) return 'aqua'; // Tema padrão
|
||||
const tema = obterTema(id);
|
||||
if (!tema) return 'aqua';
|
||||
return temaParaDaisyUI[tema.id] || 'aqua';
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplicar tema ao documento HTML
|
||||
* NÃO salva no localStorage - apenas no banco de dados do usuário
|
||||
*/
|
||||
export function aplicarTema(temaId: TemaId | string | null | undefined): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
const nomeDaisyUI = obterNomeDaisyUI(temaId || 'purple');
|
||||
const htmlElement = document.documentElement;
|
||||
const bodyElement = document.body;
|
||||
|
||||
if (htmlElement) {
|
||||
// Remover todos os atributos data-theme existentes primeiro
|
||||
htmlElement.removeAttribute('data-theme');
|
||||
if (bodyElement) {
|
||||
bodyElement.removeAttribute('data-theme');
|
||||
}
|
||||
|
||||
// Aplicar o novo tema
|
||||
htmlElement.setAttribute('data-theme', nomeDaisyUI);
|
||||
if (bodyElement) {
|
||||
bodyElement.setAttribute('data-theme', nomeDaisyUI);
|
||||
}
|
||||
|
||||
// Forçar reflow para garantir que o CSS seja aplicado
|
||||
void htmlElement.offsetHeight;
|
||||
|
||||
// Forçar atualização de todas as variáveis CSS
|
||||
// Isso garante que os temas customizados sejam aplicados corretamente
|
||||
if (typeof window !== 'undefined' && window.getComputedStyle) {
|
||||
const computedStyle = window.getComputedStyle(htmlElement);
|
||||
// Forçar recálculo das variáveis CSS
|
||||
computedStyle.getPropertyValue('--p');
|
||||
}
|
||||
|
||||
// Disparar evento customizado para notificar mudança de tema
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('themechange', { detail: { theme: nomeDaisyUI } }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplicar tema padrão (roxo)
|
||||
*/
|
||||
export function aplicarTemaPadrao(): void {
|
||||
aplicarTema('purple');
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter tema padrão
|
||||
*/
|
||||
export function obterTemaPadrao(): Tema {
|
||||
return temasDisponiveis[0]; // Purple
|
||||
}
|
||||
|
||||
/**
|
||||
* Obter cores CSS do tema atual para usar em gráficos e componentes
|
||||
*/
|
||||
export function obterCoresDoTema(): {
|
||||
primary: string;
|
||||
success: string;
|
||||
error: string;
|
||||
warning: string;
|
||||
info: string;
|
||||
baseContent: string;
|
||||
base100: string;
|
||||
base200: string;
|
||||
base300: string;
|
||||
} {
|
||||
if (typeof window === 'undefined' || typeof document === 'undefined') {
|
||||
// Valores padrão para SSR
|
||||
return {
|
||||
primary: '#667eea',
|
||||
success: '#10b981',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6',
|
||||
baseContent: '#1f2937',
|
||||
base100: '#ffffff',
|
||||
base200: '#f3f4f6',
|
||||
base300: '#e5e7eb'
|
||||
};
|
||||
}
|
||||
|
||||
const htmlElement = document.documentElement;
|
||||
const getComputedStyle = (varName: string): string => {
|
||||
return getComputedStyle(htmlElement).getPropertyValue(varName).trim() || '';
|
||||
};
|
||||
|
||||
// Tentar obter variáveis CSS do DaisyUI
|
||||
const primary = getComputedStyle('--p') || '#667eea';
|
||||
const success = getComputedStyle('--suc') || '#10b981';
|
||||
const error = getComputedStyle('--er') || '#ef4444';
|
||||
const warning = getComputedStyle('--wa') || '#f59e0b';
|
||||
const info = getComputedStyle('--in') || '#3b82f6';
|
||||
const baseContent = getComputedStyle('--bc') || '#1f2937';
|
||||
const base100 = getComputedStyle('--b1') || '#ffffff';
|
||||
const base200 = getComputedStyle('--b2') || '#f3f4f6';
|
||||
const base300 = getComputedStyle('--b3') || '#e5e7eb';
|
||||
|
||||
return {
|
||||
primary: primary || '#667eea',
|
||||
success: success || '#10b981',
|
||||
error: error || '#ef4444',
|
||||
warning: warning || '#f59e0b',
|
||||
info: info || '#3b82f6',
|
||||
baseContent: baseContent || '#1f2937',
|
||||
base100: base100 || '#ffffff',
|
||||
base200: base200 || '#f3f4f6',
|
||||
base300: base300 || '#e5e7eb'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter cor hex para rgba
|
||||
*/
|
||||
export function hexToRgba(hex: string, alpha: number = 1): string {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return `rgba(102, 126, 234, ${alpha})`;
|
||||
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converter cor hex para rgb
|
||||
*/
|
||||
export function hexToRgb(hex: string): string {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
if (!result) return 'rgb(102, 126, 234)';
|
||||
|
||||
const r = parseInt(result[1], 16);
|
||||
const g = parseInt(result[2], 16);
|
||||
const b = parseInt(result[3], 16);
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
150
apps/web/src/lib/utils/webcam.ts
Normal file
150
apps/web/src/lib/utils/webcam.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Verifica se webcam está disponível
|
||||
*/
|
||||
export async function validarWebcamDisponivel(): Promise<boolean> {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
return devices.some((device) => device.kind === 'videoinput');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captura imagem da webcam
|
||||
*/
|
||||
export async function capturarWebcam(): Promise<Blob | null> {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
try {
|
||||
// Solicitar acesso à webcam
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
// Criar elemento de vídeo temporário
|
||||
const video = document.createElement('video');
|
||||
video.srcObject = stream;
|
||||
video.play();
|
||||
|
||||
// Aguardar vídeo estar pronto
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
video.onloadedmetadata = () => {
|
||||
video.width = video.videoWidth;
|
||||
video.height = video.videoHeight;
|
||||
resolve();
|
||||
};
|
||||
video.onerror = reject;
|
||||
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
|
||||
});
|
||||
|
||||
// Capturar frame
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Não foi possível obter contexto do canvas');
|
||||
}
|
||||
|
||||
ctx.drawImage(video, 0, 0);
|
||||
|
||||
// Converter para blob
|
||||
return await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
resolve(blob);
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao capturar webcam:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// Parar stream
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Captura imagem da webcam com preview
|
||||
*/
|
||||
export async function capturarWebcamComPreview(
|
||||
videoElement: HTMLVideoElement,
|
||||
canvasElement: HTMLCanvasElement
|
||||
): Promise<Blob | null> {
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let stream: MediaStream | null = null;
|
||||
|
||||
try {
|
||||
// Solicitar acesso à webcam
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user',
|
||||
},
|
||||
});
|
||||
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
|
||||
// Aguardar vídeo estar pronto
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
videoElement.onloadedmetadata = () => {
|
||||
canvasElement.width = videoElement.videoWidth;
|
||||
canvasElement.height = videoElement.videoHeight;
|
||||
resolve();
|
||||
};
|
||||
videoElement.onerror = reject;
|
||||
setTimeout(() => reject(new Error('Timeout ao carregar vídeo')), 5000);
|
||||
});
|
||||
|
||||
// Capturar frame
|
||||
const ctx = canvasElement.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Não foi possível obter contexto do canvas');
|
||||
}
|
||||
|
||||
ctx.drawImage(videoElement, 0, 0);
|
||||
|
||||
// Converter para blob
|
||||
return await new Promise<Blob | null>((resolve) => {
|
||||
canvasElement.toBlob(
|
||||
(blob) => {
|
||||
resolve(blob);
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao capturar webcam:', error);
|
||||
return null;
|
||||
} finally {
|
||||
// Parar stream
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// Resolver recurso/ação a partir da rota
|
||||
const routeAction = $derived.by(() => {
|
||||
const p = page.url.pathname;
|
||||
if (p === '/' || p === '/solicitar-acesso') return null;
|
||||
if (p === '/' || p === '/abrir-chamado') return null;
|
||||
|
||||
// Funcionários
|
||||
if (p.startsWith('/recursos-humanos/funcionarios')) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
238
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
238
apps/web/src/routes/(dashboard)/abrir-chamado/+page.svelte
Normal file
@@ -0,0 +1,238 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import TicketForm from "$lib/components/chamados/TicketForm.svelte";
|
||||
import TicketTimeline from "$lib/components/chamados/TicketTimeline.svelte";
|
||||
import { chamadosStore } from "$lib/stores/chamados";
|
||||
import { resolve } from "$app/paths";
|
||||
import { useConvexWithAuth } from "$lib/hooks/useConvexWithAuth";
|
||||
|
||||
type Ticket = Doc<"tickets">;
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let submitLoading = $state(false);
|
||||
let resetSignal = $state(0);
|
||||
let feedback = $state<{ tipo: "success" | "error"; mensagem: string; numero?: string } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const exemploTimeline = $state<NonNullable<Ticket["timeline"]>>([
|
||||
{
|
||||
etapa: "abertura",
|
||||
status: "concluido",
|
||||
prazo: Date.now(),
|
||||
concluidoEm: Date.now(),
|
||||
observacao: "Chamado criado",
|
||||
},
|
||||
{
|
||||
etapa: "resposta_inicial",
|
||||
status: "pendente",
|
||||
prazo: Date.now() + 4 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
etapa: "conclusao",
|
||||
status: "pendente",
|
||||
prazo: Date.now() + 24 * 60 * 60 * 1000,
|
||||
},
|
||||
]);
|
||||
|
||||
$effect(() => {
|
||||
// Garante que o cliente Convex use o token do usuário logado
|
||||
useConvexWithAuth();
|
||||
});
|
||||
|
||||
async function uploadArquivo(file: File) {
|
||||
const uploadUrl = await client.mutation(api.chamados.generateUploadUrl, {});
|
||||
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data?.storageId) {
|
||||
throw new Error("Falha ao enviar arquivo. Tente novamente.");
|
||||
}
|
||||
|
||||
return {
|
||||
arquivoId: data.storageId as Id<"_storage">,
|
||||
nome: file.name,
|
||||
tipo: file.type,
|
||||
tamanho: file.size,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(event: CustomEvent<{ values: any }>) {
|
||||
const { values } = event.detail;
|
||||
try {
|
||||
submitLoading = true;
|
||||
feedback = null;
|
||||
|
||||
const anexos = [];
|
||||
for (const file of values.anexos ?? []) {
|
||||
const uploaded = await uploadArquivo(file);
|
||||
anexos.push(uploaded);
|
||||
}
|
||||
|
||||
const resultado = await client.mutation(api.chamados.abrirChamado, {
|
||||
titulo: values.titulo,
|
||||
descricao: values.descricao,
|
||||
tipo: values.tipo,
|
||||
categoria: values.categoria,
|
||||
prioridade: values.prioridade,
|
||||
canalOrigem: values.canalOrigem,
|
||||
anexos,
|
||||
});
|
||||
|
||||
feedback = {
|
||||
tipo: "success",
|
||||
mensagem: "Chamado registrado com sucesso! Você pode acompanhar pelo seu perfil.",
|
||||
numero: resultado.numero,
|
||||
};
|
||||
resetSignal = resetSignal + 1;
|
||||
|
||||
// Atualizar store local
|
||||
const novoTicket = await client.query(api.chamados.obterChamado, {
|
||||
ticketId: resultado.ticketId,
|
||||
});
|
||||
if (novoTicket?.ticket) {
|
||||
chamadosStore.upsertTicket(novoTicket.ticket);
|
||||
chamadosStore.setDetalhe(resultado.ticketId, novoTicket);
|
||||
}
|
||||
} catch (error) {
|
||||
const mensagem =
|
||||
error instanceof Error ? error.message : "Erro ao enviar o chamado. Tente novamente.";
|
||||
feedback = {
|
||||
tipo: "error",
|
||||
mensagem,
|
||||
};
|
||||
} finally {
|
||||
submitLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="mx-auto w-full max-w-6xl space-y-10 px-4 py-8">
|
||||
<section
|
||||
class="relative overflow-hidden rounded-3xl border border-primary/30 bg-linear-to-br from-primary/10 via-base-100 to-secondary/20 p-10 shadow-2xl"
|
||||
>
|
||||
<div class="absolute -left-16 top-0 h-52 w-52 rounded-full bg-primary/20 blur-3xl"></div>
|
||||
<div class="absolute -bottom-20 right-0 h-64 w-64 rounded-full bg-secondary/20 blur-3xl"></div>
|
||||
|
||||
<div class="relative z-10 space-y-4">
|
||||
<span
|
||||
class="inline-flex items-center gap-2 rounded-full border border-primary/40 bg-primary/10 px-4 py-1 text-xs font-semibold uppercase tracking-[0.28em] text-primary"
|
||||
>
|
||||
Central de Chamados
|
||||
</span>
|
||||
<div class="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div class="max-w-3xl space-y-4">
|
||||
<h1 class="text-4xl font-black leading-tight text-base-content sm:text-5xl">
|
||||
Abrir novo chamado
|
||||
</h1>
|
||||
<p class="text-base text-base-content/70 sm:text-lg">
|
||||
Registre reclamações, sugestões, elogios ou chamados técnicos. Toda interação gera
|
||||
notificações automáticas via e-mail e chat com a assinatura do SGSE - Sistema de Gerenciamento de Secretaria.
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-3 text-sm text-base-content/70">
|
||||
<span class="badge badge-success badge-sm">Resposta ágil</span>
|
||||
<span class="badge badge-info badge-sm">Timeline com SLA</span>
|
||||
<span class="badge badge-warning badge-sm">Alertas de vencimento</span>
|
||||
</div>
|
||||
</div>
|
||||
<a href={resolve("/perfil/chamados")} class="btn btn-outline btn-sm">
|
||||
Acompanhar meus chamados
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if feedback}
|
||||
<div class={`alert ${feedback.tipo === "success" ? "alert-success" : "alert-error"} shadow-lg`}>
|
||||
<div>
|
||||
<span class="font-semibold">{feedback.mensagem}</span>
|
||||
{#if feedback.numero}
|
||||
<p class="text-sm">Número do ticket: {feedback.numero}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-3">
|
||||
<div class="lg:col-span-2">
|
||||
<div class="rounded-3xl border-2 border-primary/20 bg-gradient-to-br from-base-100 via-base-100/95 to-primary/5 p-8 shadow-xl">
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<div class="rounded-xl bg-primary/10 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-base-content">Formulário</h2>
|
||||
<p class="text-sm text-base-content/60">
|
||||
Informe os detalhes para que nossa equipe possa priorizar o atendimento.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
{#if resetSignal % 2 === 0}
|
||||
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
|
||||
{:else}
|
||||
<TicketForm loading={submitLoading} on:submit={handleSubmit} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="space-y-6">
|
||||
<div class="rounded-3xl border-2 border-info/20 bg-gradient-to-br from-base-100 via-base-100/95 to-info/5 p-6 shadow-lg">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="rounded-xl bg-info/10 p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-info"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-base-content">Como funciona a timeline</h3>
|
||||
</div>
|
||||
<div class="mb-4 space-y-2 rounded-xl bg-base-200/50 p-4">
|
||||
<p class="text-sm font-medium text-base-content/80">
|
||||
Todas as etapas do ticket são monitoradas automaticamente.
|
||||
</p>
|
||||
<p class="text-xs text-base-content/60">
|
||||
Os prazos mudam de cor conforme o SLA: <span class="text-success">dentro do prazo</span>, <span class="text-warning">próximo ao vencimento</span> ou <span class="text-error">vencido</span>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-base-300 bg-base-100/80 p-4">
|
||||
<TicketTimeline timeline={exemploTimeline} />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -1,512 +1,424 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient, useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { authStore } from "$lib/stores/auth.svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
import { Key, Eye, EyeOff, CheckCircle2, XCircle, Shield, Lock, AlertCircle, Info } from 'lucide-svelte';
|
||||
|
||||
const convex = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const convex = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let senhaAtual = $state("");
|
||||
let novaSenha = $state("");
|
||||
let confirmarSenha = $state("");
|
||||
let carregando = $state(false);
|
||||
let notice = $state<{ type: "success" | "error"; message: string } | null>(
|
||||
null,
|
||||
);
|
||||
let mostrarSenhaAtual = $state(false);
|
||||
let mostrarNovaSenha = $state(false);
|
||||
let mostrarConfirmarSenha = $state(false);
|
||||
let senhaAtual = $state('');
|
||||
let novaSenha = $state('');
|
||||
let confirmarSenha = $state('');
|
||||
let carregando = $state(false);
|
||||
let notice = $state<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
let mostrarSenhaAtual = $state(false);
|
||||
let mostrarNovaSenha = $state(false);
|
||||
let mostrarConfirmarSenha = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (!currentUser?.data) {
|
||||
goto("/");
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (!currentUser?.data) {
|
||||
goto(resolve('/'));
|
||||
}
|
||||
});
|
||||
|
||||
function validarSenha(senha: string): { valido: boolean; erros: string[] } {
|
||||
const erros: string[] = [];
|
||||
function validarSenha(senha: string): { valido: boolean; erros: string[] } {
|
||||
const erros: string[] = [];
|
||||
|
||||
if (senha.length < 8) {
|
||||
erros.push("A senha deve ter no mínimo 8 caracteres");
|
||||
}
|
||||
if (!/[A-Z]/.test(senha)) {
|
||||
erros.push("A senha deve conter pelo menos uma letra maiúscula");
|
||||
}
|
||||
if (!/[a-z]/.test(senha)) {
|
||||
erros.push("A senha deve conter pelo menos uma letra minúscula");
|
||||
}
|
||||
if (!/[0-9]/.test(senha)) {
|
||||
erros.push("A senha deve conter pelo menos um número");
|
||||
}
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(senha)) {
|
||||
erros.push("A senha deve conter pelo menos um caractere especial");
|
||||
}
|
||||
if (senha.length < 8) {
|
||||
erros.push('A senha deve ter no mínimo 8 caracteres');
|
||||
}
|
||||
if (!/[A-Z]/.test(senha)) {
|
||||
erros.push('A senha deve conter pelo menos uma letra maiúscula');
|
||||
}
|
||||
if (!/[a-z]/.test(senha)) {
|
||||
erros.push('A senha deve conter pelo menos uma letra minúscula');
|
||||
}
|
||||
if (!/[0-9]/.test(senha)) {
|
||||
erros.push('A senha deve conter pelo menos um número');
|
||||
}
|
||||
if (!/[!@#$%^&*(),.?":{}|<>]/.test(senha)) {
|
||||
erros.push('A senha deve conter pelo menos um caractere especial');
|
||||
}
|
||||
|
||||
return {
|
||||
valido: erros.length === 0,
|
||||
erros,
|
||||
};
|
||||
}
|
||||
return {
|
||||
valido: erros.length === 0,
|
||||
erros
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
notice = null;
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
notice = null;
|
||||
|
||||
// Validações
|
||||
if (!senhaAtual || !novaSenha || !confirmarSenha) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "Todos os campos são obrigatórios",
|
||||
};
|
||||
return;
|
||||
}
|
||||
// Validações
|
||||
if (!senhaAtual || !novaSenha || !confirmarSenha) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: 'Todos os campos são obrigatórios'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (novaSenha !== confirmarSenha) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "A nova senha e a confirmação não coincidem",
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (novaSenha !== confirmarSenha) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: 'A nova senha e a confirmação não coincidem'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
if (senhaAtual === novaSenha) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "A nova senha deve ser diferente da senha atual",
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (senhaAtual === novaSenha) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: 'A nova senha deve ser diferente da senha atual'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const validacao = validarSenha(novaSenha);
|
||||
if (!validacao.valido) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: validacao.erros.join(". "),
|
||||
};
|
||||
return;
|
||||
}
|
||||
const validacao = validarSenha(novaSenha);
|
||||
if (!validacao.valido) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: validacao.erros.join('. ')
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
carregando = true;
|
||||
carregando = true;
|
||||
|
||||
try {
|
||||
if (!authStore.token) {
|
||||
throw new Error("Token não encontrado");
|
||||
}
|
||||
try {
|
||||
if (!authStore.token) {
|
||||
throw new Error('Token não encontrado');
|
||||
}
|
||||
|
||||
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
|
||||
token: authStore.token,
|
||||
senhaAtual: senhaAtual,
|
||||
novaSenha: novaSenha,
|
||||
});
|
||||
const resultado = await convex.mutation(api.autenticacao.alterarSenha, {
|
||||
token: authStore.token,
|
||||
senhaAtual: senhaAtual,
|
||||
novaSenha: novaSenha
|
||||
});
|
||||
|
||||
if (resultado.sucesso) {
|
||||
notice = {
|
||||
type: "success",
|
||||
message: "Senha alterada com sucesso! Redirecionando...",
|
||||
};
|
||||
if (resultado.sucesso) {
|
||||
notice = {
|
||||
type: 'success',
|
||||
message: 'Senha alterada com sucesso! Redirecionando...'
|
||||
};
|
||||
|
||||
// Limpar campos
|
||||
senhaAtual = "";
|
||||
novaSenha = "";
|
||||
confirmarSenha = "";
|
||||
// Limpar campos
|
||||
senhaAtual = '';
|
||||
novaSenha = '';
|
||||
confirmarSenha = '';
|
||||
|
||||
// Redirecionar após 2 segundos
|
||||
setTimeout(() => {
|
||||
goto("/");
|
||||
}, 2000);
|
||||
} else {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: resultado.erro || "Erro ao alterar senha",
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: error.message || "Erro ao conectar com o servidor",
|
||||
};
|
||||
} finally {
|
||||
carregando = false;
|
||||
}
|
||||
}
|
||||
// Redirecionar após 2 segundos
|
||||
setTimeout(() => {
|
||||
goto(resolve('/'));
|
||||
}, 2000);
|
||||
} else {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: resultado.erro || 'Erro ao alterar senha'
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: error.message || 'Erro ao conectar com o servidor'
|
||||
};
|
||||
} finally {
|
||||
carregando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelar() {
|
||||
goto("/");
|
||||
}
|
||||
function cancelar() {
|
||||
goto(resolve('/'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-10 w-10 text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-4xl font-bold text-primary">Alterar Senha</h1>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-lg">
|
||||
Atualize sua senha de acesso ao sistema
|
||||
</p>
|
||||
</div>
|
||||
<main class="container mx-auto max-w-4xl px-4 py-8">
|
||||
<!-- Header Moderno -->
|
||||
<div class="mb-8">
|
||||
<div class="bg-linear-to-r from-primary/20 via-primary/10 to-primary/20 rounded-2xl p-6 mb-6 shadow-lg border border-primary/20">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary/20 rounded-2xl">
|
||||
<Key class="h-8 w-8 text-primary" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h1 class="text-primary text-4xl font-bold mb-2">Alterar Senha</h1>
|
||||
<p class="text-base-content/70 text-lg">
|
||||
Atualize sua senha de acesso ao sistema de forma segura
|
||||
</p>
|
||||
</div>
|
||||
<div class="badge badge-primary badge-lg gap-2">
|
||||
<Shield class="h-4 w-4" strokeWidth={2} />
|
||||
Seguro
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="text-sm breadcrumbs mb-6">
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li>Alterar Senha</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="breadcrumbs text-sm mb-6">
|
||||
<ul>
|
||||
<li><a href={resolve('/')} class="link link-hover">Dashboard</a></li>
|
||||
<li>Alterar Senha</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alertas -->
|
||||
{#if notice}
|
||||
<div
|
||||
class="alert {notice.type === 'success'
|
||||
? 'alert-success'
|
||||
: 'alert-error'} mb-6 shadow-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if notice.type === "success"}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Alertas -->
|
||||
{#if notice}
|
||||
<div
|
||||
class="alert {notice.type === 'success'
|
||||
? 'alert-success'
|
||||
: 'alert-error'} mb-6 shadow-xl animate-in fade-in slide-in-from-top duration-300"
|
||||
>
|
||||
{#if notice.type === 'success'}
|
||||
<CheckCircle2 class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
{:else}
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
{/if}
|
||||
<span class="font-semibold">{notice.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Senha Atual -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="senha-atual">
|
||||
<span class="label-text font-semibold">Senha Atual</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="senha-atual"
|
||||
type={mostrarSenhaAtual ? "text" : "password"}
|
||||
placeholder="Digite sua senha atual"
|
||||
class="input input-bordered input-primary w-full pr-12"
|
||||
bind:value={senhaAtual}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => (mostrarSenhaAtual = !mostrarSenhaAtual)}
|
||||
>
|
||||
{#if mostrarSenhaAtual}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Formulário Principal -->
|
||||
<div class="lg:col-span-2">
|
||||
<div
|
||||
class="card bg-base-100 border-base-300/50 border-2 shadow-xl hover:shadow-2xl transition-all duration-300"
|
||||
>
|
||||
<div class="card-body p-8">
|
||||
<div class="flex items-center gap-3 mb-6">
|
||||
<div class="p-2 bg-primary/10 rounded-xl">
|
||||
<Lock class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-base-content">Formulário de Alteração</h2>
|
||||
</div>
|
||||
|
||||
<!-- Nova Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="nova-senha">
|
||||
<span class="label-text font-semibold">Nova Senha</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="nova-senha"
|
||||
type={mostrarNovaSenha ? "text" : "password"}
|
||||
placeholder="Digite sua nova senha"
|
||||
class="input input-bordered input-primary w-full pr-12"
|
||||
bind:value={novaSenha}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => (mostrarNovaSenha = !mostrarNovaSenha)}
|
||||
>
|
||||
{#if mostrarNovaSenha}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Mínimo 8 caracteres, com letras maiúsculas, minúsculas, números e
|
||||
caracteres especiais
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Senha Atual -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="senha-atual">
|
||||
<span class="label-text font-semibold text-base">Senha Atual</span>
|
||||
<span class="label-text-alt text-error font-bold">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="senha-atual"
|
||||
type={mostrarSenhaAtual ? 'text' : 'password'}
|
||||
placeholder="Digite sua senha atual"
|
||||
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
|
||||
bind:value={senhaAtual}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost btn-circle absolute top-1/2 right-2 -translate-y-1/2 hover:bg-primary/10"
|
||||
onclick={() => (mostrarSenhaAtual = !mostrarSenhaAtual)}
|
||||
disabled={carregando}
|
||||
aria-label={mostrarSenhaAtual ? 'Ocultar senha' : 'Mostrar senha'}
|
||||
>
|
||||
{#if mostrarSenhaAtual}
|
||||
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{:else}
|
||||
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Confirmar Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="confirmar-senha">
|
||||
<span class="label-text font-semibold">Confirmar Nova Senha</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="confirmar-senha"
|
||||
type={mostrarConfirmarSenha ? "text" : "password"}
|
||||
placeholder="Digite novamente sua nova senha"
|
||||
class="input input-bordered input-primary w-full pr-12"
|
||||
bind:value={confirmarSenha}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 btn btn-ghost btn-sm btn-circle"
|
||||
onclick={() => (mostrarConfirmarSenha = !mostrarConfirmarSenha)}
|
||||
>
|
||||
{#if mostrarConfirmarSenha}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Nova Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="nova-senha">
|
||||
<span class="label-text font-semibold text-base">Nova Senha</span>
|
||||
<span class="label-text-alt text-error font-bold">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="nova-senha"
|
||||
type={mostrarNovaSenha ? 'text' : 'password'}
|
||||
placeholder="Digite sua nova senha"
|
||||
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
|
||||
bind:value={novaSenha}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost btn-circle absolute top-1/2 right-2 -translate-y-1/2 hover:bg-primary/10"
|
||||
onclick={() => (mostrarNovaSenha = !mostrarNovaSenha)}
|
||||
disabled={carregando}
|
||||
aria-label={mostrarNovaSenha ? 'Ocultar senha' : 'Mostrar senha'}
|
||||
>
|
||||
{#if mostrarNovaSenha}
|
||||
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{:else}
|
||||
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="label">
|
||||
<span class="label-text-alt text-base-content/60 text-xs">
|
||||
Mínimo 8 caracteres com maiúsculas, minúsculas, números e especiais
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Requisitos de Senha -->
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Requisitos de Senha:</h3>
|
||||
<ul class="text-sm list-disc list-inside mt-2 space-y-1">
|
||||
<li>Mínimo de 8 caracteres</li>
|
||||
<li>Pelo menos uma letra maiúscula (A-Z)</li>
|
||||
<li>Pelo menos uma letra minúscula (a-z)</li>
|
||||
<li>Pelo menos um número (0-9)</li>
|
||||
<li>Pelo menos um caractere especial (!@#$%^&*...)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Confirmar Senha -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="confirmar-senha">
|
||||
<span class="label-text font-semibold text-base">Confirmar Nova Senha</span>
|
||||
<span class="label-text-alt text-error font-bold">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
id="confirmar-senha"
|
||||
type={mostrarConfirmarSenha ? 'text' : 'password'}
|
||||
placeholder="Digite novamente sua nova senha"
|
||||
class="input input-bordered input-primary w-full pr-12 h-12 text-base"
|
||||
bind:value={confirmarSenha}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost btn-circle absolute top-1/2 right-2 -translate-y-1/2 hover:bg-primary/10"
|
||||
onclick={() => (mostrarConfirmarSenha = !mostrarConfirmarSenha)}
|
||||
disabled={carregando}
|
||||
aria-label={mostrarConfirmarSenha ? 'Ocultar senha' : 'Mostrar senha'}
|
||||
>
|
||||
{#if mostrarConfirmarSenha}
|
||||
<EyeOff class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{:else}
|
||||
<Eye class="h-5 w-5 text-base-content/60" strokeWidth={2} />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="flex gap-4 justify-end mt-8">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
onclick={cancelar}
|
||||
disabled={carregando}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled={carregando}>
|
||||
{#if carregando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Alterando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Alterar Senha
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Botões -->
|
||||
<div class="flex flex-col sm:flex-row justify-end gap-4 mt-8 pt-6 border-t border-base-300">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline btn-lg flex-1 sm:flex-initial"
|
||||
onclick={cancelar}
|
||||
disabled={carregando}
|
||||
>
|
||||
<XCircle class="h-5 w-5" strokeWidth={2} />
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-lg flex-1 sm:flex-initial shadow-lg hover:shadow-xl transition-all duration-200"
|
||||
disabled={carregando}
|
||||
>
|
||||
{#if carregando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Alterando...
|
||||
{:else}
|
||||
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
||||
Alterar Senha
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dicas de Segurança -->
|
||||
<div class="mt-6 card bg-base-200 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-warning"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
Dicas de Segurança
|
||||
</h3>
|
||||
<ul class="text-sm space-y-2 text-base-content/70">
|
||||
<li>✅ Nunca compartilhe sua senha com ninguém</li>
|
||||
<li>✅ Use uma senha única para cada sistema</li>
|
||||
<li>✅ Altere sua senha regularmente</li>
|
||||
<li>
|
||||
✅ Não use informações pessoais óbvias (nome, data de nascimento,
|
||||
etc.)
|
||||
</li>
|
||||
<li>✅ Considere usar um gerenciador de senhas</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sidebar com Informações -->
|
||||
<div class="space-y-6">
|
||||
<!-- Requisitos de Senha -->
|
||||
<div
|
||||
class="card bg-linear-to-br from-info/10 to-info/5 border-2 border-info/20 shadow-lg"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-info/20 rounded-xl">
|
||||
<Info class="h-6 w-6 text-info" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-base-content">Requisitos de Senha</h3>
|
||||
</div>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li class="flex items-start gap-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Mínimo de 8 caracteres</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Pelo menos uma letra maiúscula (A-Z)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Pelo menos uma letra minúscula (a-z)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Pelo menos um número (0-9)</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<CheckCircle2 class="h-5 w-5 text-success shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Pelo menos um caractere especial (!@#$%...)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dicas de Segurança -->
|
||||
<div
|
||||
class="card bg-linear-to-br from-warning/10 to-warning/5 border-2 border-warning/20 shadow-lg"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="p-2 bg-warning/20 rounded-xl">
|
||||
<Shield class="h-6 w-6 text-warning" strokeWidth={2} />
|
||||
</div>
|
||||
<h3 class="text-lg font-bold text-base-content">Dicas de Segurança</h3>
|
||||
</div>
|
||||
<ul class="space-y-3 text-sm">
|
||||
<li class="flex items-start gap-2">
|
||||
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Nunca compartilhe sua senha</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Use uma senha única para cada sistema</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Altere sua senha regularmente</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Evite informações pessoais óbvias</span>
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<AlertCircle class="h-5 w-5 text-warning shrink-0 mt-0.5" strokeWidth={2} />
|
||||
<span class="text-base-content/80">Considere usar um gerenciador de senhas</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: fade-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Compras</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -40,4 +43,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Megaphone, Edit, Plus } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Comunicação</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -40,4 +43,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { BarChart3, ClipboardCheck, Plus, CheckCircle2, Clock, TrendingUp } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Controladoria</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -85,4 +88,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,264 +1,364 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
const convex = useConvexClient();
|
||||
import { resolve } from '$app/paths';
|
||||
const convex = useConvexClient();
|
||||
|
||||
let matricula = $state("");
|
||||
let email = $state("");
|
||||
let carregando = $state(false);
|
||||
let notice = $state<{ type: "success" | "error" | "info"; message: string } | null>(null);
|
||||
let solicitacaoEnviada = $state(false);
|
||||
let matricula = $state('');
|
||||
let email = $state('');
|
||||
let carregando = $state(false);
|
||||
let notice = $state<{ type: 'success' | 'error' | 'info'; message: string } | null>(null);
|
||||
let solicitacaoEnviada = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
notice = null;
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
notice = null;
|
||||
|
||||
if (!matricula || !email) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "Por favor, preencha todos os campos",
|
||||
};
|
||||
return;
|
||||
}
|
||||
if (!matricula || !email) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: 'Por favor, preencha todos os campos'
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
carregando = true;
|
||||
carregando = true;
|
||||
|
||||
try {
|
||||
// Verificar se o usuário existe
|
||||
const usuarios = await convex.query(api.usuarios.listar, {
|
||||
matricula: matricula,
|
||||
});
|
||||
try {
|
||||
// Verificar se o usuário existe
|
||||
const usuarios = await convex.query(api.usuarios.listar, {
|
||||
matricula: matricula
|
||||
});
|
||||
|
||||
const usuario = usuarios.find(u => u.matricula === matricula && u.email === email);
|
||||
const usuario = usuarios.find((u) => u.matricula === matricula && u.email === email);
|
||||
|
||||
if (!usuario) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: "Matrícula ou e-mail não encontrados. Verifique os dados e tente novamente.",
|
||||
};
|
||||
carregando = false;
|
||||
return;
|
||||
}
|
||||
if (!usuario) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: 'Matrícula ou e-mail não encontrados. Verifique os dados e tente novamente.'
|
||||
};
|
||||
carregando = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Simular envio de solicitação
|
||||
solicitacaoEnviada = true;
|
||||
notice = {
|
||||
type: "success",
|
||||
message: "Solicitação enviada com sucesso! A equipe de TI entrará em contato em breve.",
|
||||
};
|
||||
// Simular envio de solicitação
|
||||
solicitacaoEnviada = true;
|
||||
notice = {
|
||||
type: 'success',
|
||||
message: 'Solicitação enviada com sucesso! A equipe de TI entrará em contato em breve.'
|
||||
};
|
||||
|
||||
// Limpar campos
|
||||
matricula = "";
|
||||
email = "";
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: "error",
|
||||
message: error.message || "Erro ao processar solicitação",
|
||||
};
|
||||
} finally {
|
||||
carregando = false;
|
||||
}
|
||||
}
|
||||
// Limpar campos
|
||||
matricula = '';
|
||||
email = '';
|
||||
} catch (error: any) {
|
||||
notice = {
|
||||
type: 'error',
|
||||
message: error.message || 'Erro ao processar solicitação'
|
||||
};
|
||||
} finally {
|
||||
carregando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-8 max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<h1 class="text-4xl font-bold text-primary">Esqueci Minha Senha</h1>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-lg">
|
||||
Solicite a recuperação da sua senha de acesso
|
||||
</p>
|
||||
</div>
|
||||
<main class="container mx-auto max-w-2xl px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-10 w-10"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<h1 class="text-primary text-4xl font-bold">Esqueci Minha Senha</h1>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-lg">Solicite a recuperação da sua senha de acesso</p>
|
||||
</div>
|
||||
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="text-sm breadcrumbs mb-6">
|
||||
<ul>
|
||||
<li><a href="/">Dashboard</a></li>
|
||||
<li>Esqueci Minha Senha</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Breadcrumbs -->
|
||||
<div class="breadcrumbs mb-6 text-sm">
|
||||
<ul>
|
||||
<li><a href={resolve('/')}>Dashboard</a></li>
|
||||
<li>Esqueci Minha Senha</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Alertas -->
|
||||
{#if notice}
|
||||
<div class="alert {notice.type === 'success' ? 'alert-success' : notice.type === 'error' ? 'alert-error' : 'alert-info'} mb-6 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if notice.type === "success"}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else if notice.type === "error"}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Alertas -->
|
||||
{#if notice}
|
||||
<div
|
||||
class="alert {notice.type === 'success'
|
||||
? 'alert-success'
|
||||
: notice.type === 'error'
|
||||
? 'alert-error'
|
||||
: 'alert-info'} mb-6 shadow-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
{#if notice.type === 'success'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else if notice.type === 'error'}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
<span>{notice.message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !solicitacaoEnviada}
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 shadow-xl border border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-6">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Como funciona?</h3>
|
||||
<p class="text-sm">
|
||||
Informe sua matrícula e e-mail cadastrados. A equipe de TI receberá sua solicitação e entrará em contato para resetar sua senha.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if !solicitacaoEnviada}
|
||||
<!-- Formulário -->
|
||||
<div class="card bg-base-100 border-base-300 border shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 class="font-bold">Como funciona?</h3>
|
||||
<p class="text-sm">
|
||||
Informe sua matrícula e e-mail cadastrados. A equipe de TI receberá sua solicitação e
|
||||
entrará em contato para resetar sua senha.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Matrícula -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="matricula">
|
||||
<span class="label-text font-semibold">Matrícula</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="matricula"
|
||||
type="text"
|
||||
placeholder="Digite sua matrícula"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
</div>
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<!-- Matrícula -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="matricula">
|
||||
<span class="label-text font-semibold">Matrícula</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="matricula"
|
||||
type="text"
|
||||
placeholder="Digite sua matrícula"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- E-mail -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text font-semibold">E-mail</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail cadastrado"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={email}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Use o e-mail cadastrado no sistema
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<!-- E-mail -->
|
||||
<div class="form-control">
|
||||
<label class="label" for="email">
|
||||
<span class="label-text font-semibold">E-mail</span>
|
||||
<span class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Digite seu e-mail cadastrado"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={email}
|
||||
required
|
||||
disabled={carregando}
|
||||
/>
|
||||
<label class="label">
|
||||
<span class="label-text-alt text-base-content/60">
|
||||
Use o e-mail cadastrado no sistema
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Botões -->
|
||||
<div class="flex gap-4 justify-end mt-8">
|
||||
<a href="/" class="btn btn-ghost" class:btn-disabled={carregando}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Voltar
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary"
|
||||
disabled={carregando}
|
||||
>
|
||||
{#if carregando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mensagem de Sucesso -->
|
||||
<div class="card bg-success/10 shadow-xl border border-success/30">
|
||||
<div class="card-body text-center">
|
||||
<div class="flex justify-center mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-24 w-24 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-success mb-4">Solicitação Enviada!</h2>
|
||||
<p class="text-base-content/70 mb-6">
|
||||
Sua solicitação de recuperação de senha foi enviada para a equipe de TI.
|
||||
Você receberá um contato em breve com as instruções para resetar sua senha.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="/" class="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Voltar ao Dashboard
|
||||
</a>
|
||||
<button type="button" class="btn btn-ghost" onclick={() => solicitacaoEnviada = false}>
|
||||
Enviar Nova Solicitação
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Botões -->
|
||||
<div class="mt-8 flex justify-end gap-4">
|
||||
<a href={resolve('/')} class="btn" class:btn-disabled={carregando}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" disabled={carregando}>
|
||||
{#if carregando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Mensagem de Sucesso -->
|
||||
<div class="card bg-success/10 border-success/30 border shadow-xl">
|
||||
<div class="card-body text-center">
|
||||
<div class="mb-4 flex justify-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-success h-24 w-24"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-success mb-4 text-2xl font-bold">Solicitação Enviada!</h2>
|
||||
<p class="text-base-content/70 mb-6">
|
||||
Sua solicitação de recuperação de senha foi enviada para a equipe de TI. Você receberá um
|
||||
contato em breve com as instruções para resetar sua senha.
|
||||
</p>
|
||||
<div class="flex justify-center gap-4">
|
||||
<a href={resolve('/')} class="btn btn-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
|
||||
/>
|
||||
</svg>
|
||||
Voltar ao Dashboard
|
||||
</a>
|
||||
<button type="button" class="btn" onclick={() => (solicitacaoEnviada = false)}>
|
||||
Enviar Nova Solicitação
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Card de Contato -->
|
||||
<div class="mt-6 card bg-base-200 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
Precisa de Ajuda?
|
||||
</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Se você não conseguir recuperar sua senha ou tiver problemas com o sistema, entre em contato diretamente com a equipe de TI:
|
||||
</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="text-sm">ti@sgse.pe.gov.br</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span class="text-sm">(81) 3183-8000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Card de Contato -->
|
||||
<div class="card bg-base-200 mt-6 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-info h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
Precisa de Ajuda?
|
||||
</h3>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Se você não conseguir recuperar sua senha ou tiver problemas com o sistema, entre em contato
|
||||
diretamente com a equipe de TI:
|
||||
</p>
|
||||
<div class="mt-4 space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm">ti@sgse.pe.gov.br</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm">(81) 3183-8000</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
<script lang="ts">
|
||||
import { DollarSign, Building2, Plus, Calculator, TrendingUp, FileText } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Financeiro</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -85,4 +88,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import WidgetGestaoPontos from '$lib/components/ponto/WidgetGestaoPontos.svelte';
|
||||
const menuItems = [
|
||||
{
|
||||
categoria: "Gestão de Ausências",
|
||||
@@ -27,7 +29,7 @@
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Secretaria de Gestão de Pessoas</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -114,6 +116,11 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Widget Gestão de Pontos -->
|
||||
<div class="mt-8">
|
||||
<WidgetGestaoPontos />
|
||||
</div>
|
||||
|
||||
<!-- Card de Ajuda -->
|
||||
<div class="alert alert-info shadow-lg mt-8">
|
||||
<svg
|
||||
|
||||
@@ -1,419 +1,408 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import AprovarAusencias from "$lib/components/AprovarAusencias.svelte";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import AprovarAusencias from '$lib/components/AprovarAusencias.svelte';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
// Buscar TODAS as solicitações de ausências
|
||||
const todasAusenciasQuery = useQuery(api.ausencias.listarTodas, {});
|
||||
// Buscar TODAS as solicitações de ausências
|
||||
const todasAusenciasQuery = useQuery(api.ausencias.listarTodas, {});
|
||||
|
||||
let filtroStatus = $state<string>("todos");
|
||||
let solicitacaoSelecionada = $state<Id<"solicitacoesAusencias"> | null>(null);
|
||||
let filtroStatus = $state<string>('todos');
|
||||
let solicitacaoSelecionada = $state<Id<'solicitacoesAusencias'> | null>(null);
|
||||
|
||||
const ausencias = $derived(todasAusenciasQuery?.data || []);
|
||||
const ausencias = $derived(todasAusenciasQuery?.data || []);
|
||||
|
||||
// Filtrar solicitações
|
||||
const ausenciasFiltradas = $derived(
|
||||
ausencias.filter((a) => {
|
||||
// Filtro de status
|
||||
if (filtroStatus !== "todos" && a.status !== filtroStatus) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
// Filtrar solicitações
|
||||
const ausenciasFiltradas = $derived(
|
||||
ausencias.filter((a) => {
|
||||
// Filtro de status
|
||||
if (filtroStatus !== 'todos' && a.status !== filtroStatus) return false;
|
||||
return true;
|
||||
})
|
||||
);
|
||||
|
||||
// Estatísticas gerais
|
||||
const stats = $derived({
|
||||
total: ausencias.length,
|
||||
aguardando: ausencias.filter((a) => a.status === "aguardando_aprovacao").length,
|
||||
aprovadas: ausencias.filter((a) => a.status === "aprovado").length,
|
||||
reprovadas: ausencias.filter((a) => a.status === "reprovado").length,
|
||||
});
|
||||
// Estatísticas gerais
|
||||
const stats = $derived({
|
||||
total: ausencias.length,
|
||||
aguardando: ausencias.filter((a) => a.status === 'aguardando_aprovacao').length,
|
||||
aprovadas: ausencias.filter((a) => a.status === 'aprovado').length,
|
||||
reprovadas: ausencias.filter((a) => a.status === 'reprovado').length
|
||||
});
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
aguardando_aprovacao: "badge-warning",
|
||||
aprovado: "badge-success",
|
||||
reprovado: "badge-error",
|
||||
};
|
||||
return badges[status] || "badge-neutral";
|
||||
}
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
aguardando_aprovacao: 'badge-warning',
|
||||
aprovado: 'badge-success',
|
||||
reprovado: 'badge-error'
|
||||
};
|
||||
return badges[status] || 'badge-neutral';
|
||||
}
|
||||
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: "Aguardando",
|
||||
aprovado: "Aprovado",
|
||||
reprovado: "Reprovado",
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
function getStatusTexto(status: string) {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: 'Aguardando',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
async function selecionarSolicitacao(solicitacaoId: Id<"solicitacoesAusencias">) {
|
||||
solicitacaoSelecionada = solicitacaoId;
|
||||
}
|
||||
async function selecionarSolicitacao(solicitacaoId: Id<'solicitacoesAusencias'>) {
|
||||
solicitacaoSelecionada = solicitacaoId;
|
||||
}
|
||||
|
||||
async function recarregar() {
|
||||
solicitacaoSelecionada = null;
|
||||
}
|
||||
async function recarregar() {
|
||||
solicitacaoSelecionada = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-6 max-w-7xl">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/gestao-pessoas" class="text-primary hover:underline"
|
||||
>Secretaria de Gestão de Pessoas</a
|
||||
>
|
||||
</li>
|
||||
<li>Gestão de Ausências</li>
|
||||
</ul>
|
||||
</div>
|
||||
<main class="container mx-auto max-w-7xl px-4 py-6">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<ul>
|
||||
<li>
|
||||
<a href={resolve('/gestao-pessoas')} class="text-primary hover:underline"
|
||||
>Secretaria de Gestão de Pessoas</a
|
||||
>
|
||||
</li>
|
||||
<li>Gestão de Ausências</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-orange-500/20 rounded-xl">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-orange-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Gestão de Ausências</h1>
|
||||
<p class="text-base-content/70">
|
||||
Visão geral de todas as solicitações de ausências
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost gap-2"
|
||||
onclick={() => goto("/gestao-pessoas")}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="rounded-xl bg-orange-500/20 p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8 text-orange-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-primary text-3xl font-bold">Gestão de Ausências</h1>
|
||||
<p class="text-base-content/70">Visão geral de todas as solicitações de ausências</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn gap-2" onclick={() => goto(resolve('/gestao-pessoas'))}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 19l-7-7m0 0l7-7m-7 7h18"
|
||||
/>
|
||||
</svg>
|
||||
Voltar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Estatísticas -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div class="stat bg-base-100 shadow-lg rounded-box border border-base-300">
|
||||
<div class="stat-figure text-orange-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Total</div>
|
||||
<div class="stat-value text-orange-500">{stats.total}</div>
|
||||
<div class="stat-desc">Solicitações</div>
|
||||
</div>
|
||||
<!-- Estatísticas -->
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
<div class="stat bg-base-100 rounded-box border-base-300 border shadow-lg">
|
||||
<div class="stat-figure text-orange-500">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Total</div>
|
||||
<div class="stat-value text-orange-500">{stats.total}</div>
|
||||
<div class="stat-desc">Solicitações</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 shadow-lg rounded-box border border-warning/30">
|
||||
<div class="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Pendentes</div>
|
||||
<div class="stat-value text-warning">{stats.aguardando}</div>
|
||||
<div class="stat-desc">Aguardando</div>
|
||||
</div>
|
||||
<div class="stat bg-base-100 rounded-box border-warning/30 border shadow-lg">
|
||||
<div class="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Pendentes</div>
|
||||
<div class="stat-value text-warning">{stats.aguardando}</div>
|
||||
<div class="stat-desc">Aguardando</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 shadow-lg rounded-box border border-success/30">
|
||||
<div class="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Aprovadas</div>
|
||||
<div class="stat-value text-success">{stats.aprovadas}</div>
|
||||
<div class="stat-desc">Deferidas</div>
|
||||
</div>
|
||||
<div class="stat bg-base-100 rounded-box border-success/30 border shadow-lg">
|
||||
<div class="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Aprovadas</div>
|
||||
<div class="stat-value text-success">{stats.aprovadas}</div>
|
||||
<div class="stat-desc">Deferidas</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-100 shadow-lg rounded-box border border-error/30">
|
||||
<div class="stat-figure text-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Reprovadas</div>
|
||||
<div class="stat-value text-error">{stats.reprovadas}</div>
|
||||
<div class="stat-desc">Indeferidas</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat bg-base-100 rounded-box border-error/30 border shadow-lg">
|
||||
<div class="stat-figure text-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-8 w-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="stat-title">Reprovadas</div>
|
||||
<div class="stat-value text-error">{stats.reprovadas}</div>
|
||||
<div class="stat-desc">Indeferidas</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">Filtros</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="filtro-status">
|
||||
<span class="label-text">Status</span>
|
||||
</label>
|
||||
<select
|
||||
id="filtro-status"
|
||||
class="select select-bordered"
|
||||
bind:value={filtroStatus}
|
||||
>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="aguardando_aprovacao">Aguardando Aprovação</option>
|
||||
<option value="aprovado">Aprovado</option>
|
||||
<option value="reprovado">Reprovado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filtros -->
|
||||
<div class="card bg-base-100 mb-6 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-lg">Filtros</h2>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for="filtro-status">
|
||||
<span class="label-text">Status</span>
|
||||
</label>
|
||||
<select id="filtro-status" class="select select-bordered" bind:value={filtroStatus}>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="aguardando_aprovacao">Aguardando Aprovação</option>
|
||||
<option value="aprovado">Aprovado</option>
|
||||
<option value="reprovado">Reprovado</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lista de Solicitações -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-lg mb-4">
|
||||
Todas as Solicitações ({ausenciasFiltradas.length})
|
||||
</h2>
|
||||
<!-- Lista de Solicitações -->
|
||||
<div class="card bg-base-100 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title mb-4 text-lg">
|
||||
Todas as Solicitações ({ausenciasFiltradas.length})
|
||||
</h2>
|
||||
|
||||
{#if ausenciasFiltradas.length === 0}
|
||||
<div class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Nenhuma solicitação encontrada com os filtros aplicados.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funcionário</th>
|
||||
<th>Time</th>
|
||||
<th>Período</th>
|
||||
<th>Dias</th>
|
||||
<th>Motivo</th>
|
||||
<th>Status</th>
|
||||
<th>Solicitado em</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ausenciasFiltradas as ausencia}
|
||||
<tr>
|
||||
<td class="font-semibold">
|
||||
{ausencia.funcionario?.nome || "N/A"}
|
||||
</td>
|
||||
<td>
|
||||
{#if ausencia.time}
|
||||
<div
|
||||
class="badge badge-sm font-semibold"
|
||||
style="background-color: {ausencia.time.cor}20; border-color: {ausencia.time.cor}; color: {ausencia.time.cor}"
|
||||
>
|
||||
{ausencia.time.nome}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-base-content/50">Sem time</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString("pt-BR")} até{" "}
|
||||
{new Date(ausencia.dataFim).toLocaleDateString("pt-BR")}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title={ausencia.motivo}>
|
||||
{ausencia.motivo}
|
||||
</td>
|
||||
<td>
|
||||
<div class={`badge ${getStatusBadge(ausencia.status)}`}>
|
||||
{getStatusTexto(ausencia.status)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
{new Date(ausencia.criadoEm).toLocaleDateString("pt-BR")}
|
||||
</td>
|
||||
<td>
|
||||
{#if ausencia.status === "aguardando_aprovacao"}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={() => selecionarSolicitacao(ausencia._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm gap-2"
|
||||
onclick={() => selecionarSolicitacao(ausencia._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if ausenciasFiltradas.length === 0}
|
||||
<div class="alert">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Nenhuma solicitação encontrada com os filtros aplicados.</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Funcionário</th>
|
||||
<th>Time</th>
|
||||
<th>Período</th>
|
||||
<th>Dias</th>
|
||||
<th>Motivo</th>
|
||||
<th>Status</th>
|
||||
<th>Solicitado em</th>
|
||||
<th>Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each ausenciasFiltradas as ausencia}
|
||||
<tr>
|
||||
<td class="font-semibold">
|
||||
{ausencia.funcionario?.nome || 'N/A'}
|
||||
</td>
|
||||
<td>
|
||||
{#if ausencia.time}
|
||||
<div
|
||||
class="badge badge-sm font-semibold"
|
||||
style="background-color: {ausencia.time.cor}20; border-color: {ausencia.time
|
||||
.cor}; color: {ausencia.time.cor}"
|
||||
>
|
||||
{ausencia.time.nome}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-base-content/50">Sem time</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{new Date(ausencia.dataInicio).toLocaleDateString('pt-BR')} até{' '}
|
||||
{new Date(ausencia.dataFim).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td class="font-bold">
|
||||
{calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias
|
||||
</td>
|
||||
<td class="max-w-xs truncate" title={ausencia.motivo}>
|
||||
{ausencia.motivo}
|
||||
</td>
|
||||
<td>
|
||||
<div class={`badge ${getStatusBadge(ausencia.status)}`}>
|
||||
{getStatusTexto(ausencia.status)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
{new Date(ausencia.criadoEm).toLocaleDateString('pt-BR')}
|
||||
</td>
|
||||
<td>
|
||||
{#if ausencia.status === 'aguardando_aprovacao'}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm gap-2"
|
||||
onclick={() => selecionarSolicitacao(ausencia._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm gap-2"
|
||||
onclick={() => selecionarSolicitacao(ausencia._id)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
Ver Detalhes
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Modal de Aprovação -->
|
||||
{#if solicitacaoSelecionada && currentUser.data}
|
||||
{#await client.query(api.ausencias.obterDetalhes, {
|
||||
solicitacaoId: solicitacaoSelecionada,
|
||||
}) then detalhes}
|
||||
{#if detalhes}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<AprovarAusencias
|
||||
solicitacao={detalhes}
|
||||
gestorId={currentUser.data._id}
|
||||
onSucesso={recarregar}
|
||||
onCancelar={() => (solicitacaoSelecionada = null)}
|
||||
/>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (solicitacaoSelecionada = null)}
|
||||
aria-label="Fechar modal"
|
||||
>Fechar</button
|
||||
>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
{/await}
|
||||
{#await client.query( api.ausencias.obterDetalhes, { solicitacaoId: solicitacaoSelecionada } ) then detalhes}
|
||||
{#if detalhes}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<AprovarAusencias
|
||||
solicitacao={detalhes}
|
||||
gestorId={currentUser.data._id}
|
||||
onSucesso={recarregar}
|
||||
onCancelar={() => (solicitacaoSelecionada = null)}
|
||||
/>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (solicitacaoSelecionada = null)}
|
||||
aria-label="Fechar modal">Fechar</button
|
||||
>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { Scale, BookOpen, Plus } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Jurídico</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -40,4 +43,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
@@ -1,88 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { FileText, ClipboardCopy, Plus, Users, FileDoc } from "lucide-svelte";
|
||||
import { FileText, ClipboardCopy, Building2 } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
|
||||
</script>
|
||||
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="text-sm breadcrumbs mb-4">
|
||||
<ul>
|
||||
<li><a href="/" class="text-primary hover:underline">Dashboard</a></li>
|
||||
<li>Licitações</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ProtectedRoute>
|
||||
<main class="container mx-auto px-4 py-4">
|
||||
<!-- Breadcrumb -->
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<ul>
|
||||
<li>
|
||||
<a href={resolve('/')} class="text-primary hover:underline">Dashboard</a>
|
||||
</li>
|
||||
<li>Licitações</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Cabeçalho -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-4 mb-2">
|
||||
<div class="p-3 bg-orange-500/20 rounded-xl">
|
||||
<FileText class="h-8 w-8 text-orange-600" strokeWidth={2} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-primary">Licitações</h1>
|
||||
<p class="text-base-content/70">Gestão de processos licitatórios</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-3">
|
||||
<a
|
||||
href={resolve('/licitacoes/empresas')}
|
||||
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<Building2 class="text-primary h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Empresas</h4>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Cadastro, listagem e edição de empresas e seus contatos.
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Card de Aviso -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-6">
|
||||
<FileDoc class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||
<p class="text-base-content/70 max-w-md mb-6">
|
||||
O módulo de Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios.
|
||||
</p>
|
||||
<div class="badge badge-warning badge-lg gap-2">
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/licitacoes/contratos')}
|
||||
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<FileText class="text-primary h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Contratos</h4>
|
||||
</div>
|
||||
<p class="text-base-content/70 text-sm">Gestão de contratos, vigências e situações.</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Funcionalidades Previstas -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<ClipboardCopy class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Processos Licitatórios</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Cadastro e acompanhamento de licitações</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<Users class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Fornecedores</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Cadastro e gestão de fornecedores</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<FileDoc class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="font-semibold">Documentação</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Gestão de documentos e editais</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="card bg-base-100 opacity-70 shadow-md">
|
||||
<div class="card-body">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-base-200 rounded-lg p-2">
|
||||
<ClipboardCopy class="text-base-content/50 h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="text-base-content/70 font-semibold">Processos Licitatórios</h4>
|
||||
</div>
|
||||
<p class="text-base-content/60 text-sm">
|
||||
Em breve: cadastro e acompanhamento de licitações.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-base-100 opacity-70 shadow-md">
|
||||
<div class="card-body">
|
||||
<div class="mb-2 flex items-center gap-3">
|
||||
<div class="bg-base-200 rounded-lg p-2">
|
||||
<FileText class="text-base-content/50 h-6 w-6" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="text-base-content/70 font-semibold">Documentação</h4>
|
||||
</div>
|
||||
<p class="text-base-content/60 text-sm">Em breve: gestão de documentos e editais.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { Plus, AlertTriangle } from 'lucide-svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { format } from 'date-fns';
|
||||
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
|
||||
|
||||
// Client para mutations
|
||||
const client = useConvexClient();
|
||||
|
||||
// Autenticação
|
||||
$effect(() => {
|
||||
useConvexWithAuth();
|
||||
});
|
||||
|
||||
// Filtros
|
||||
let responsavelId = $state<Id<'funcionarios'> | undefined>(undefined);
|
||||
let dataInicio = $state<string | undefined>(undefined);
|
||||
let dataFim = $state<string | undefined>(undefined);
|
||||
|
||||
// Queries
|
||||
const contratosQuery = useQuery(api.contratos.listar, () => ({
|
||||
responsavelId: responsavelId,
|
||||
dataInicio,
|
||||
dataFim
|
||||
}));
|
||||
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
// Derivados para facilitar acesso aos dados
|
||||
const contratos = $derived.by(() => {
|
||||
if (contratosQuery === undefined || contratosQuery === null) return [];
|
||||
if (Array.isArray(contratosQuery)) return contratosQuery;
|
||||
if ('data' in contratosQuery) return contratosQuery.data || [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const funcionarios = $derived.by(() => {
|
||||
if (funcionariosQuery === undefined || funcionariosQuery === null) return [];
|
||||
if (Array.isArray(funcionariosQuery)) return funcionariosQuery;
|
||||
if ('data' in funcionariosQuery) return funcionariosQuery.data || [];
|
||||
return [];
|
||||
});
|
||||
|
||||
const isLoading = $derived(contratosQuery === undefined);
|
||||
const error = $derived(contratosQuery instanceof Error ? contratosQuery : null);
|
||||
|
||||
// Helpers
|
||||
function formatarData(data: string) {
|
||||
if (!data) return '-';
|
||||
return format(new Date(data), 'dd/MM/yyyy');
|
||||
}
|
||||
|
||||
function formatarMoeda(valor: string) {
|
||||
if (!valor) return '-';
|
||||
return new Intl.NumberFormat('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL'
|
||||
}).format(parseFloat(valor));
|
||||
}
|
||||
|
||||
async function handleDelete(id: Id<'contratos'>) {
|
||||
if (confirm('Tem certeza que deseja excluir este contrato?')) {
|
||||
await client.mutation(api.contratos.excluir, { id });
|
||||
}
|
||||
}
|
||||
|
||||
// Verificar vencimento (lógica visual simples baseada na data de fim)
|
||||
function isProximoVencimento(dataFim: string, diasAviso: number) {
|
||||
if (!dataFim) return false;
|
||||
const fim = new Date(dataFim).getTime();
|
||||
const hoje = Date.now();
|
||||
const aviso = fim - diasAviso * 24 * 60 * 60 * 1000;
|
||||
return hoje >= aviso && hoje <= fim;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Contratos</h1>
|
||||
<p class="text-muted-foreground">Gerencie os contratos, vigências e situações.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick={() => goto(resolve('/licitacoes/contratos/novo'))}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Novo Contrato
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="bg-base-100 grid gap-4 rounded-lg border p-4 shadow-sm md:grid-cols-4">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtroResponsavel">
|
||||
<span class="label-text">Responsável</span>
|
||||
</label>
|
||||
<select
|
||||
id="filtroResponsavel"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={responsavelId}
|
||||
>
|
||||
<option value={undefined}>Todos</option>
|
||||
{#each funcionarios as func (func._id)}
|
||||
<option value={func._id}>{func.nome}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtroDataInicio">
|
||||
<span class="label-text">Vigência Início (A partir de)</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtroDataInicio"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={dataInicio}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="filtroDataFim">
|
||||
<span class="label-text">Vigência Fim (Até)</span>
|
||||
</label>
|
||||
<input
|
||||
id="filtroDataFim"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={dataFim}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
class="btn btn-outline w-full"
|
||||
onclick={() => {
|
||||
responsavelId = undefined;
|
||||
dataInicio = undefined;
|
||||
dataFim = undefined;
|
||||
}}
|
||||
>
|
||||
Limpar Filtros
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabela -->
|
||||
<div class="bg-base-100 overflow-x-auto rounded-md border">
|
||||
<table class="table w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nº Contrato</th>
|
||||
<th>Objeto</th>
|
||||
<th>Contratada</th>
|
||||
<th>Vigência</th>
|
||||
<th>Valor</th>
|
||||
<th>Situação</th>
|
||||
<th>Responsável</th>
|
||||
<th class="text-right">Ações</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if isLoading}
|
||||
<tr>
|
||||
<td colspan="8" class="h-24 text-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</td>
|
||||
</tr>
|
||||
{:else if error}
|
||||
<tr>
|
||||
<td colspan="8" class="text-error h-24 text-center">
|
||||
Erro ao carregar contratos: {error.message}
|
||||
</td>
|
||||
</tr>
|
||||
{:else if contratos.length === 0}
|
||||
<tr>
|
||||
<td colspan="8" class="text-base-content/70 h-24 text-center">
|
||||
Nenhum contrato encontrado.
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each contratos as contrato (contrato._id)}
|
||||
<tr>
|
||||
<td class="font-medium">
|
||||
<div class="flex items-center gap-2">
|
||||
{contrato.numeroContrato}/{contrato.anoContrato}
|
||||
{#if isProximoVencimento(contrato.dataFimVigencia, contrato.diasAvisoVencimento)}
|
||||
<div class="tooltip" data-tip="Próximo do vencimento">
|
||||
<AlertTriangle class="text-warning h-4 w-4" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="max-w-[200px] truncate" title={contrato.objeto}>
|
||||
{contrato.objeto}
|
||||
</td>
|
||||
<td>
|
||||
{contrato.contratada?.razao_social || 'Empresa não encontrada'}
|
||||
</td>
|
||||
<td>
|
||||
<div class="text-xs">
|
||||
{formatarData(contrato.dataInicioVigencia)} até
|
||||
<br />
|
||||
{formatarData(contrato.dataFimVigencia)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatarMoeda(contrato.valorTotal)}</td>
|
||||
<td>
|
||||
<div
|
||||
class="badge gap-2
|
||||
{contrato.situacao === 'em_execucao'
|
||||
? 'badge-success'
|
||||
: contrato.situacao === 'rescendido'
|
||||
? 'badge-error'
|
||||
: contrato.situacao === 'aguardando_assinatura'
|
||||
? 'badge-warning'
|
||||
: 'badge-ghost'}"
|
||||
>
|
||||
{contrato.situacao.replace('_', ' ').toUpperCase()}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{contrato.responsavel?.nome || '-'}
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
onclick={() => goto(resolve(`/licitacoes/contratos/${contrato._id}`))}
|
||||
>
|
||||
Editar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
onclick={() => handleDelete(contrato._id)}
|
||||
>
|
||||
Excluir
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,394 @@
|
||||
<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 { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/stores';
|
||||
import { ArrowLeft } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
|
||||
|
||||
// Client para mutations
|
||||
const client = useConvexClient();
|
||||
|
||||
// Autenticação
|
||||
$effect(() => {
|
||||
useConvexWithAuth();
|
||||
});
|
||||
|
||||
const contratoId = $page.params.id as Id<'contratos'>;
|
||||
|
||||
// Queries
|
||||
const contratoQuery = useQuery(api.contratos.obter, { id: contratoId });
|
||||
const empresasQuery = useQuery(api.empresas.list, {});
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
// Derivados
|
||||
const contrato = $derived(contratoQuery.data);
|
||||
const empresas = $derived(empresasQuery.data || []);
|
||||
const funcionarios = $derived(funcionariosQuery.data || []);
|
||||
const isLoading = $derived(contratoQuery.isLoading);
|
||||
const error = $derived(contratoQuery.error);
|
||||
|
||||
type SituacaoContrato = 'em_execucao' | 'rescendido' | 'aguardando_assinatura' | 'finalizado';
|
||||
|
||||
type ContratoForm = {
|
||||
contratadaId: string | null;
|
||||
objeto: string;
|
||||
numeroNotaEmpenho: string;
|
||||
responsavelId: string | null;
|
||||
departamento: string;
|
||||
situacao: SituacaoContrato;
|
||||
numeroProcessoLicitatorio: string;
|
||||
modalidade: string;
|
||||
numeroContrato: string;
|
||||
anoContrato: number;
|
||||
dataInicioVigencia: string;
|
||||
dataFimVigencia: string;
|
||||
nomeFiscal: string;
|
||||
valorTotal: string;
|
||||
dataAditivoPrazo: string;
|
||||
diasAvisoVencimento: number;
|
||||
};
|
||||
|
||||
// Estado do formulário
|
||||
let loading = $state(false);
|
||||
let formData = $state<ContratoForm>({
|
||||
contratadaId: null,
|
||||
objeto: '',
|
||||
numeroNotaEmpenho: '',
|
||||
responsavelId: null,
|
||||
departamento: '',
|
||||
situacao: 'aguardando_assinatura',
|
||||
numeroProcessoLicitatorio: '',
|
||||
modalidade: '',
|
||||
numeroContrato: '',
|
||||
anoContrato: new Date().getFullYear(),
|
||||
dataInicioVigencia: '',
|
||||
dataFimVigencia: '',
|
||||
nomeFiscal: '',
|
||||
valorTotal: '',
|
||||
dataAditivoPrazo: '',
|
||||
diasAvisoVencimento: 30
|
||||
});
|
||||
|
||||
// Carregar dados quando a query retornar
|
||||
$effect(() => {
|
||||
if (contrato) {
|
||||
formData = {
|
||||
contratadaId: contrato.contratadaId,
|
||||
objeto: contrato.objeto,
|
||||
numeroNotaEmpenho: contrato.numeroNotaEmpenho,
|
||||
responsavelId: contrato.responsavelId,
|
||||
departamento: contrato.departamento,
|
||||
situacao: contrato.situacao,
|
||||
numeroProcessoLicitatorio: contrato.numeroProcessoLicitatorio,
|
||||
modalidade: contrato.modalidade,
|
||||
numeroContrato: contrato.numeroContrato,
|
||||
anoContrato: contrato.anoContrato,
|
||||
dataInicioVigencia: contrato.dataInicioVigencia,
|
||||
dataFimVigencia: contrato.dataFimVigencia,
|
||||
nomeFiscal: contrato.nomeFiscal,
|
||||
valorTotal: contrato.valorTotal,
|
||||
dataAditivoPrazo: contrato.dataAditivoPrazo || '',
|
||||
diasAvisoVencimento: contrato.diasAvisoVencimento
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!formData.contratadaId || !formData.responsavelId) {
|
||||
toast.error('Selecione a empresa e o responsável.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
await client.mutation(api.contratos.editar, {
|
||||
id: contratoId,
|
||||
contratadaId: formData.contratadaId as Id<'empresas'>,
|
||||
objeto: formData.objeto,
|
||||
numeroNotaEmpenho: formData.numeroNotaEmpenho,
|
||||
responsavelId: formData.responsavelId as Id<'funcionarios'>,
|
||||
departamento: formData.departamento,
|
||||
situacao: formData.situacao,
|
||||
numeroProcessoLicitatorio: formData.numeroProcessoLicitatorio,
|
||||
modalidade: formData.modalidade,
|
||||
numeroContrato: formData.numeroContrato,
|
||||
anoContrato: Number(formData.anoContrato),
|
||||
dataInicioVigencia: formData.dataInicioVigencia,
|
||||
dataFimVigencia: formData.dataFimVigencia,
|
||||
nomeFiscal: formData.nomeFiscal,
|
||||
valorTotal: formData.valorTotal,
|
||||
dataAditivoPrazo: formData.dataAditivoPrazo || undefined,
|
||||
diasAvisoVencimento: Number(formData.diasAvisoVencimento)
|
||||
});
|
||||
toast.success('Contrato atualizado com sucesso!');
|
||||
goto(resolve('/licitacoes/contratos'));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
toast.error('Erro ao atualizar contrato: ' + error.message);
|
||||
} else {
|
||||
toast.error('Erro ao atualizar contrato: ' + String(error));
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex max-w-4xl flex-col gap-6 p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
class="btn btn-ghost btn-square"
|
||||
onclick={() => goto(resolve('/licitacoes/contratos'))}
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Editar Contrato</h1>
|
||||
<p class="text-muted-foreground">Atualize os dados do contrato.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="flex justify-center p-8">
|
||||
<span class="loading loading-spinner loading-lg text-base-content/50"></span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="alert alert-error">
|
||||
<span>Erro ao carregar contrato: {error.message}</span>
|
||||
</div>
|
||||
{:else if contrato}
|
||||
<div class="bg-base-100 grid gap-6 rounded-lg border p-6 shadow-sm">
|
||||
<!-- Dados Básicos -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="numeroContrato">
|
||||
<span class="label-text">Número do Contrato</span>
|
||||
</label>
|
||||
<input
|
||||
id="numeroContrato"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.numeroContrato}
|
||||
placeholder="Ex: 001"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="anoContrato">
|
||||
<span class="label-text">Ano do Contrato</span>
|
||||
</label>
|
||||
<input
|
||||
id="anoContrato"
|
||||
type="number"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.anoContrato}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full md:col-span-2">
|
||||
<label class="label" for="objeto">
|
||||
<span class="label-text">Objeto</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="objeto"
|
||||
class="textarea textarea-bordered w-full"
|
||||
bind:value={formData.objeto}
|
||||
placeholder="Descrição do objeto do contrato"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Partes -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="contratadaId">
|
||||
<span class="label-text">Empresa Contratada</span>
|
||||
</label>
|
||||
<select
|
||||
id="contratadaId"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={formData.contratadaId}
|
||||
>
|
||||
<option value={null} disabled>Selecione a empresa...</option>
|
||||
{#each empresas as emp (emp._id)}
|
||||
<option value={emp._id}>{emp.razao_social}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="responsavelId">
|
||||
<span class="label-text">Responsável (Fiscal)</span>
|
||||
</label>
|
||||
<select
|
||||
id="responsavelId"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={formData.responsavelId}
|
||||
>
|
||||
<option value={null} disabled>Selecione o responsável...</option>
|
||||
{#each funcionarios as func (func._id)}
|
||||
<option value={func._id}>{func.nome}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="nomeFiscal">
|
||||
<span class="label-text">Nome do Fiscal (Texto)</span>
|
||||
</label>
|
||||
<input
|
||||
id="nomeFiscal"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.nomeFiscal}
|
||||
placeholder="Nome completo do fiscal"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="departamento">
|
||||
<span class="label-text">Departamento</span>
|
||||
</label>
|
||||
<input
|
||||
id="departamento"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.departamento}
|
||||
placeholder="Ex: TI, Administrativo"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detalhes Administrativos -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="numeroProcessoLicitatorio">
|
||||
<span class="label-text">Processo Licitatório</span>
|
||||
</label>
|
||||
<input
|
||||
id="numeroProcessoLicitatorio"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.numeroProcessoLicitatorio}
|
||||
placeholder="Nº do processo"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="modalidade">
|
||||
<span class="label-text">Modalidade</span>
|
||||
</label>
|
||||
<input
|
||||
id="modalidade"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.modalidade}
|
||||
placeholder="Ex: Pregão Eletrônico"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="numeroNotaEmpenho">
|
||||
<span class="label-text">Nota de Empenho</span>
|
||||
</label>
|
||||
<input
|
||||
id="numeroNotaEmpenho"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.numeroNotaEmpenho}
|
||||
placeholder="Nº da nota"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Valores e Prazos -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="valorTotal">
|
||||
<span class="label-text">Valor Total (R$)</span>
|
||||
</label>
|
||||
<input
|
||||
id="valorTotal"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.valorTotal}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="dataInicioVigencia">
|
||||
<span class="label-text">Início Vigência</span>
|
||||
</label>
|
||||
<input
|
||||
id="dataInicioVigencia"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.dataInicioVigencia}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="dataFimVigencia">
|
||||
<span class="label-text">Fim Vigência</span>
|
||||
</label>
|
||||
<input
|
||||
id="dataFimVigencia"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.dataFimVigencia}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Situação e Alertas -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="situacao">
|
||||
<span class="label-text">Situação</span>
|
||||
</label>
|
||||
<select
|
||||
id="situacao"
|
||||
class="select select-bordered w-full"
|
||||
bind:value={formData.situacao}
|
||||
>
|
||||
<option value="aguardando_assinatura">Aguardando Assinatura</option>
|
||||
<option value="em_execucao">Em Execução</option>
|
||||
<option value="rescendido">Rescindido</option>
|
||||
<option value="finalizado">Finalizado</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="diasAvisoVencimento">
|
||||
<span class="label-text">Aviso de Vencimento (dias antes)</span>
|
||||
</label>
|
||||
<input
|
||||
id="diasAvisoVencimento"
|
||||
type="number"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.diasAvisoVencimento}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control w-full">
|
||||
<label class="label" for="dataAditivoPrazo">
|
||||
<span class="label-text">Data Aditivo Prazo (Opcional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="dataAditivoPrazo"
|
||||
type="date"
|
||||
class="input input-bordered w-full"
|
||||
bind:value={formData.dataAditivoPrazo}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-4 pt-4">
|
||||
<button
|
||||
class="btn btn-outline"
|
||||
onclick={() => goto(resolve('/licitacoes/contratos'))}>Cancelar</button
|
||||
>
|
||||
<button class="btn btn-primary" onclick={handleSubmit} disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
Salvar Alterações
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user