Compare commits
287 Commits
bug-perfil
...
feat-pedid
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c7412c764 | |||
| 0a4be24655 | |||
| 011a867aac | |||
| d3a4e5db8f | |||
| f008610b26 | |||
| 230be4db61 | |||
| 94373c6b94 | |||
| 69914170bf | |||
| 551a2fed00 | |||
| 9072619e26 | |||
| fbf00c824e | |||
| f90b27648f | |||
| fd2669aa4f | |||
| c7b4ea15bd | |||
| a5ad843b3e | |||
| f3288b9639 | |||
| c272ca05e8 | |||
| 4faf279c3e | |||
| d2c0636179 | |||
| 0404edd0ba | |||
| 91d41f6d98 | |||
| 9e45a43910 | |||
| c068715fc1 | |||
| 13ec7cc8e3 | |||
| 4f238022cf | |||
| b771322b24 | |||
| b47a317c33 | |||
| ba39167b2b | |||
| 92a9605417 | |||
| 4eb49d3e63 | |||
| 84dbe50fce | |||
| 3aa1e49ddb | |||
| bd0ac0a3b4 | |||
| 864226256a | |||
|
|
6b4cdb7497 | ||
| 21e876261b | |||
| f6f87fa2e7 | |||
|
|
1fd6e550e3 | ||
| 56dffbaad7 | |||
| 9f523d99a5 | |||
| d27c0b6f91 | |||
| f1b2cf815a | |||
|
|
eb47af1fd8 | ||
| 73da995109 | |||
| 7b3d429e23 | |||
| be3fb4ea64 | |||
| 248d7cd623 | |||
| 881f2fbb8b | |||
| 090298659e | |||
| 2172d9a937 | |||
| 4110b12724 | |||
| 7637cd52f1 | |||
| e6f380d7cc | |||
| cae6d886de | |||
| 1810cbabe2 | |||
| 09af2c796b | |||
| e92b10668e | |||
| e46738c5bf | |||
| fdfbd8b051 | |||
| e1f1af7530 | |||
| 12984997ce | |||
| 10a729baed | |||
| 426e358d86 | |||
| 0ec12721ba | |||
| f3b4721119 | |||
| 1ceef73847 | |||
| 14127a7977 | |||
| 398bf102e9 | |||
| e8137c116c | |||
| aec3201410 | |||
| 72450d1f28 | |||
| ff91d8a3ab | |||
| 80e9b76649 | |||
| 6a99ab74f1 | |||
| 69f32a342c | |||
| 1000b5a030 | |||
| 66f995cb08 | |||
| c8d717b315 | |||
| 4a1f48300f | |||
| 8e09e8cada | |||
| 6e659514e3 | |||
| 29577b8e63 | |||
| 7621fbea36 | |||
| 68475f549a | |||
| 300dfe7fc9 | |||
| eb7f3507d3 | |||
| 88f25dc6ab | |||
| 2cdf66375c | |||
| a3d9e782af | |||
| 7746dce25a | |||
| 4a662c08a0 | |||
| b145fcc74a | |||
| fb78866a0e | |||
| d86d7d8dbb | |||
| 4d29501849 | |||
| 8a50fb6f61 | |||
| 4bd9e21748 | |||
| d79e6959c3 | |||
| f48d28067c | |||
| c5dfddad46 | |||
| 75ab4d261d | |||
| ffa4dc5fb2 | |||
| e81054874f | |||
| 11a3c5c0e2 | |||
| b87f34fe4c | |||
| 8b5078de92 | |||
|
|
93e4e1cc87 | ||
| 0c507f41da | |||
| 05e7f1181d | |||
| e460b114ed | |||
| 2825bd0e6e | |||
|
|
a02d8f03eb | ||
| 1c0bd219b2 | |||
| fec5f5c33d | |||
| 95c3b48ae6 | |||
| c19c8c859e | |||
| b652822c30 | |||
| 6e836e9eb5 | |||
| b8a67e0a57 | |||
| db2105872f | |||
| 8fabb4149c | |||
| a149c5ead6 | |||
| 4af566e54c | |||
| 4c2d12f443 | |||
| d9e78079c8 | |||
| 4e3feca84d | |||
| 4ab151bed7 | |||
| 2fb7df8849 | |||
| 268510bbf2 | |||
| 08f3394de3 | |||
| 78ab6161cf | |||
| e43f9fcf14 | |||
| 3204440a38 | |||
| f1c2ae0e6b | |||
| 334676b860 | |||
| e35846103e | |||
| b34166691e | |||
| 39c948aa6b | |||
| b85021d924 | |||
| 298326e264 | |||
| 545e119367 | |||
| 1d9f924cb8 | |||
| f059a0c688 | |||
| e9e7c654ee | |||
| cdb28bf742 | |||
| 7defdaa59d | |||
| bc62cd51c0 | |||
| 9dcd26ee82 | |||
| 02b8d72f59 | |||
| 501751c22f | |||
|
|
330d376930 | ||
| 5e7de6c943 | |||
|
|
b9be21e302 | ||
| af21a35f05 | |||
| 277dc616b3 | |||
|
|
ecc60f4bee | ||
| 0c0c7a29c0 | |||
|
|
7fd78f12ae | ||
| be959eb230 | |||
| 86ae2a1084 | |||
| e1bd6fa61a | |||
|
|
edd8d1edca | ||
| 75989b0546 | |||
|
|
085502d71e | ||
| 08869fe5da | |||
|
|
3e1026343e | ||
| 71959f6553 | |||
| de694ed665 | |||
|
|
5aad901254 | ||
| daee99191c | |||
| 6128c20da0 | |||
| f8d9c17f63 | |||
| 409872352c | |||
|
|
d8361769e4 | ||
| 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 | |||
| d173e2a255 | |||
| 7e3c100fb9 | |||
| 05d3a394da | |||
| 29118d22ce | |||
| 55847e2a77 | |||
| 5ef6ef8550 | |||
| fb784d6f7e | |||
| 24b8eb6a14 | |||
| 60e0bfa69e | |||
| 70d405d98d | |||
| 88983ea297 | |||
| ea01e2401a | |||
| 118051ad56 |
127
.agent/rules/convex-svelte-best-practices.md
Normal file
127
.agent/rules/convex-svelte-best-practices.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
trigger: glob
|
||||
globs: **/*.svelte.ts,**/*.svelte
|
||||
---
|
||||
|
||||
# Convex + Svelte Best Practices
|
||||
|
||||
This document outlines the mandatory rules and best practices for integrating Convex with Svelte in this project.
|
||||
|
||||
## 1. Imports
|
||||
|
||||
Always use the following import paths. Do NOT use `$lib/convex` or relative paths for generated files unless specifically required by a local override.
|
||||
|
||||
### Correct Imports:
|
||||
|
||||
```typescript
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
```
|
||||
|
||||
### Incorrect Imports (Avoid):
|
||||
|
||||
```typescript
|
||||
import { convex } from '$lib/convex'; // Avoid direct client usage for queries
|
||||
import { api } from '$lib/convex/_generated/api'; // Incorrect path
|
||||
import { api } from '../convex/_generated/api'; // Relative path
|
||||
```
|
||||
|
||||
## 2. Data Fetching
|
||||
|
||||
### Use `useQuery` for Reactivity
|
||||
|
||||
Instead of manually fetching data inside `onMount`, use the `useQuery` hook. This ensures your data is reactive and automatically updates when the backend data changes.
|
||||
|
||||
**Preferred Pattern:**
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
const tasksQuery = useQuery(api.tasks.list, { status: 'pending' });
|
||||
const tasks = $derived(tasksQuery.data || []);
|
||||
const isLoading = $derived(tasksQuery.isLoading);
|
||||
</script>
|
||||
```
|
||||
|
||||
**Avoid Pattern:**
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { convex } from '$lib/convex';
|
||||
|
||||
let tasks = [];
|
||||
|
||||
onMount(async () => {
|
||||
// This is not reactive!
|
||||
tasks = await convex.query(api.tasks.list, { status: 'pending' });
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Mutations
|
||||
|
||||
Use `useConvexClient` to access the client for mutations.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
async function completeTask(id) {
|
||||
await client.mutation(api.tasks.complete, { id });
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 3. Type Safety
|
||||
|
||||
### No `any`
|
||||
|
||||
Strictly avoid using `any`. The Convex generated data model provides precise types for all your tables.
|
||||
|
||||
### Use Generated Types
|
||||
|
||||
Use `Doc<"tableName">` for full document objects and `Id<"tableName">` for IDs.
|
||||
|
||||
**Correct:**
|
||||
|
||||
```typescript
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
let selectedTask: Doc<'tasks'> | null = $state(null);
|
||||
let taskId: Id<'tasks'>;
|
||||
```
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```typescript
|
||||
let selectedTask: any = $state(null);
|
||||
let taskId: string;
|
||||
```
|
||||
|
||||
### Union Types for Enums
|
||||
|
||||
When dealing with status fields or other enums, define the specific union type instead of casting to `any`.
|
||||
|
||||
**Correct:**
|
||||
|
||||
```typescript
|
||||
async function updateStatus(newStatus: 'pending' | 'completed' | 'archived') {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```typescript
|
||||
async function updateStatus(newStatus: string) {
|
||||
// ...
|
||||
status: newStatus as any; // Avoid this
|
||||
}
|
||||
```
|
||||
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._
|
||||
69
.agent/rules/convex-typing.md
Normal file
69
.agent/rules/convex-typing.md
Normal file
@@ -0,0 +1,69 @@
|
||||
---
|
||||
trigger: glob
|
||||
description: Regras de tipagem para queries e mutations do Convex
|
||||
globs: **/*.svelte.ts,**/*.svelte
|
||||
---
|
||||
|
||||
# Regras de Tipagem do Convex
|
||||
|
||||
## Regra Principal
|
||||
|
||||
**NUNCA** crie anotações de tipo manuais para queries ou mutations do Convex. Os tipos já são inferidos automaticamente pelo Convex.
|
||||
|
||||
### ❌ Errado - Não faça isso:
|
||||
|
||||
```typescript
|
||||
// NÃO crie tipos manuais para o retorno de queries
|
||||
type Funcionario = {
|
||||
_id: Id<'funcionarios'>;
|
||||
nome: string;
|
||||
email: string;
|
||||
// ... outras propriedades
|
||||
};
|
||||
|
||||
const funcionarios: Funcionario[] = useQuery(api.funcionarios.getAll) ?? [];
|
||||
```
|
||||
|
||||
### ✅ Correto - Use inferência automática:
|
||||
|
||||
```typescript
|
||||
// O tipo já vem inferido automaticamente
|
||||
const funcionarios = useQuery(api.funcionarios.getAll);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quando Tipar É Necessário
|
||||
|
||||
Em situações onde você **realmente precisa** de um tipo explícito (ex: props de componentes, variáveis de estado, etc.), use `FunctionReturnType` para inferir o tipo:
|
||||
|
||||
```typescript
|
||||
import { FunctionReturnType } from 'convex/server';
|
||||
import { api } from '$convex/_generated/api';
|
||||
|
||||
// Infere o tipo de retorno da query
|
||||
type FuncionariosQueryResult = FunctionReturnType<typeof api.funcionarios.getAll>;
|
||||
|
||||
// Agora pode usar em props de componentes
|
||||
interface Props {
|
||||
funcionarios: FuncionariosQueryResult;
|
||||
}
|
||||
```
|
||||
|
||||
### Casos de Uso Válidos para `FunctionReturnType`:
|
||||
|
||||
1. **Props de componentes** - quando um componente filho recebe dados de uma query
|
||||
2. **Variáveis derivadas** - quando precisa tipar uma transformação dos dados
|
||||
3. **Funções auxiliares** - quando cria funções que operam sobre os dados da query
|
||||
4. **Stores/Estado global** - quando armazena dados em estado externo ao componente
|
||||
|
||||
---
|
||||
|
||||
## Resumo
|
||||
|
||||
| Situação | Abordagem |
|
||||
| --------------------------- | ------------------------------------------------- |
|
||||
| Usar `useQuery` diretamente | Deixe o tipo ser inferido automaticamente |
|
||||
| Props de componentes | Use `FunctionReturnType<typeof api.module.query>` |
|
||||
| Transformações de dados | Use `FunctionReturnType<typeof api.module.query>` |
|
||||
| Anotações manuais de tipo | **NUNCA** - sempre infira do Convex |
|
||||
27
.agent/rules/svelte.md
Normal file
27
.agent/rules/svelte.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
trigger: always_on
|
||||
---
|
||||
|
||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||
|
||||
## Available MCP Tools:
|
||||
|
||||
### 1. list-sections
|
||||
|
||||
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||
|
||||
### 2. get-documentation
|
||||
|
||||
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||
|
||||
### 3. svelte-autofixer
|
||||
|
||||
Analyzes Svelte code and returns problems and suggestions.
|
||||
You MUST use this tool whenever you write Svelte code before submitting it to the user. Keep calling it until no problems or suggestions are returned. Remember that this does not eliminate all lint errors, so still keep checking for lint errors before proceeding.
|
||||
|
||||
### 4. playground-link
|
||||
|
||||
Generates a Svelte Playground link with the provided code.
|
||||
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||
@@ -14,6 +14,13 @@
|
||||
"mcp",
|
||||
"start"
|
||||
]
|
||||
},
|
||||
"ark-ui": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"-y",
|
||||
"@ark-ui/mcp"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
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
|
||||
37
.github/workflows/deploy.yml
vendored
Normal file
37
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
||||
name: Build Docker images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["master"]
|
||||
|
||||
jobs:
|
||||
build-and-push-dockerfile-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./apps/web/Dockerfile
|
||||
push: true
|
||||
# Make sure to replace with your own namespace and repository
|
||||
tags: |
|
||||
killercf/sgc:latest
|
||||
platforms: linux/amd64
|
||||
build-args: |
|
||||
PUBLIC_CONVEX_URL=${{ secrets.PUBLIC_CONVEX_URL }}
|
||||
PUBLIC_CONVEX_SITE_URL=${{ secrets.PUBLIC_CONVEX_SITE_URL }}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,3 +49,5 @@ coverage
|
||||
tmp
|
||||
temp
|
||||
.eslintcache
|
||||
|
||||
out
|
||||
29
.vscode/settings.json
vendored
29
.vscode/settings.json
vendored
@@ -12,18 +12,27 @@
|
||||
// },
|
||||
"eslint.useFlatConfig": true,
|
||||
"eslint.workingDirectories": [
|
||||
{ "pattern": "apps/*" },
|
||||
{ "pattern": "packages/*" }
|
||||
],
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"typescript",
|
||||
"typescriptreact",
|
||||
"svelte"
|
||||
{
|
||||
"pattern": "apps/*"
|
||||
},
|
||||
{
|
||||
"pattern": "packages/*"
|
||||
}
|
||||
],
|
||||
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||
"eslint.options": {
|
||||
"cache": true,
|
||||
"cacheLocation": ".eslintcache"
|
||||
}
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[jsonc]": {
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"editor.tabSize": 2
|
||||
}
|
||||
|
||||
72
apps/web/Dockerfile
Normal file
72
apps/web/Dockerfile
Normal file
@@ -0,0 +1,72 @@
|
||||
# Use the official Bun image
|
||||
FROM oven/bun:1 AS base
|
||||
|
||||
# Set the working directory inside the container
|
||||
WORKDIR /app
|
||||
|
||||
# ---
|
||||
FROM base AS prepare
|
||||
|
||||
RUN bun add -g turbo@^2
|
||||
COPY . .
|
||||
RUN turbo prune web --docker
|
||||
|
||||
|
||||
# ---
|
||||
FROM base AS builder
|
||||
|
||||
# First install the dependencies (as they change less often)
|
||||
COPY --from=prepare /app/out/json/ .
|
||||
RUN bun install
|
||||
# Build the project
|
||||
COPY --from=prepare /app/out/full/ .
|
||||
|
||||
ARG PUBLIC_CONVEX_URL
|
||||
ENV PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
|
||||
|
||||
ARG PUBLIC_CONVEX_SITE_URL
|
||||
ENV PUBLIC_CONVEX_SITE_URL=$PUBLIC_CONVEX_SITE_URL
|
||||
|
||||
RUN bunx turbo build
|
||||
|
||||
# Production stage
|
||||
FROM oven/bun:1-slim AS production
|
||||
|
||||
# Set working directory to match builder structure
|
||||
WORKDIR /app
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 sveltekit
|
||||
RUN adduser --system --uid 1001 sveltekit
|
||||
|
||||
# Copy root node_modules (contains hoisted dependencies)
|
||||
COPY --from=builder --chown=sveltekit:sveltekit /app/node_modules ./node_modules
|
||||
|
||||
# Copy built application and workspace files
|
||||
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/build ./apps/web/build
|
||||
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/package.json ./apps/web/package.json
|
||||
# Copy workspace node_modules (contains symlinks to root node_modules)
|
||||
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/node_modules ./apps/web/node_modules
|
||||
|
||||
# Copy any additional files needed for runtime
|
||||
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/static ./apps/web/static
|
||||
|
||||
# Switch to non-root user
|
||||
USER sveltekit
|
||||
|
||||
# Set working directory to the app
|
||||
WORKDIR /app/apps/web
|
||||
|
||||
# Expose the port that the app runs on
|
||||
EXPOSE 5173
|
||||
|
||||
# Set environment variables
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=5173
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD bun --version || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["bun", "./build/index.js"]
|
||||
13
apps/web/convex/_generated/api.d.ts
vendored
13
apps/web/convex/_generated/api.d.ts
vendored
@@ -8,11 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type {
|
||||
ApiFromModules,
|
||||
FilterApi,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server';
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
@@ -25,13 +21,10 @@ import type {
|
||||
declare const fullApi: ApiFromModules<{}>;
|
||||
declare const fullApiWithMounts: typeof fullApi;
|
||||
|
||||
export declare const api: FilterApi<
|
||||
typeof fullApiWithMounts,
|
||||
FunctionReference<any, "public">
|
||||
>;
|
||||
export declare const api: FilterApi<typeof fullApiWithMounts, FunctionReference<any, 'public'>>;
|
||||
export declare const internal: FilterApi<
|
||||
typeof fullApiWithMounts,
|
||||
FunctionReference<any, "internal">
|
||||
FunctionReference<any, 'internal'>
|
||||
>;
|
||||
|
||||
export declare const components: {};
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { anyApi, componentsGeneric } from "convex/server";
|
||||
import { anyApi, componentsGeneric } from 'convex/server';
|
||||
|
||||
/**
|
||||
* A utility for referencing Convex functions in your app's API.
|
||||
|
||||
7
apps/web/convex/_generated/dataModel.d.ts
vendored
7
apps/web/convex/_generated/dataModel.d.ts
vendored
@@ -8,8 +8,8 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { AnyDataModel } from "convex/server";
|
||||
import type { GenericId } from "convex/values";
|
||||
import { AnyDataModel } from 'convex/server';
|
||||
import type { GenericId } from 'convex/values';
|
||||
|
||||
/**
|
||||
* No `schema.ts` file found!
|
||||
@@ -43,8 +43,7 @@ export type Doc = any;
|
||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||
* strings when type checking.
|
||||
*/
|
||||
export type Id<TableName extends TableNames = TableNames> =
|
||||
GenericId<TableName>;
|
||||
export type Id<TableName extends TableNames = TableNames> = GenericId<TableName>;
|
||||
|
||||
/**
|
||||
* A type describing your Convex data model.
|
||||
|
||||
18
apps/web/convex/_generated/server.d.ts
vendored
18
apps/web/convex/_generated/server.d.ts
vendored
@@ -19,9 +19,9 @@ import {
|
||||
GenericQueryCtx,
|
||||
GenericDatabaseReader,
|
||||
GenericDatabaseWriter,
|
||||
FunctionReference,
|
||||
} from "convex/server";
|
||||
import type { DataModel } from "./dataModel.js";
|
||||
FunctionReference
|
||||
} from 'convex/server';
|
||||
import type { DataModel } from './dataModel.js';
|
||||
|
||||
type GenericCtx =
|
||||
| GenericActionCtx<DataModel>
|
||||
@@ -36,7 +36,7 @@ type GenericCtx =
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const query: QueryBuilder<DataModel, "public">;
|
||||
export declare const query: QueryBuilder<DataModel, 'public'>;
|
||||
|
||||
/**
|
||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -46,7 +46,7 @@ export declare const query: QueryBuilder<DataModel, "public">;
|
||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
||||
|
||||
/**
|
||||
* Define a mutation in this Convex app's public API.
|
||||
@@ -56,7 +56,7 @@ export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
||||
|
||||
/**
|
||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -66,7 +66,7 @@ export declare const mutation: MutationBuilder<DataModel, "public">;
|
||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
||||
|
||||
/**
|
||||
* Define an action in this Convex app's public API.
|
||||
@@ -79,7 +79,7 @@ export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const action: ActionBuilder<DataModel, "public">;
|
||||
export declare const action: ActionBuilder<DataModel, 'public'>;
|
||||
|
||||
/**
|
||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||
@@ -87,7 +87,7 @@ export declare const action: ActionBuilder<DataModel, "public">;
|
||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||
*/
|
||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
||||
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
|
||||
|
||||
/**
|
||||
* Define an HTTP action.
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
internalActionGeneric,
|
||||
internalMutationGeneric,
|
||||
internalQueryGeneric,
|
||||
componentsGeneric,
|
||||
} from "convex/server";
|
||||
componentsGeneric
|
||||
} from 'convex/server';
|
||||
|
||||
/**
|
||||
* Define a query in this Convex app's public API.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { config as svelteConfigBase } from '@sgse-app/eslint-config/svelte';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import ts from 'typescript-eslint';
|
||||
import { defineConfig } from "eslint/config";
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
export default defineConfig([
|
||||
@@ -25,4 +25,4 @@ export default defineConfig([
|
||||
'**/.turbo/**'
|
||||
]
|
||||
}
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"dev": "bunx --bun vite dev",
|
||||
"build": "bunx --bun vite build",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sgse-app/eslint-config": "*",
|
||||
@@ -22,13 +24,15 @@
|
||||
"esbuild": "^0.25.11",
|
||||
"postcss": "^8.5.6",
|
||||
"svelte": "^5.38.1",
|
||||
"svelte-adapter-bun": "^1.0.1",
|
||||
"svelte-check": "^4.3.1",
|
||||
"svelte-dnd-action": "^0.9.67",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^7.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"eslint": "catalog:",
|
||||
"@ark-ui/svelte": "^5.15.0",
|
||||
"@convex-dev/better-auth": "^0.9.7",
|
||||
"@dicebear/collection": "^9.2.4",
|
||||
"@dicebear/core": "^9.2.4",
|
||||
@@ -47,12 +51,19 @@
|
||||
"convex-svelte": "^0.0.12",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-picker-element": "^1.27.0",
|
||||
"eslint": "catalog:",
|
||||
"exceljs": "^4.4.0",
|
||||
"is-network-error": "^1.3.0",
|
||||
"jspdf": "^3.0.3",
|
||||
"jspdf-autotable": "^5.0.2",
|
||||
"lib-jitsi-meet": "^1.0.6",
|
||||
"lucide-svelte": "^0.552.0",
|
||||
"marked": "^17.0.1",
|
||||
"papaparse": "^5.4.1",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"theme-change": "^2.5.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"xlsx-js-style": "^1.2.0",
|
||||
"zod": "^4.1.12"
|
||||
}
|
||||
}
|
||||
@@ -1,77 +1,368 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
@import 'tailwindcss';
|
||||
@plugin 'daisyui';
|
||||
|
||||
/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */
|
||||
|
||||
/* Estilo padrão dos botões - mesmo estilo do sidebar */
|
||||
.btn-standard {
|
||||
@apply font-medium flex items-center justify-center gap-2 text-center p-3 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors;
|
||||
@apply border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content flex items-center justify-center gap-2 rounded-xl border p-3 text-center font-medium transition-colors hover:text-white active:text-white;
|
||||
}
|
||||
|
||||
/* Sobrescrever estilos DaisyUI para seguir o padrão */
|
||||
.btn-primary {
|
||||
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content hover:text-white active:text-white transition-colors;
|
||||
@apply border-base-300 bg-base-100 hover:bg-primary/60 active:bg-primary text-base-content flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors hover:text-white active:text-white;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-base-300 bg-base-100 hover:bg-base-200 active:bg-base-300 text-base-content transition-colors;
|
||||
@apply border-base-300 bg-base-100 hover:bg-base-200 active:bg-base-300 text-base-content flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors;
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
@apply font-medium flex items-center justify-center gap-2 text-center px-4 py-2 rounded-xl border border-error bg-base-100 hover:bg-error/60 active:bg-error text-error hover:text-white active:text-white transition-colors;
|
||||
@apply border-error bg-base-100 hover:bg-error/60 active:bg-error text-error flex items-center justify-center gap-2 rounded-xl border px-4 py-2 text-center font-medium transition-colors hover:text-white active:text-white;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover) {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transform: translateY(0);
|
||||
transition: transform 220ms ease, box-shadow 220ms ease;
|
||||
/* Tema Aqua (padrão roxo/azul) - redefinido como custom para garantir compatibilidade */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'aqua';
|
||||
default: true;
|
||||
color-scheme: light;
|
||||
/* Azul principal (ligeiramente mais escuro que o anterior) */
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(217 20% 95%);
|
||||
--color-base-300: hsl(217 20% 90%);
|
||||
--color-base-content: hsl(217 20% 17%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 1.15rem;
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(15, 23, 42, 0.04),
|
||||
0 14px 32px -22px rgba(15, 23, 42, 0.45),
|
||||
0 6px 18px -16px rgba(102, 126, 234, 0.35);
|
||||
opacity: 0.55;
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
/* Temas customizados para SGSE */
|
||||
|
||||
/* Azul */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-blue';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(217 20% 95%);
|
||||
--color-base-300: hsl(217 20% 90%);
|
||||
--color-base-content: hsl(217 20% 17%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(135deg, rgba(102, 126, 234, 0.12), rgba(118, 75, 162, 0.12));
|
||||
opacity: 0;
|
||||
transform: scale(0.96);
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
/* Verde */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-green';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(142 76% 36%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(142 76% 36%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(142 76% 36%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(142 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(142 20% 95%);
|
||||
--color-base-300: hsl(142 20% 90%);
|
||||
--color-base-content: hsl(142 20% 17%);
|
||||
--color-info: hsl(142 76% 36%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover):hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: 0 20px 45px -20px rgba(15, 23, 42, 0.35);
|
||||
/* Laranja */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-orange';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(25 95% 53%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(25 95% 53%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(25 95% 53%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(25 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(25 20% 95%);
|
||||
--color-base-300: hsl(25 20% 90%);
|
||||
--color-base-content: hsl(25 20% 17%);
|
||||
--color-info: hsl(25 95% 53%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover):hover::before {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
/* Vermelho */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-red';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(0 84% 60%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(0 84% 60%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(0 84% 60%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(0 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(0 20% 95%);
|
||||
--color-base-300: hsl(0 20% 90%);
|
||||
--color-base-content: hsl(0 20% 17%);
|
||||
--color-info: hsl(0 84% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover):hover::after {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
/* Rosa */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-pink';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(330 81% 60%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(330 81% 60%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(330 81% 60%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(330 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(330 20% 95%);
|
||||
--color-base-300: hsl(330 20% 90%);
|
||||
--color-base-content: hsl(330 20% 17%);
|
||||
--color-info: hsl(330 81% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
:where(.card, .card-hover) > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
/* Teal */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-teal';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(173 80% 40%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(173 80% 40%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(173 80% 40%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(173 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(173 20% 95%);
|
||||
--color-base-300: hsl(173 20% 90%);
|
||||
--color-base-content: hsl(173 20% 17%);
|
||||
--color-info: hsl(173 80% 40%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Corporativo (Dark Blue) */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'sgse-corporate';
|
||||
color-scheme: dark;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 30% 15%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
/* Aproxima do fundo do login (Tailwind slate-900 = #0f172a) */
|
||||
--color-base-100: hsl(222 47% 11%);
|
||||
/* Escala de contraste (slate-800 / slate-700 aproximados) */
|
||||
--color-base-200: hsl(215 28% 17%);
|
||||
--color-base-300: hsl(215 25% 23%);
|
||||
--color-base-content: hsl(217 10% 90%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Light */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'light';
|
||||
color-scheme: light;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 20% 17%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(0 0% 100%);
|
||||
--color-base-200: hsl(217 20% 95%);
|
||||
--color-base-300: hsl(217 20% 90%);
|
||||
--color-base-content: hsl(217 20% 17%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
|
||||
/* Dark */
|
||||
@plugin 'daisyui/theme' {
|
||||
name: 'dark';
|
||||
color-scheme: dark;
|
||||
--color-primary: hsl(217 91% 55%);
|
||||
--color-primary-content: hsl(0 0% 100%);
|
||||
--color-secondary: hsl(217 91% 55%);
|
||||
--color-secondary-content: hsl(0 0% 100%);
|
||||
--color-accent: hsl(217 91% 55%);
|
||||
--color-accent-content: hsl(0 0% 100%);
|
||||
--color-neutral: hsl(217 30% 15%);
|
||||
--color-neutral-content: hsl(0 0% 100%);
|
||||
--color-base-100: hsl(217 30% 10%);
|
||||
--color-base-200: hsl(217 30% 15%);
|
||||
--color-base-300: hsl(217 30% 20%);
|
||||
--color-base-content: hsl(217 10% 90%);
|
||||
--color-info: hsl(217 91% 60%);
|
||||
--color-info-content: hsl(0 0% 100%);
|
||||
--color-success: hsl(142 76% 36%);
|
||||
--color-success-content: hsl(0 0% 100%);
|
||||
--color-warning: hsl(38 92% 50%);
|
||||
--color-warning-content: hsl(0 0% 100%);
|
||||
--color-error: hsl(0 84% 60%);
|
||||
--color-error-content: hsl(0 0% 100%);
|
||||
--radius-selector: 0.5rem;
|
||||
--radius-field: 0.5rem;
|
||||
--radius-box: 1rem;
|
||||
--size-selector: 0.25rem;
|
||||
--size-field: 0.25rem;
|
||||
--border: 1px;
|
||||
--depth: 1;
|
||||
--noise: 0;
|
||||
}
|
||||
@@ -1,10 +1,131 @@
|
||||
<!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 && !html.getAttribute('data-theme')) {
|
||||
var tema = null;
|
||||
try {
|
||||
// theme-change usa por padrão a chave "theme"
|
||||
tema = localStorage.getItem('theme');
|
||||
} catch (e) {
|
||||
tema = null;
|
||||
}
|
||||
|
||||
// Fallback para o tema padrão se não houver persistência
|
||||
html.setAttribute('data-theme', tema || 'aqua');
|
||||
}
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
@@ -1,9 +1,91 @@
|
||||
import type { Handle } from "@sveltejs/kit";
|
||||
import { createAuth } from "@sgse-app/backend/convex/auth";
|
||||
import { getToken } from "@mmailaender/convex-better-auth-svelte/sveltekit";
|
||||
import type { Handle, HandleServerError } from '@sveltejs/kit';
|
||||
import { createAuth } from '@sgse-app/backend/convex/auth';
|
||||
import { getToken, createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
event.locals.token = await getToken(createAuth, event.cookies);
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handleError: HandleServerError = async ({ error, event, status, message }) => {
|
||||
// Notificar erros 404 e 500+ (erros internos do servidor)
|
||||
if (status === 404 || status === 500 || status >= 500) {
|
||||
// Evitar loop infinito: não registrar erros relacionados à própria página de erros
|
||||
const urlPath = event.url.pathname;
|
||||
if (urlPath.includes('/ti/erros-servidor')) {
|
||||
console.warn(
|
||||
`⚠️ Erro na página de erros do servidor (${status}): Não será registrado para evitar loop.`
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
// Obter token do usuário (se autenticado)
|
||||
const token = event.locals.token;
|
||||
|
||||
// Criar cliente Convex para chamar a action
|
||||
const client = createConvexHttpClient({
|
||||
token: token || undefined
|
||||
});
|
||||
|
||||
// Extrair informações do erro
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const errorStack = error instanceof Error ? error.stack : undefined;
|
||||
const url = event.url.toString();
|
||||
const method = event.request.method;
|
||||
const ipAddress = event.getClientAddress();
|
||||
const userAgent = event.request.headers.get('user-agent') || undefined;
|
||||
|
||||
// Log para debug
|
||||
console.log(`📝 Registrando erro ${status} no servidor:`, {
|
||||
url,
|
||||
method,
|
||||
mensagem: errorMessage.substring(0, 100)
|
||||
});
|
||||
|
||||
// Chamar action para registrar e notificar erro
|
||||
// Aguardar a promise mas não bloquear a resposta se falhar
|
||||
try {
|
||||
// Usar Promise.race com timeout para evitar bloquear a resposta
|
||||
const actionPromise = client.action(api.errosServidor.registrarErroServidor, {
|
||||
statusCode: status,
|
||||
mensagem: errorMessage,
|
||||
stack: errorStack,
|
||||
url,
|
||||
method,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
usuarioId: undefined // Pode ser implementado depois para obter do token
|
||||
});
|
||||
|
||||
// Timeout de 3 segundos
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Timeout ao registrar erro')), 3000);
|
||||
});
|
||||
|
||||
const resultado = await Promise.race([actionPromise, timeoutPromise]);
|
||||
console.log(`✅ Erro ${status} registrado com sucesso:`, resultado);
|
||||
} catch (actionError) {
|
||||
// Log do erro de notificação, mas não falhar a resposta
|
||||
console.error(
|
||||
`❌ Erro ao registrar notificação de erro ${status}:`,
|
||||
actionError instanceof Error ? actionError.message : actionError
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
// Se falhar ao criar cliente ou chamar action, apenas logar
|
||||
// Não queremos que falhas na notificação quebrem a resposta de erro
|
||||
console.error(
|
||||
`❌ Erro ao tentar notificar equipe técnica sobre erro ${status}:`,
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retornar mensagem de erro padrão
|
||||
return {
|
||||
message: message || 'Erro interno do servidor',
|
||||
status
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,9 +5,14 @@
|
||||
* Este cliente será usado para autenticação quando Better Auth estiver ativo.
|
||||
*/
|
||||
|
||||
import { createAuthClient } from "better-auth/svelte";
|
||||
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
||||
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({
|
||||
plugins: [convexClient()],
|
||||
// baseURL padrão é window.location.origin, que é o correto para SvelteKit
|
||||
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts
|
||||
plugins: [convexClient()]
|
||||
});
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { loginModalStore } from "$lib/stores/loginModal.svelte";
|
||||
import { TriangleAlert } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
recurso: string;
|
||||
acao: string;
|
||||
children?: any;
|
||||
}
|
||||
|
||||
let { recurso, acao, children }: Props = $props();
|
||||
|
||||
let verificando = $state(true);
|
||||
let permitido = $state(false);
|
||||
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
const permissaoQuery = $derived(
|
||||
currentUser?.data
|
||||
? useQuery(api.permissoesAcoes.verificarAcao, {
|
||||
usuarioId: currentUser.data._id as Id<"usuarios">,
|
||||
recurso,
|
||||
acao,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
if (!currentUser?.data) {
|
||||
verificando = false;
|
||||
permitido = false;
|
||||
const currentPath = window.location.pathname;
|
||||
loginModalStore.open(currentPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permissaoQuery?.error) {
|
||||
verificando = false;
|
||||
permitido = false;
|
||||
} else if (permissaoQuery && !permissaoQuery.isLoading) {
|
||||
// Backend retorna null quando permitido
|
||||
verificando = false;
|
||||
permitido = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if verificando}
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else if permitido}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<div class="p-4 bg-error/10 rounded-full inline-block mb-4">
|
||||
<TriangleAlert class="h-16 w-16 text-error" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Negado</h2>
|
||||
<p class="text-base-content/70">
|
||||
Você não tem permissão para acessar esta ação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
125
apps/web/src/lib/components/AlertModal.svelte
Normal file
125
apps/web/src/lib/components/AlertModal.svelte
Normal file
@@ -0,0 +1,125 @@
|
||||
<script lang="ts">
|
||||
import { Info, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
buttonText?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = 'Atenção',
|
||||
message,
|
||||
buttonText = 'OK',
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-[9999]"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-alert-title"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="pointer-events-auto absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-200"
|
||||
onclick={handleClose}
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="bg-base-100 pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="border-base-300 from-info/10 to-info/5 flex flex-shrink-0 items-center justify-between border-b bg-linear-to-r px-6 py-4"
|
||||
>
|
||||
<h2 id="modal-alert-title" class="text-info flex items-center gap-2 text-xl font-bold">
|
||||
<Info class="h-6 w-6" strokeWidth={2.5} />
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||
<p class="text-base-content text-base leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-300 flex flex-shrink-0 justify-end border-t px-6 py-4">
|
||||
<button class="btn btn-primary" onclick={handleClose}>{buttonText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -40%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar customizada */
|
||||
:global(.modal-scroll) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-thumb) {
|
||||
background-color: hsl(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
|
||||
background-color: hsl(var(--bc) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<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 { useConvexClient } from 'convex-svelte';
|
||||
import { XCircle, AlertTriangle, X, Clock } from 'lucide-svelte';
|
||||
|
||||
type PeriodoFerias = Doc<'ferias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
@@ -16,7 +17,7 @@
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
|
||||
const { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -29,7 +30,8 @@
|
||||
aprovado: 'badge-success',
|
||||
reprovado: 'badge-error',
|
||||
data_ajustada_aprovada: 'badge-info',
|
||||
EmFérias: 'badge-info'
|
||||
EmFérias: 'badge-info',
|
||||
Cancelado_RH: 'badge-error'
|
||||
};
|
||||
return badges[status] || 'badge-neutral';
|
||||
}
|
||||
@@ -40,19 +42,20 @@
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado',
|
||||
data_ajustada_aprovada: 'Data Ajustada e Aprovada',
|
||||
EmFérias: 'Em Férias'
|
||||
EmFérias: 'Em Férias',
|
||||
Cancelado_RH: 'Cancelado RH'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
async function voltarParaAguardando() {
|
||||
async function cancelarPorRH() {
|
||||
try {
|
||||
processando = true;
|
||||
erro = '';
|
||||
|
||||
await client.mutation(api.ferias.atualizarStatus, {
|
||||
feriasId: solicitacao._id,
|
||||
novoStatus: 'aguardando_aprovacao',
|
||||
novoStatus: 'Cancelado_RH',
|
||||
usuarioId: usuarioId
|
||||
});
|
||||
|
||||
@@ -127,20 +130,7 @@
|
||||
<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>
|
||||
<Clock class="h-3 w-3" strokeWidth={2} />
|
||||
<span>{formatarData(hist.data)}</span>
|
||||
<span>-</span>
|
||||
<span>{hist.acao}</span>
|
||||
@@ -150,28 +140,16 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ação: Voltar para Aguardando Aprovação -->
|
||||
{#if solicitacao.status !== 'aguardando_aprovacao'}
|
||||
<!-- Ação: Cancelar por RH -->
|
||||
{#if solicitacao.status !== 'Cancelado_RH'}
|
||||
<div class="divider mt-6"></div>
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<div class="alert alert-warning">
|
||||
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<h3 class="font-bold">Alterar Status</h3>
|
||||
<h3 class="font-bold">Cancelar Férias</h3>
|
||||
<div class="text-sm">
|
||||
Ao voltar para "Aguardando Aprovação", a solicitação ficará disponível para aprovação ou
|
||||
reprovação pelo gestor.
|
||||
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>
|
||||
@@ -179,63 +157,26 @@
|
||||
<div class="card-actions mt-4 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning gap-2"
|
||||
onclick={voltarParaAguardando}
|
||||
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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
Voltar para Aguardando Aprovação
|
||||
<X class="h-5 w-5" strokeWidth={2} />
|
||||
Cancelar Férias (RH)
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="divider mt-6"></div>
|
||||
<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>Esta solicitação já está aguardando aprovação.</span>
|
||||
<div class="alert alert-error">
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>Esta solicitação já foi cancelada pelo RH.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Motivo Reprovação (se reprovado) -->
|
||||
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
|
||||
<div class="alert alert-error mt-4">
|
||||
<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>
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<div class="font-bold">Motivo da Reprovação:</div>
|
||||
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
|
||||
@@ -246,19 +187,7 @@
|
||||
<!-- 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>
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['absolute inset-0 h-full w-full', className]}>
|
||||
<div
|
||||
class="bg-primary/20 absolute top-[-10%] left-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px]"
|
||||
></div>
|
||||
<div
|
||||
class="bg-secondary/20 absolute right-[-10%] bottom-[-10%] h-[40%] w-[40%] animate-pulse rounded-full blur-[120px] delay-700"
|
||||
></div>
|
||||
</div>
|
||||
@@ -1,8 +1,11 @@
|
||||
<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 type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import ErrorModal from './ErrorModal.svelte';
|
||||
import UserAvatar from './chat/UserAvatar.svelte';
|
||||
import { Calendar, FileText, XCircle, X, Check, Clock, User, Info } from 'lucide-svelte';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
|
||||
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
@@ -17,7 +20,7 @@
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
const { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -28,13 +31,13 @@
|
||||
let mensagemErroModal = $state('');
|
||||
|
||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||
const inicio = new Date(dataInicio);
|
||||
const fim = new Date(dataFim);
|
||||
const inicio = parseLocalDate(dataInicio);
|
||||
const fim = parseLocalDate(dataFim);
|
||||
const diff = fim.getTime() - inicio.getTime();
|
||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim));
|
||||
let totalDias = $derived(calcularDias(solicitacao.dataInicio, solicitacao.dataFim));
|
||||
|
||||
async function aprovar() {
|
||||
try {
|
||||
@@ -132,47 +135,48 @@
|
||||
|
||||
<div class="aprovar-ausencia">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<h2 class="text-primary mb-2 text-3xl font-bold">Aprovar/Reprovar Ausência</h2>
|
||||
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
|
||||
<div class="mb-4">
|
||||
<h2 class="text-primary mb-1 text-2xl font-bold">Aprovar/Reprovar Ausência</h2>
|
||||
<p class="text-base-content/70 text-sm">Analise a solicitação e tome uma decisão</p>
|
||||
</div>
|
||||
|
||||
<!-- Card Principal -->
|
||||
<div class="card bg-base-100 border-t-4 border-orange-500 shadow-2xl">
|
||||
<div class="card-body">
|
||||
<div class="card bg-base-100 border-primary border-t-4 shadow-2xl">
|
||||
<div class="card-body p-4 md:p-6">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold">
|
||||
<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 class="mb-4">
|
||||
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||
<User class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
Funcionário
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Nome</p>
|
||||
<p class="text-lg font-bold">
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
|
||||
<p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
|
||||
Nome
|
||||
</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar
|
||||
fotoPerfilUrl={solicitacao.funcionario?.fotoPerfilUrl}
|
||||
nome={solicitacao.funcionario?.nome || 'N/A'}
|
||||
size="sm"
|
||||
/>
|
||||
<p class="text-base-content text-base font-bold truncate">
|
||||
{solicitacao.funcionario?.nome || 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if solicitacao.time}
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Time</p>
|
||||
<div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
|
||||
<p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
|
||||
Time
|
||||
</p>
|
||||
<div
|
||||
class="badge badge-lg font-semibold"
|
||||
class="badge badge-sm font-semibold max-w-full overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
|
||||
.cor}; color: {solicitacao.time.cor}"
|
||||
title={solicitacao.time.nome}
|
||||
>
|
||||
{solicitacao.time.nome}
|
||||
</div>
|
||||
@@ -181,166 +185,184 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-4"></div>
|
||||
|
||||
<!-- Período da Ausência -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold">
|
||||
<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 class="mb-4">
|
||||
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||
<Calendar class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
Período da Ausência
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
|
||||
>
|
||||
<div class="stat-title">Data Início</div>
|
||||
<div class="stat-value text-2xl text-orange-600 dark:text-orange-400">
|
||||
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||
<div class="stat-title text-base-content/70 text-xs">Data Início</div>
|
||||
<div class="stat-value text-primary text-lg font-bold">
|
||||
{parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
|
||||
>
|
||||
<div class="stat-title">Data Fim</div>
|
||||
<div class="stat-value text-2xl text-orange-600 dark:text-orange-400">
|
||||
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||
<div class="stat-title text-base-content/70 text-xs">Data Fim</div>
|
||||
<div class="stat-value text-primary text-lg font-bold">
|
||||
{parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="stat rounded-xl border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
class="stat border-primary/30 from-primary/10 to-primary/15 hover:border-primary/40 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
|
||||
>
|
||||
<div class="stat-title">Total de Dias</div>
|
||||
<div class="stat-value text-3xl text-orange-600 dark:text-orange-400">
|
||||
<div class="stat-title text-base-content/70 text-xs">Total de Dias</div>
|
||||
<div class="stat-value text-primary text-2xl font-bold">
|
||||
{totalDias}
|
||||
</div>
|
||||
<div class="stat-desc">dias corridos</div>
|
||||
<div class="stat-desc text-base-content/60 text-xs">dias corridos</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div class="divider my-4"></div>
|
||||
|
||||
<!-- Motivo -->
|
||||
<div class="mb-6">
|
||||
<h3 class="mb-4 flex items-center gap-2 text-xl font-bold">
|
||||
<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 class="mb-4">
|
||||
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||
<FileText class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
Motivo da Ausência
|
||||
</h3>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<p class="whitespace-pre-wrap">{solicitacao.motivo}</p>
|
||||
<div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<p class="text-base-content text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{solicitacao.motivo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Atual -->
|
||||
<div class="mb-6">
|
||||
<div class="bg-base-200/30 mb-4 rounded-lg p-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-semibold">Status:</span>
|
||||
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
|
||||
<span class="text-base-content/70 text-xs font-semibold tracking-wide uppercase"
|
||||
>Status:</span
|
||||
>
|
||||
<div class={`badge badge-sm ${getStatusBadge(solicitacao.status)}`}>
|
||||
{getStatusTexto(solicitacao.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações de Aprovação/Reprovação -->
|
||||
{#if solicitacao.status === 'aprovado'}
|
||||
<div class="alert alert-success mb-4 shadow-lg py-3">
|
||||
<Check class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<div class="font-bold text-sm">Aprovado</div>
|
||||
{#if solicitacao.gestor}
|
||||
<div class="text-xs mt-1">
|
||||
Por: <strong>{solicitacao.gestor.nome}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
{#if solicitacao.dataAprovacao}
|
||||
<div class="text-xs mt-1 opacity-80">
|
||||
Em: {new Date(solicitacao.dataAprovacao).toLocaleString('pt-BR')}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if solicitacao.status === 'reprovado'}
|
||||
<div class="alert alert-error mb-4 shadow-lg py-3">
|
||||
<XCircle class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<div class="font-bold text-sm">Reprovado</div>
|
||||
{#if solicitacao.gestor}
|
||||
<div class="text-xs mt-1">
|
||||
Por: <strong>{solicitacao.gestor.nome}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
{#if solicitacao.dataReprovacao}
|
||||
<div class="text-xs mt-1 opacity-80">
|
||||
Em: {new Date(solicitacao.dataReprovacao).toLocaleString('pt-BR')}
|
||||
</div>
|
||||
{/if}
|
||||
{#if solicitacao.motivoReprovacao}
|
||||
<div class="mt-2">
|
||||
<div class="text-xs font-semibold">Motivo:</div>
|
||||
<div class="text-xs">{solicitacao.motivoReprovacao}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Histórico de Alterações -->
|
||||
{#if solicitacao.historicoAlteracoes && solicitacao.historicoAlteracoes.length > 0}
|
||||
<div class="mb-4">
|
||||
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
Histórico de Alterações
|
||||
</h3>
|
||||
<div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
|
||||
<div class="card-body p-3">
|
||||
<div class="space-y-2">
|
||||
{#each solicitacao.historicoAlteracoes as hist}
|
||||
<div class="border-base-300 flex items-start gap-2 border-b pb-2 last:border-0 last:pb-0">
|
||||
<Clock class="text-primary mt-0.5 h-3.5 w-3.5 shrink-0" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<div class="text-base-content text-xs font-semibold">{hist.acao}</div>
|
||||
<div class="text-base-content/60 text-xs">
|
||||
{new Date(hist.data).toLocaleString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Erro -->
|
||||
{#if erro}
|
||||
<div class="alert alert-error mb-4">
|
||||
<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 class="alert alert-error mb-4 shadow-lg py-3">
|
||||
<XCircle class="h-5 w-5 shrink-0 stroke-current" />
|
||||
<span class="text-sm">{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Ações -->
|
||||
{#if solicitacao.status === 'aguardando_aprovacao'}
|
||||
<div class="card-actions mt-6 justify-end gap-4">
|
||||
<div class="card-actions mt-4 justify-end gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error btn-lg gap-2"
|
||||
class="btn btn-error btn-sm md:btn-md gap-2"
|
||||
onclick={reprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
<span class="loading loading-spinner loading-sm"></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>
|
||||
<X class="h-4 w-4" strokeWidth={2} />
|
||||
{/if}
|
||||
Reprovar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success btn-lg gap-2"
|
||||
class="btn btn-success btn-sm md:btn-md gap-2"
|
||||
onclick={aprovar}
|
||||
disabled={processando}
|
||||
>
|
||||
{#if processando}
|
||||
<span class="loading loading-spinner"></span>
|
||||
<span class="loading loading-spinner loading-sm"></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>
|
||||
<Check class="h-4 w-4" strokeWidth={2} />
|
||||
{/if}
|
||||
Aprovar
|
||||
</button>
|
||||
@@ -348,14 +370,14 @@
|
||||
|
||||
<!-- Modal de Reprovação -->
|
||||
{#if motivoReprovacao !== undefined}
|
||||
<div class="mt-4">
|
||||
<div class="border-error/20 bg-error/5 mt-4 rounded-lg border-2 p-3">
|
||||
<div class="form-control">
|
||||
<label class="label" for="motivo-reprovacao">
|
||||
<span class="label-text font-bold">Motivo da Reprovação</span>
|
||||
<label class="label py-1" for="motivo-reprovacao">
|
||||
<span class="label-text text-error text-sm font-bold">Motivo da Reprovação</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="motivo-reprovacao"
|
||||
class="textarea textarea-bordered h-24"
|
||||
class="textarea textarea-bordered textarea-sm focus:border-error focus:outline-error h-20"
|
||||
placeholder="Informe o motivo da reprovação..."
|
||||
bind:value={motivoReprovacao}
|
||||
></textarea>
|
||||
@@ -363,21 +385,9 @@
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>Esta solicitação já foi processada.</span>
|
||||
<div class="alert alert-info shadow-lg py-3">
|
||||
<Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<span class="text-sm">Esta solicitação já foi processada.</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -385,7 +395,7 @@
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => {
|
||||
if (onCancelar) onCancelar();
|
||||
}}
|
||||
@@ -408,7 +418,7 @@
|
||||
|
||||
<style>
|
||||
.aprovar-ausencia {
|
||||
max-width: 900px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<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 UserAvatar from './chat/UserAvatar.svelte';
|
||||
import { Clock, Check, Edit, X, XCircle } from 'lucide-svelte';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
|
||||
type PeriodoFerias = Doc<'ferias'> & {
|
||||
funcionario?: Doc<'funcionarios'> | null;
|
||||
@@ -16,7 +18,7 @@
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
const { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -28,7 +30,7 @@
|
||||
let erro = $state('');
|
||||
|
||||
// Calcular dias do período ajustado
|
||||
const diasAjustados = $derived.by(() => {
|
||||
let diasAjustados = $derived.by(() => {
|
||||
if (!novaDataInicio || !novaDataFim) return 0;
|
||||
const inicio = new Date(novaDataInicio);
|
||||
const fim = new Date(novaDataFim);
|
||||
@@ -69,10 +71,13 @@
|
||||
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
||||
funcionarioId: periodo.funcionario._id,
|
||||
anoReferencia: periodo.anoReferencia,
|
||||
periodos: [{
|
||||
periodos: [
|
||||
{
|
||||
dataInicio: periodo.dataInicio,
|
||||
dataFim: periodo.dataFim
|
||||
}]
|
||||
}
|
||||
],
|
||||
feriasIdExcluir: periodo._id // Excluir este período do cálculo de saldo pendente
|
||||
});
|
||||
|
||||
if (!validacao.valido) {
|
||||
@@ -140,10 +145,12 @@
|
||||
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
||||
funcionarioId: periodo.funcionario._id,
|
||||
anoReferencia: periodo.anoReferencia,
|
||||
periodos: [{
|
||||
periodos: [
|
||||
{
|
||||
dataInicio: novaDataInicio,
|
||||
dataFim: novaDataFim
|
||||
}],
|
||||
}
|
||||
],
|
||||
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
|
||||
});
|
||||
|
||||
@@ -215,6 +222,12 @@
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar
|
||||
fotoPerfilUrl={periodo.funcionario?.fotoPerfilUrl}
|
||||
nome={periodo.funcionario?.nome || 'Funcionário'}
|
||||
size="md"
|
||||
/>
|
||||
<div>
|
||||
<h2 class="card-title text-2xl">
|
||||
{periodo.funcionario?.nome || 'Funcionário'}
|
||||
@@ -223,6 +236,7 @@
|
||||
Ano de Referência: {periodo.anoReferencia}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
|
||||
{getStatusTexto(periodo.status)}
|
||||
</div>
|
||||
@@ -235,15 +249,11 @@
|
||||
<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
|
||||
>
|
||||
<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
|
||||
>
|
||||
<span class="ml-1 font-semibold">{formatarDataString(periodo.dataFim)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-base-content/70">Dias:</span>
|
||||
@@ -270,20 +280,7 @@
|
||||
<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>
|
||||
<Clock class="h-3 w-3" strokeWidth={2} />
|
||||
<span>{formatarData(hist.data)}</span>
|
||||
<span>-</span>
|
||||
<span>{hist.acao}</span>
|
||||
@@ -307,20 +304,7 @@
|
||||
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>
|
||||
<Check class="h-5 w-5" strokeWidth={2} />
|
||||
Aprovar
|
||||
</button>
|
||||
|
||||
@@ -330,20 +314,7 @@
|
||||
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>
|
||||
<Edit class="h-5 w-5" strokeWidth={2} />
|
||||
Ajustar Datas e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
@@ -364,20 +335,7 @@
|
||||
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>
|
||||
<X class="h-4 w-4" strokeWidth={2} />
|
||||
Reprovar
|
||||
</button>
|
||||
</div>
|
||||
@@ -445,20 +403,7 @@
|
||||
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>
|
||||
<Check class="h-4 w-4" strokeWidth={2} />
|
||||
Confirmar e Aprovar
|
||||
</button>
|
||||
</div>
|
||||
@@ -466,45 +411,56 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Informações de Aprovação/Reprovação -->
|
||||
{#if periodo.status === 'aprovado' || periodo.status === 'data_ajustada_aprovada' || periodo.status === 'EmFérias'}
|
||||
<div class="alert alert-success mt-4">
|
||||
<Check class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Aprovado</div>
|
||||
{#if periodo.gestor}
|
||||
<div class="text-sm mt-1">
|
||||
Por: <strong>{periodo.gestor.nome}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
{#if periodo.dataAprovacao}
|
||||
<div class="text-xs mt-1 opacity-80">
|
||||
Em: {formatarData(periodo.dataAprovacao)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Motivo Reprovação (se reprovado) -->
|
||||
{#if periodo.status === 'reprovado' && periodo.motivoReprovacao}
|
||||
{#if periodo.status === 'reprovado'}
|
||||
<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>
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<div class="font-bold">Reprovado</div>
|
||||
{#if periodo.gestor}
|
||||
<div class="text-sm mt-1">
|
||||
Por: <strong>{periodo.gestor.nome}</strong>
|
||||
</div>
|
||||
{/if}
|
||||
{#if periodo.dataReprovacao}
|
||||
<div class="text-xs mt-1 opacity-80">
|
||||
Em: {formatarData(periodo.dataReprovacao)}
|
||||
</div>
|
||||
{/if}
|
||||
{#if periodo.motivoReprovacao}
|
||||
<div class="mt-2">
|
||||
<div class="text-sm font-semibold">Motivo:</div>
|
||||
<div class="text-sm">{periodo.motivoReprovacao}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</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>
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Calendar } from "@fullcalendar/core";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
|
||||
import type { EventInput } from "@fullcalendar/core/index.js";
|
||||
import { onMount } from 'svelte';
|
||||
import { Calendar } from '@fullcalendar/core';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import type { EventInput } from '@fullcalendar/core/index.js';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
eventos: Array<{
|
||||
@@ -20,7 +21,7 @@
|
||||
tipoFiltro?: string;
|
||||
}
|
||||
|
||||
let { eventos, tipoFiltro = "todos" }: Props = $props();
|
||||
let { eventos, tipoFiltro = 'todos' }: Props = $props();
|
||||
|
||||
let calendarEl: HTMLDivElement;
|
||||
let calendar: Calendar | null = null;
|
||||
@@ -35,26 +36,37 @@
|
||||
} | null>(null);
|
||||
|
||||
// Eventos filtrados
|
||||
const eventosFiltrados = $derived.by(() => {
|
||||
if (filtroAtivo === "todos") return eventos;
|
||||
let eventosFiltrados = $derived.by(() => {
|
||||
if (filtroAtivo === 'todos') return eventos;
|
||||
return eventos.filter((e) => e.tipo === filtroAtivo);
|
||||
});
|
||||
|
||||
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
|
||||
function calcularDataFim(dataFim: string): string {
|
||||
// Usar SvelteDate para evitar problemas de mutabilidade e timezone
|
||||
const data = new SvelteDate(dataFim + 'T00:00:00');
|
||||
data.setDate(data.getDate() + 1);
|
||||
return data.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
// Converter eventos para formato FullCalendar
|
||||
const eventosFullCalendar = $derived.by(() => {
|
||||
return eventosFiltrados.map((evento) => ({
|
||||
id: evento.id,
|
||||
title: evento.title,
|
||||
start: evento.start,
|
||||
end: evento.end,
|
||||
end: calcularDataFim(evento.end), // Ajustar data fim (exclusive end)
|
||||
allDay: true, // IMPORTANTE: Tratar como dia inteiro sem timezone
|
||||
backgroundColor: evento.color,
|
||||
borderColor: evento.color,
|
||||
textColor: "#ffffff",
|
||||
textColor: '#ffffff',
|
||||
extendedProps: {
|
||||
tipo: evento.tipo,
|
||||
funcionarioNome: evento.funcionarioNome,
|
||||
funcionarioId: evento.funcionarioId,
|
||||
},
|
||||
dataInicioOriginal: evento.start, // Armazenar data original para exibição
|
||||
dataFimOriginal: evento.end // Armazenar data original para exibição
|
||||
}
|
||||
})) as EventInput[];
|
||||
});
|
||||
|
||||
@@ -63,44 +75,46 @@
|
||||
|
||||
calendar = new Calendar(calendarEl, {
|
||||
plugins: [dayGridPlugin, interactionPlugin],
|
||||
initialView: "dayGridMonth",
|
||||
initialView: 'dayGridMonth',
|
||||
locale: ptBrLocale,
|
||||
firstDay: 0, // Domingo
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right: "dayGridMonth",
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth'
|
||||
},
|
||||
buttonText: {
|
||||
today: "Hoje",
|
||||
month: "Mês",
|
||||
week: "Semana",
|
||||
day: "Dia",
|
||||
today: 'Hoje',
|
||||
month: 'Mês',
|
||||
week: 'Semana',
|
||||
day: 'Dia'
|
||||
},
|
||||
events: eventosFullCalendar,
|
||||
eventClick: (info) => {
|
||||
// Usar datas originais armazenadas nos extendedProps para exibição correta
|
||||
const props = info.event.extendedProps;
|
||||
eventoSelecionado = {
|
||||
title: info.event.title,
|
||||
start: info.event.startStr || "",
|
||||
end: info.event.endStr || "",
|
||||
tipo: info.event.extendedProps.tipo as string,
|
||||
funcionarioNome: info.event.extendedProps.funcionarioNome as string,
|
||||
start: (props.dataInicioOriginal as string) || info.event.startStr || '',
|
||||
end: (props.dataFimOriginal as string) || info.event.endStr || '',
|
||||
tipo: props.tipo as string,
|
||||
funcionarioNome: props.funcionarioNome as string
|
||||
};
|
||||
showModal = true;
|
||||
},
|
||||
eventDisplay: "block",
|
||||
eventDisplay: 'block',
|
||||
dayMaxEvents: 3,
|
||||
moreLinkClick: "popover",
|
||||
height: "auto",
|
||||
contentHeight: "auto",
|
||||
moreLinkClick: 'popover',
|
||||
height: 'auto',
|
||||
contentHeight: 'auto',
|
||||
aspectRatio: 1.8,
|
||||
eventMouseEnter: (info) => {
|
||||
info.el.style.cursor = "pointer";
|
||||
info.el.style.opacity = "0.9";
|
||||
info.el.style.cursor = 'pointer';
|
||||
info.el.style.opacity = '0.9';
|
||||
},
|
||||
eventMouseLeave: (info) => {
|
||||
info.el.style.opacity = "1";
|
||||
},
|
||||
info.el.style.opacity = '1';
|
||||
}
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
@@ -122,53 +136,51 @@
|
||||
});
|
||||
|
||||
function formatarData(data: string): string {
|
||||
return new Date(data).toLocaleDateString("pt-BR", {
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
return new Date(data).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function getTipoNome(tipo: string): string {
|
||||
const nomes: Record<string, string> = {
|
||||
atestado_medico: "Atestado Médico",
|
||||
declaracao_comparecimento: "Declaração de Comparecimento",
|
||||
maternidade: "Licença Maternidade",
|
||||
paternidade: "Licença Paternidade",
|
||||
ferias: "Férias",
|
||||
atestado_medico: 'Atestado Médico',
|
||||
declaracao_comparecimento: 'Declaração de Comparecimento',
|
||||
maternidade: 'Licença Maternidade',
|
||||
paternidade: 'Licença Paternidade',
|
||||
ferias: 'Férias'
|
||||
};
|
||||
return nomes[tipo] || tipo;
|
||||
}
|
||||
|
||||
function getTipoCor(tipo: string): string {
|
||||
const cores: Record<string, string> = {
|
||||
atestado_medico: "text-error",
|
||||
declaracao_comparecimento: "text-warning",
|
||||
maternidade: "text-secondary",
|
||||
paternidade: "text-info",
|
||||
ferias: "text-success",
|
||||
atestado_medico: 'text-error',
|
||||
declaracao_comparecimento: 'text-warning',
|
||||
maternidade: 'text-secondary',
|
||||
paternidade: 'text-info',
|
||||
ferias: 'text-success'
|
||||
};
|
||||
return cores[tipo] || "text-base-content";
|
||||
return cores[tipo] || 'text-base-content';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<!-- Header com filtros -->
|
||||
<div
|
||||
class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6"
|
||||
>
|
||||
<div class="mb-6 flex flex-col items-start justify-between gap-4 md:flex-row md:items-center">
|
||||
<h2 class="card-title text-2xl">Calendário de Afastamentos</h2>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-base-content/70">Filtrar:</span>
|
||||
<span class="text-base-content/70 text-sm font-medium">Filtrar:</span>
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm {filtroAtivo === 'todos'
|
||||
? 'btn-active btn-primary'
|
||||
: 'btn-ghost'}"
|
||||
onclick={() => (filtroAtivo = "todos")}
|
||||
onclick={() => (filtroAtivo = 'todos')}
|
||||
>
|
||||
Todos
|
||||
</button>
|
||||
@@ -176,16 +188,15 @@
|
||||
class="join-item btn btn-sm {filtroAtivo === 'atestado_medico'
|
||||
? 'btn-active btn-error'
|
||||
: 'btn-ghost'}"
|
||||
onclick={() => (filtroAtivo = "atestado_medico")}
|
||||
onclick={() => (filtroAtivo = 'atestado_medico')}
|
||||
>
|
||||
Atestados
|
||||
</button>
|
||||
<button
|
||||
class="join-item btn btn-sm {filtroAtivo ===
|
||||
'declaracao_comparecimento'
|
||||
class="join-item btn btn-sm {filtroAtivo === 'declaracao_comparecimento'
|
||||
? 'btn-active btn-warning'
|
||||
: 'btn-ghost'}"
|
||||
onclick={() => (filtroAtivo = "declaracao_comparecimento")}
|
||||
onclick={() => (filtroAtivo = 'declaracao_comparecimento')}
|
||||
>
|
||||
Declarações
|
||||
</button>
|
||||
@@ -193,7 +204,7 @@
|
||||
class="join-item btn btn-sm {filtroAtivo === 'maternidade'
|
||||
? 'btn-active btn-secondary'
|
||||
: 'btn-ghost'}"
|
||||
onclick={() => (filtroAtivo = "maternidade")}
|
||||
onclick={() => (filtroAtivo = 'maternidade')}
|
||||
>
|
||||
Maternidade
|
||||
</button>
|
||||
@@ -201,7 +212,7 @@
|
||||
class="join-item btn btn-sm {filtroAtivo === 'paternidade'
|
||||
? 'btn-active btn-info'
|
||||
: 'btn-ghost'}"
|
||||
onclick={() => (filtroAtivo = "paternidade")}
|
||||
onclick={() => (filtroAtivo = 'paternidade')}
|
||||
>
|
||||
Paternidade
|
||||
</button>
|
||||
@@ -209,7 +220,7 @@
|
||||
class="join-item btn btn-sm {filtroAtivo === 'ferias'
|
||||
? 'btn-active btn-success'
|
||||
: 'btn-ghost'}"
|
||||
onclick={() => (filtroAtivo = "ferias")}
|
||||
onclick={() => (filtroAtivo = 'ferias')}
|
||||
>
|
||||
Férias
|
||||
</button>
|
||||
@@ -218,25 +229,25 @@
|
||||
</div>
|
||||
|
||||
<!-- Legenda -->
|
||||
<div class="flex flex-wrap gap-4 mb-4 p-4 bg-base-200/50 rounded-lg">
|
||||
<div class="bg-base-200/50 mb-4 flex flex-wrap gap-4 rounded-lg p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded bg-error"></div>
|
||||
<div class="bg-error h-4 w-4 rounded"></div>
|
||||
<span class="text-sm">Atestado Médico</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded bg-warning"></div>
|
||||
<div class="bg-warning h-4 w-4 rounded"></div>
|
||||
<span class="text-sm">Declaração</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded bg-secondary"></div>
|
||||
<div class="bg-secondary h-4 w-4 rounded"></div>
|
||||
<span class="text-sm">Licença Maternidade</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded bg-info"></div>
|
||||
<div class="bg-info h-4 w-4 rounded"></div>
|
||||
<span class="text-sm">Licença Paternidade</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4 h-4 rounded bg-success"></div>
|
||||
<div class="bg-success h-4 w-4 rounded"></div>
|
||||
<span class="text-sm">Férias</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,23 +269,17 @@
|
||||
>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bg-base-100 rounded-2xl shadow-2xl w-full max-w-md mx-4 transform transition-all"
|
||||
class="bg-base-100 mx-4 w-full max-w-md transform rounded-2xl shadow-2xl transition-all"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header do Modal -->
|
||||
<div
|
||||
class="p-6 border-b border-base-300 bg-linear-to-r from-primary/10 to-secondary/10"
|
||||
>
|
||||
<div class="border-base-300 from-primary/10 to-secondary/10 border-b bg-linear-to-r p-6">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-xl font-bold text-base-content mb-2">
|
||||
<h3 class="text-base-content mb-2 text-xl font-bold">
|
||||
{eventoSelecionado.funcionarioNome}
|
||||
</h3>
|
||||
<p
|
||||
class="text-sm {getTipoCor(
|
||||
eventoSelecionado.tipo,
|
||||
)} font-medium"
|
||||
>
|
||||
<p class="text-sm {getTipoCor(eventoSelecionado.tipo)} font-medium">
|
||||
{getTipoNome(eventoSelecionado.tipo)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -302,11 +307,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo do Modal -->
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
|
||||
<div class="space-y-4 p-6">
|
||||
<div class="bg-base-200/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-primary"
|
||||
class="text-primary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -319,17 +324,17 @@
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Data Início</p>
|
||||
<p class="text-base-content/60 text-sm">Data Início</p>
|
||||
<p class="font-semibold">
|
||||
{formatarData(eventoSelecionado.start)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
|
||||
<div class="bg-base-200/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-secondary"
|
||||
class="text-secondary h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -342,17 +347,17 @@
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Data Fim</p>
|
||||
<p class="text-base-content/60 text-sm">Data Fim</p>
|
||||
<p class="font-semibold">
|
||||
{formatarData(eventoSelecionado.end)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 p-4 bg-base-200/50 rounded-lg">
|
||||
<div class="bg-base-200/50 flex items-center gap-3 rounded-lg p-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-accent"
|
||||
class="text-accent h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -365,15 +370,16 @@
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/60">Duração</p>
|
||||
<p class="text-base-content/60 text-sm">Duração</p>
|
||||
<p class="font-semibold">
|
||||
{(() => {
|
||||
const inicio = new Date(eventoSelecionado.start);
|
||||
const fim = new Date(eventoSelecionado.end);
|
||||
// Usar SvelteDate para evitar problemas de mutabilidade e timezone
|
||||
const inicio = new SvelteDate(eventoSelecionado.start + 'T00:00:00');
|
||||
const fim = new SvelteDate(eventoSelecionado.end + 'T00:00:00');
|
||||
// Não precisa ajustar porque estamos usando as datas originais dos extendedProps
|
||||
const diffTime = Math.abs(fim.getTime() - inicio.getTime());
|
||||
const diffDays =
|
||||
Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return `${diffDays} ${diffDays === 1 ? "dia" : "dias"}`;
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
return `${diffDays} ${diffDays === 1 ? 'dia' : 'dias'}`;
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -381,10 +387,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer do Modal -->
|
||||
<div class="p-6 border-t border-base-300 flex justify-end">
|
||||
<button class="btn btn-primary" onclick={() => (showModal = false)}>
|
||||
Fechar
|
||||
</button>
|
||||
<div class="border-base-300 flex justify-end border-t p-6">
|
||||
<button class="btn btn-primary" onclick={() => (showModal = false)}> Fechar </button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
135
apps/web/src/lib/components/ConfirmModal.svelte
Normal file
135
apps/web/src/lib/components/ConfirmModal.svelte
Normal file
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = 'Confirmar ação',
|
||||
message,
|
||||
confirmText = 'Confirmar',
|
||||
cancelText = 'Cancelar',
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: Props = $props();
|
||||
|
||||
function handleConfirm() {
|
||||
open = false;
|
||||
onConfirm();
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
open = false;
|
||||
onCancel();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-[9999]"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-confirm-title"
|
||||
>
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="pointer-events-auto absolute inset-0 bg-black/40 backdrop-blur-sm transition-opacity duration-200"
|
||||
onclick={handleCancel}
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="bg-base-100 pointer-events-auto absolute left-1/2 top-1/2 z-10 flex max-h-[90vh] w-full max-w-md transform -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="border-base-300 from-warning/10 to-warning/5 flex flex-shrink-0 items-center justify-between border-b bg-linear-to-r px-6 py-4"
|
||||
>
|
||||
<h2 id="modal-confirm-title" class="text-warning flex items-center gap-2 text-xl font-bold">
|
||||
<AlertTriangle class="h-6 w-6" strokeWidth={2.5} />
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
|
||||
onclick={handleCancel}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||
<p class="text-base-content text-base leading-relaxed">{message}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="border-base-300 flex flex-shrink-0 justify-end gap-3 border-t px-6 py-4">
|
||||
<button class="btn btn-ghost" onclick={handleCancel}>{cancelText}</button>
|
||||
<button class="btn btn-warning" onclick={handleConfirm}>{confirmText}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -40%) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar customizada */
|
||||
:global(.modal-scroll) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-thumb) {
|
||||
background-color: hsl(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
|
||||
background-color: hsl(var(--bc) / 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
133
apps/web/src/lib/components/ConfirmationModal.svelte
Normal file
133
apps/web/src/lib/components/ConfirmationModal.svelte
Normal file
@@ -0,0 +1,133 @@
|
||||
<script lang="ts">
|
||||
import { AlertTriangle, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
isDestructive?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
open = $bindable(false),
|
||||
title = 'Confirmar Ação',
|
||||
message,
|
||||
confirmText = 'Confirmar',
|
||||
cancelText = 'Cancelar',
|
||||
isDestructive = false,
|
||||
onConfirm,
|
||||
onClose
|
||||
}: Props = $props();
|
||||
|
||||
// Tenta centralizar, mas se tiver um contexto específico pode ser ajustado
|
||||
// Por padrão, centralizado.
|
||||
function getModalStyle() {
|
||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 500px;';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
open = false;
|
||||
onConfirm();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-50"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-confirm-title"
|
||||
>
|
||||
<!-- Backdrop leve -->
|
||||
<div
|
||||
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
|
||||
onclick={handleClose}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="pointer-events-auto absolute z-10 flex w-full max-w-lg flex-col overflow-hidden rounded-2xl bg-white shadow-2xl transition-all duration-300"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex shrink-0 items-center justify-between border-b border-gray-100 px-6 py-4">
|
||||
<h2
|
||||
id="modal-confirm-title"
|
||||
class="flex items-center gap-2 text-xl font-bold {isDestructive
|
||||
? 'text-red-600'
|
||||
: 'text-gray-900'}"
|
||||
>
|
||||
{#if isDestructive}
|
||||
<AlertTriangle class="h-6 w-6" strokeWidth={2.5} />
|
||||
{/if}
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto px-6 py-6">
|
||||
<p class="text-base leading-relaxed font-medium text-gray-700">{message}</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex shrink-0 justify-end gap-3 border-t border-gray-100 bg-gray-50 px-6 py-4">
|
||||
<button
|
||||
class="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-200"
|
||||
onclick={handleClose}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg px-4 py-2 text-sm font-medium text-white shadow-sm {isDestructive
|
||||
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
|
||||
: 'bg-blue-600 hover:bg-blue-700 focus:ring-blue-500'}"
|
||||
onclick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
14
apps/web/src/lib/components/DecorativeTopLine.svelte
Normal file
14
apps/web/src/lib/components/DecorativeTopLine.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'via-primary absolute top-0 left-0 h-1 w-full bg-linear-to-r from-transparent to-transparent opacity-50',
|
||||
className
|
||||
]}
|
||||
></div>
|
||||
22
apps/web/src/lib/components/ErrorMessage.svelte
Normal file
22
apps/web/src/lib/components/ErrorMessage.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { XCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
message?: string | null;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { message = null, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if message}
|
||||
<div
|
||||
class={[
|
||||
'border-error/20 bg-error/10 text-error-content/90 mb-6 flex items-center gap-3 rounded-lg border p-4 backdrop-blur-md',
|
||||
className
|
||||
]}
|
||||
>
|
||||
<XCircle class="h-5 w-5 shrink-0" />
|
||||
<span class="text-sm font-medium">{message}</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, X } from 'lucide-svelte';
|
||||
import { AlertCircle, HelpCircle, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -11,38 +11,132 @@
|
||||
|
||||
let { open = $bindable(false), title = 'Erro', message, details, onClose }: Props = $props();
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Função para calcular a posição baseada no card de registro de ponto
|
||||
function calcularPosicaoModal() {
|
||||
// Procurar pelo elemento do card de registro de ponto
|
||||
const cardRef = document.getElementById('card-registro-ponto-ref');
|
||||
|
||||
if (cardRef) {
|
||||
const rect = cardRef.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
|
||||
const top = rect.top;
|
||||
|
||||
// Garantir que o modal não saia da viewport
|
||||
// Considerar uma altura mínima do modal (aproximadamente 300px)
|
||||
const minTop = 20;
|
||||
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
|
||||
const finalTop = Math.max(minTop, Math.min(top, maxTop));
|
||||
|
||||
// Centralizar horizontalmente
|
||||
return {
|
||||
top: finalTop,
|
||||
left: window.innerWidth / 2
|
||||
};
|
||||
}
|
||||
|
||||
// Se não encontrar, usar posição padrão (centro da tela)
|
||||
return null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
|
||||
const updatePosition = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calcularPosicaoModal();
|
||||
if (pos) {
|
||||
modalPosition = pos;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Aguardar um pouco mais para garantir que o DOM está atualizado
|
||||
setTimeout(updatePosition, 50);
|
||||
|
||||
// Adicionar listener de scroll para atualizar posição
|
||||
const handleScroll = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
} else {
|
||||
// Limpar posição quando o modal for fechado
|
||||
modalPosition = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Função para obter estilo do modal baseado na posição calculada
|
||||
function getModalStyle() {
|
||||
if (modalPosition) {
|
||||
// Posicionar na altura do card, centralizado horizontalmente
|
||||
// position: fixed já é relativo à viewport, então podemos usar diretamente
|
||||
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
|
||||
}
|
||||
// Se não houver posição calculada, centralizar na tela
|
||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
|
||||
}
|
||||
|
||||
// Verificar se details contém instruções ou apenas detalhes técnicos
|
||||
let temInstrucoes = $derived.by(() => {
|
||||
if (!details) return false;
|
||||
// Se contém palavras-chave de instruções, é uma instrução
|
||||
return (
|
||||
details.includes('Por favor') ||
|
||||
details.includes('aguarde') ||
|
||||
details.includes('recarregue') ||
|
||||
details.includes('Verifique') ||
|
||||
details.includes('tente novamente') ||
|
||||
details.match(/^\d+\./)
|
||||
); // Começa com número (lista numerada)
|
||||
});
|
||||
|
||||
function handleClose() {
|
||||
open = false;
|
||||
onClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open && modalRef) {
|
||||
modalRef.showModal();
|
||||
} else if (!open && modalRef) {
|
||||
modalRef.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<dialog
|
||||
bind:this={modalRef}
|
||||
class="modal"
|
||||
onclick={(e) => e.target === e.currentTarget && handleClose()}
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-50"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-error-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-error flex items-center gap-2 text-xl font-bold">
|
||||
<AlertCircle class="h-5 w-5" strokeWidth={2} />
|
||||
<!-- Backdrop leve -->
|
||||
<div
|
||||
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
|
||||
onclick={handleClose}
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="bg-base-100 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl shadow-2xl transition-all duration-300"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header fixo -->
|
||||
<div
|
||||
class="border-base-300 flex flex-shrink-0 items-center justify-between border-b px-6 py-4"
|
||||
>
|
||||
<h2 id="modal-error-title" class="text-error flex items-center gap-2 text-xl font-bold">
|
||||
<AlertCircle class="h-6 w-6" strokeWidth={2.5} />
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle"
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300"
|
||||
onclick={handleClose}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
@@ -50,24 +144,102 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="px-6 py-6">
|
||||
<p class="text-base-content mb-4">{message}</p>
|
||||
<!-- Content com rolagem -->
|
||||
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||
<!-- Mensagem principal -->
|
||||
<div class="mb-6">
|
||||
<p class="text-base-content text-base leading-relaxed font-medium">{message}</p>
|
||||
</div>
|
||||
|
||||
<!-- Instruções ou detalhes (se houver) -->
|
||||
{#if details}
|
||||
<div class="bg-base-200 mb-4 rounded-lg p-4">
|
||||
<p class="text-base-content/70 text-sm whitespace-pre-line">{details}</p>
|
||||
<div class="bg-info/10 border-info/30 mb-4 rounded-lg border-l-4 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<HelpCircle class="text-info mt-0.5 h-5 w-5 shrink-0" strokeWidth={2} />
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/90 mb-2 text-sm font-semibold">
|
||||
{temInstrucoes ? 'Como resolver:' : 'Informação adicional:'}
|
||||
</p>
|
||||
<div class="text-base-content/80 space-y-2 text-sm">
|
||||
{#each details
|
||||
.split('\n')
|
||||
.filter((line) => line.trim().length > 0) as linha (linha)}
|
||||
{#if linha.trim().match(/^\d+\./)}
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-info shrink-0 font-semibold"
|
||||
>{linha.trim().split('.')[0]}.</span
|
||||
>
|
||||
<span class="flex-1 leading-relaxed"
|
||||
>{linha
|
||||
.trim()
|
||||
.substring(linha.trim().indexOf('.') + 1)
|
||||
.trim()}</span
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="leading-relaxed">{linha.trim()}</p>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action px-6 pb-6">
|
||||
<button class="btn btn-primary" onclick={handleClose}> Fechar </button>
|
||||
<!-- Footer fixo -->
|
||||
<div class="border-base-300 flex flex-shrink-0 justify-end border-t px-6 py-4">
|
||||
<button class="btn btn-primary" onclick={handleClose}>Entendi, obrigado</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={handleClose}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/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,6 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import {
|
||||
ExternalLink,
|
||||
FileText,
|
||||
|
||||
57
apps/web/src/lib/components/Footer.svelte
Normal file
57
apps/web/src/lib/components/Footer.svelte
Normal file
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
</script>
|
||||
|
||||
<footer class="bg-base-200 text-base-content border-base-300 mt-16 border-t">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="grid grid-cols-1 gap-8 text-center md:grid-cols-3 md:text-left">
|
||||
<div>
|
||||
<h3 class="text-primary mb-4 text-lg font-bold">SGSE</h3>
|
||||
<p class="mx-auto max-w-xs text-sm opacity-75 md:mx-0">
|
||||
Sistema de Gestão de Secretaria<br />
|
||||
Simplificando processos e conectando pessoas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-bold">Links Úteis</h3>
|
||||
<ul class="space-y-2 text-sm opacity-75">
|
||||
<li>
|
||||
<a
|
||||
href="https://www.pe.gov.br/"
|
||||
target="_blank"
|
||||
class="hover:text-primary transition-colors">Portal do Governo</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/privacidade')} class="hover:text-primary transition-colors"
|
||||
>Política de Privacidade</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/abrir-chamado')} class="hover:text-primary transition-colors"
|
||||
>Suporte</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="mb-4 text-lg font-bold">Contato</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Secretaria de Educação<br />
|
||||
Recife - PE
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider mt-8 mb-4"></div>
|
||||
|
||||
<div class="flex flex-col items-center justify-between text-sm opacity-60 md:flex-row">
|
||||
<p>© {currentYear} Governo de Pernambuco. Todos os direitos reservados.</p>
|
||||
<p class="mt-2 md:mt-0">Desenvolvido com tecnologia de ponta.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -0,0 +1,131 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string; // Matrícula do funcionário
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = 'Digite a matrícula do funcionário',
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
// Usar value diretamente como busca para evitar conflitos de sincronização
|
||||
let mostrarDropdown = $state(false);
|
||||
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
|
||||
|
||||
// Filtrar funcionários baseado na busca (por matrícula ou nome)
|
||||
let funcionariosFiltrados = $derived.by(() => {
|
||||
if (!value || !value.trim()) return funcionarios.slice(0, 10); // Limitar a 10 quando vazio
|
||||
|
||||
const termo = value.toLowerCase().trim();
|
||||
return funcionarios
|
||||
.filter((f) => {
|
||||
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
|
||||
const nomeMatch = f.nome?.toLowerCase().includes(termo);
|
||||
return matriculaMatch || nomeMatch;
|
||||
})
|
||||
.slice(0, 20); // Limitar resultados
|
||||
});
|
||||
|
||||
function selecionarFuncionario(matricula: string) {
|
||||
value = matricula;
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay para permitir click no dropdown
|
||||
setTimeout(() => {
|
||||
mostrarDropdown = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
bind:value
|
||||
oninput={handleInput}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
class="input input-bordered w-full pr-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/40 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
|
||||
>
|
||||
{#each funcionariosFiltrados as funcionario}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selecionarFuncionario(funcionario.matricula || '')}
|
||||
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{#if funcionario.matricula}
|
||||
Matrícula: {funcionario.matricula}
|
||||
{:else}
|
||||
Sem matrícula
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{funcionario.nome}
|
||||
{#if funcionario.descricaoCargo}
|
||||
{funcionario.nome ? ' • ' : ''}
|
||||
{funcionario.descricaoCargo}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
|
||||
>
|
||||
<div class="text-sm">Nenhum funcionário encontrado</div>
|
||||
<div class="mt-1 text-xs opacity-70">
|
||||
Você pode continuar digitando para buscar livremente
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
127
apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte
Normal file
127
apps/web/src/lib/components/FuncionarioNomeAutocomplete.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string; // Nome do funcionário
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholder = 'Digite o nome do funcionário',
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
// Usar value diretamente como busca para evitar conflitos de sincronização
|
||||
let mostrarDropdown = $state(false);
|
||||
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
|
||||
|
||||
// Filtrar funcionários baseado na busca (por nome ou matrícula)
|
||||
let funcionariosFiltrados = $derived.by(() => {
|
||||
if (!value || !value.trim()) return funcionarios.slice(0, 10); // Limitar a 10 quando vazio
|
||||
|
||||
const termo = value.toLowerCase().trim();
|
||||
return funcionarios
|
||||
.filter((f) => {
|
||||
const nomeMatch = f.nome?.toLowerCase().includes(termo);
|
||||
const matriculaMatch = f.matricula?.toLowerCase().includes(termo);
|
||||
return nomeMatch || matriculaMatch;
|
||||
})
|
||||
.slice(0, 20); // Limitar resultados
|
||||
});
|
||||
|
||||
function selecionarFuncionario(nome: string) {
|
||||
value = nome;
|
||||
mostrarDropdown = false;
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
if (!disabled) {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
// Delay para permitir click no dropdown
|
||||
setTimeout(() => {
|
||||
mostrarDropdown = false;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
mostrarDropdown = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="relative w-full">
|
||||
<input
|
||||
type="text"
|
||||
bind:value
|
||||
oninput={handleInput}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
onfocus={handleFocus}
|
||||
onblur={handleBlur}
|
||||
class="input input-bordered w-full pr-10"
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<div class="pointer-events-none absolute top-1/2 right-3 -translate-y-1/2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/40 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{#if mostrarDropdown && funcionariosFiltrados.length > 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border shadow-lg"
|
||||
>
|
||||
{#each funcionariosFiltrados as funcionario}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => selecionarFuncionario(funcionario.nome || '')}
|
||||
class="hover:bg-base-200 border-base-200 w-full border-b px-4 py-3 text-left transition-colors last:border-b-0"
|
||||
>
|
||||
<div class="font-medium">{funcionario.nome}</div>
|
||||
<div class="text-base-content/60 text-sm">
|
||||
{#if funcionario.matricula}
|
||||
Matrícula: {funcionario.matricula}
|
||||
{/if}
|
||||
{#if funcionario.descricaoCargo}
|
||||
{funcionario.matricula ? ' • ' : ''}
|
||||
{funcionario.descricaoCargo}
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mostrarDropdown && value && value.trim() && funcionariosFiltrados.length === 0}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 text-base-content/60 absolute z-50 mt-1 w-full rounded-lg border p-4 text-center shadow-lg"
|
||||
>
|
||||
<div class="text-sm">Nenhum funcionário encontrado</div>
|
||||
<div class="mt-1 text-xs opacity-70">
|
||||
Você pode continuar digitando para buscar livremente
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<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';
|
||||
|
||||
interface Props {
|
||||
value?: string; // Id do funcionário selecionado
|
||||
@@ -23,10 +23,10 @@
|
||||
// Buscar funcionários
|
||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||
|
||||
const funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
|
||||
let funcionarios = $derived(funcionariosQuery?.data?.filter((f) => !f.desligamentoData) || []);
|
||||
|
||||
// Filtrar funcionários baseado na busca
|
||||
const funcionariosFiltrados = $derived.by(() => {
|
||||
let funcionariosFiltrados = $derived.by(() => {
|
||||
if (!busca.trim()) return funcionarios;
|
||||
|
||||
const termo = busca.toLowerCase().trim();
|
||||
@@ -39,7 +39,7 @@
|
||||
});
|
||||
|
||||
// Funcionário selecionado
|
||||
const funcionarioSelecionado = $derived.by(() => {
|
||||
let funcionarioSelecionado = $derived.by(() => {
|
||||
if (!value) return null;
|
||||
return funcionarios.find((f) => f._id === value);
|
||||
});
|
||||
|
||||
19
apps/web/src/lib/components/GlassCard.svelte
Normal file
19
apps/web/src/lib/components/GlassCard.svelte
Normal file
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'border-base-content/10 bg-base-content/5 ring-base-content/10 relative overflow-hidden rounded-2xl border p-8 shadow-2xl ring-1 backdrop-blur-xl transition-all duration-300',
|
||||
className
|
||||
]}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -1,7 +1,101 @@
|
||||
<script lang="ts">
|
||||
import logo from "$lib/assets/logo_governo_PE.png";
|
||||
import { resolve } from '$app/paths';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { aplicarTemaDaisyUI } from '$lib/utils/temas';
|
||||
|
||||
type HeaderProps = {
|
||||
left?: Snippet;
|
||||
right?: Snippet;
|
||||
};
|
||||
|
||||
const { left, right }: HeaderProps = $props();
|
||||
|
||||
let themeSelectEl: HTMLSelectElement | null = null;
|
||||
|
||||
function safeGetThemeLS(): string | null {
|
||||
try {
|
||||
const t = localStorage.getItem('theme');
|
||||
return t && t.trim() ? t : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const persisted = safeGetThemeLS();
|
||||
if (persisted) {
|
||||
// Sincroniza UI + HTML com o valor persistido (evita select ficar "aqua" indevido)
|
||||
if (themeSelectEl && themeSelectEl.value !== persisted) {
|
||||
themeSelectEl.value = persisted;
|
||||
}
|
||||
aplicarTemaDaisyUI(persisted);
|
||||
}
|
||||
});
|
||||
|
||||
function onThemeChange(e: Event) {
|
||||
const nextValue = (e.currentTarget as HTMLSelectElement | null)?.value ?? null;
|
||||
|
||||
// Se o theme-change não atualizar (caso comum após login/logout),
|
||||
// garantimos aqui a persistência + aplicação imediata.
|
||||
if (nextValue) {
|
||||
try {
|
||||
localStorage.setItem('theme', nextValue);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
aplicarTemaDaisyUI(nextValue);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="navbar bg-base-200 shadow-sm p-4 w-76">
|
||||
<img src={logo} alt="Logo" class="" />
|
||||
<header
|
||||
class="bg-base-200 border-base-100 sticky top-0 z-50 w-full border-b py-3 shadow-sm backdrop-blur-md transition-all duration-300"
|
||||
>
|
||||
<div class=" flex h-16 w-full items-center justify-between px-4">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if left}
|
||||
{@render left()}
|
||||
{/if}
|
||||
|
||||
<a
|
||||
href={resolve('/')}
|
||||
class="group flex items-center gap-3 transition-transform hover:scale-[1.02]"
|
||||
>
|
||||
<img src={logo} alt="Logo Governo PE" class="h-10 w-auto object-contain drop-shadow-sm" />
|
||||
<div class="hidden flex-col sm:flex">
|
||||
<span class="text-primary text-2xl font-bold tracking-wider uppercase">SGSE</span>
|
||||
<span class="text-base-content -mt-1 text-lg leading-none font-extrabold tracking-tight"
|
||||
>Sistema de Gestão da Secretaria de Esportes</span
|
||||
>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<select
|
||||
bind:this={themeSelectEl}
|
||||
class="select select-sm bg-base-100 border-base-300 w-40"
|
||||
aria-label="Selecionar tema"
|
||||
data-choose-theme
|
||||
onchange={onThemeChange}
|
||||
>
|
||||
<option value="aqua">Aqua</option>
|
||||
<option value="sgse-blue">Azul</option>
|
||||
<option value="sgse-green">Verde</option>
|
||||
<option value="sgse-orange">Laranja</option>
|
||||
<option value="sgse-red">Vermelho</option>
|
||||
<option value="sgse-pink">Rosa</option>
|
||||
<option value="sgse-teal">Verde-água</option>
|
||||
<option value="sgse-corporate">Corporativo</option>
|
||||
<option value="light">Claro</option>
|
||||
<option value="dark">Escuro</option>
|
||||
</select>
|
||||
|
||||
{#if right}
|
||||
{@render right()}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { loginModalStore } from "$lib/stores/loginModal.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
interface MenuProtectionProps {
|
||||
menuPath: string;
|
||||
requireGravar?: boolean;
|
||||
children?: any;
|
||||
redirectTo?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
menuPath,
|
||||
requireGravar = false,
|
||||
children,
|
||||
redirectTo = "/",
|
||||
}: MenuProtectionProps = $props();
|
||||
|
||||
let verificando = $state(true);
|
||||
let temPermissao = $state(false);
|
||||
let motivoNegacao = $state("");
|
||||
|
||||
// Query para verificar permissões (só executa se o usuário estiver autenticado)
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
const permissaoQuery = $derived(
|
||||
currentUser?.data
|
||||
? useQuery(api.menuPermissoes.verificarAcesso, {
|
||||
usuarioId: currentUser.data._id as Id<"usuarios">,
|
||||
menuPath: menuPath,
|
||||
})
|
||||
: null,
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
verificarPermissoes();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-verificar quando o status do usuário atual mudar
|
||||
verificarPermissoes();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Re-verificar quando a query carregar
|
||||
if (permissaoQuery?.data) {
|
||||
verificarPermissoes();
|
||||
}
|
||||
});
|
||||
|
||||
function verificarPermissoes() {
|
||||
// Dashboard e Solicitar Acesso são públicos
|
||||
if (menuPath === "/" || menuPath === "/solicitar-acesso") {
|
||||
verificando = false;
|
||||
temPermissao = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Se não está autenticado
|
||||
if (!currentUser?.data) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = "auth_required";
|
||||
|
||||
// Abrir modal de login e salvar rota de redirecionamento
|
||||
const currentPath = window.location.pathname;
|
||||
loginModalStore.open(currentPath);
|
||||
|
||||
// NÃO redirecionar, apenas mostrar o modal
|
||||
// O usuário verá a mensagem "Verificando permissões..." enquanto o modal está aberto
|
||||
return;
|
||||
}
|
||||
|
||||
// Se está autenticado, verificar permissões
|
||||
if (permissaoQuery?.data) {
|
||||
const permissao = permissaoQuery.data;
|
||||
|
||||
// Se não pode acessar
|
||||
if (!permissao.podeAcessar) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = "access_denied";
|
||||
return;
|
||||
}
|
||||
|
||||
// Se requer gravação mas não tem permissão
|
||||
if (requireGravar && !permissao.podeGravar) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = "write_denied";
|
||||
return;
|
||||
}
|
||||
|
||||
// Tem permissão!
|
||||
verificando = false;
|
||||
temPermissao = true;
|
||||
} else if (permissaoQuery?.error) {
|
||||
verificando = false;
|
||||
temPermissao = false;
|
||||
motivoNegacao = "error";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if verificando}
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
{#if motivoNegacao === "auth_required"}
|
||||
<div class="p-4 bg-warning/10 rounded-full inline-block mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 text-warning"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-base-content mb-2">
|
||||
Acesso Restrito
|
||||
</h2>
|
||||
<p class="text-base-content/70 mb-4">
|
||||
Esta área requer autenticação.<br />
|
||||
Por favor, faça login para continuar.
|
||||
</p>
|
||||
{:else}
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p class="mt-4 text-base-content/70">Verificando permissões...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if temPermissao}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<div class="p-4 bg-error/10 rounded-full inline-block mb-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 text-error"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold text-base-content mb-2">Acesso Negado</h2>
|
||||
<p class="text-base-content/70">
|
||||
Você não tem permissão para acessar esta página.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
85
apps/web/src/lib/components/MenuToggleIcon.svelte
Normal file
85
apps/web/src/lib/components/MenuToggleIcon.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import { prefersReducedMotion, Spring } from 'svelte/motion';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
class?: string;
|
||||
stroke?: number;
|
||||
}
|
||||
|
||||
let { open, class: className = '', stroke = 2 }: Props = $props();
|
||||
|
||||
const progress = Spring.of(() => (open ? 1 : 0), {
|
||||
stiffness: 0.25,
|
||||
damping: 0.65,
|
||||
precision: 0.001
|
||||
});
|
||||
|
||||
const clamp01 = (n: number) => Math.max(0, Math.min(1, n));
|
||||
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
||||
|
||||
let t = $derived(prefersReducedMotion.current ? (open ? 1 : 0) : progress.current);
|
||||
let tFast = $derived(clamp01(t * 1.15));
|
||||
|
||||
// Fechado: hambúrguer. Aberto: "outro menu" (linhas deslocadas + comprimentos diferentes).
|
||||
// Continua sendo ícone de menu (não vira X).
|
||||
let topY = $derived(lerp(-6, -7, tFast));
|
||||
let botY = $derived(lerp(6, 7, tFast));
|
||||
|
||||
let topX = $derived(lerp(0, 3.25, t));
|
||||
let midX = $derived(lerp(0, -2.75, t));
|
||||
let botX = $derived(lerp(0, 1.75, t));
|
||||
|
||||
// micro-inclinação só pra dar “vida”, sem cruzar em X
|
||||
let topR = $derived(lerp(0, 2.5, tFast));
|
||||
let botR = $derived(lerp(0, -2.5, tFast));
|
||||
|
||||
let topScaleX = $derived(lerp(1, 0.62, tFast));
|
||||
let midScaleX = $derived(lerp(1, 0.92, tFast));
|
||||
let botScaleX = $derived(lerp(1, 0.72, tFast));
|
||||
|
||||
let topOpacity = $derived(1);
|
||||
let midOpacity = $derived(1);
|
||||
let botOpacity = $derived(1);
|
||||
</script>
|
||||
|
||||
<span class="menu-toggle-icon {className}" aria-hidden="true" style="--stroke: {stroke}px">
|
||||
<span
|
||||
class="line"
|
||||
style="--x: {topX}px; --y: {topY}px; --r: {topR}deg; --o: {topOpacity}; --sx: {topScaleX}"
|
||||
></span>
|
||||
<span
|
||||
class="line"
|
||||
style="--x: {midX}px; --y: 0px; --r: 0deg; --o: {midOpacity}; --sx: {midScaleX}"
|
||||
></span>
|
||||
<span
|
||||
class="line"
|
||||
style="--x: {botX}px; --y: {botY}px; --r: {botR}deg; --o: {botOpacity}; --sx: {botScaleX}"
|
||||
></span>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.menu-toggle-icon {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
color: currentColor;
|
||||
}
|
||||
|
||||
.line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
margin-top: calc(var(--stroke) / -2);
|
||||
height: var(--stroke);
|
||||
border-radius: 9999px;
|
||||
background: currentColor;
|
||||
opacity: var(--o, 1);
|
||||
transform-origin: center;
|
||||
transform: translateX(var(--x, 0px)) translateY(var(--y, 0px)) rotate(var(--r, 0deg))
|
||||
scaleX(var(--sx, 1));
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { modelosDeclaracoes } from "$lib/utils/modelosDeclaracoes";
|
||||
import { modelosDeclaracoes } from '$lib/utils/modelosDeclaracoes';
|
||||
import {
|
||||
gerarDeclaracaoAcumulacaoCargo,
|
||||
gerarDeclaracaoDependentesIR,
|
||||
gerarDeclaracaoIdoneidade,
|
||||
gerarTermoNepotismo,
|
||||
gerarTermoOpcaoRemuneracao,
|
||||
downloadBlob,
|
||||
} from "$lib/utils/declaracoesGenerator";
|
||||
import { FileText, Info } from "lucide-svelte";
|
||||
downloadBlob
|
||||
} from '$lib/utils/declaracoesGenerator';
|
||||
import { FileText, Info } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
funcionario?: any;
|
||||
@@ -19,10 +19,10 @@
|
||||
let generating = $state(false);
|
||||
|
||||
function baixarModelo(arquivoUrl: string, nomeModelo: string) {
|
||||
const link = document.createElement("a");
|
||||
const link = document.createElement('a');
|
||||
link.href = arquivoUrl;
|
||||
link.download = nomeModelo + ".pdf";
|
||||
link.target = "_blank";
|
||||
link.download = nomeModelo + '.pdf';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
async function gerarPreenchido(modeloId: string) {
|
||||
if (!funcionario) {
|
||||
alert("Dados do funcionário não disponíveis");
|
||||
alert('Dados do funcionário não disponíveis');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -40,40 +40,40 @@
|
||||
let nomeArquivo: string;
|
||||
|
||||
switch (modeloId) {
|
||||
case "acumulacao_cargo":
|
||||
case 'acumulacao_cargo':
|
||||
blob = await gerarDeclaracaoAcumulacaoCargo(funcionario);
|
||||
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case "dependentes_ir":
|
||||
case 'dependentes_ir':
|
||||
blob = await gerarDeclaracaoDependentesIR(funcionario);
|
||||
nomeArquivo = `Declaracao_Dependentes_IR_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Declaracao_Dependentes_IR_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case "idoneidade":
|
||||
case 'idoneidade':
|
||||
blob = await gerarDeclaracaoIdoneidade(funcionario);
|
||||
nomeArquivo = `Declaracao_Idoneidade_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Declaracao_Idoneidade_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case "nepotismo":
|
||||
case 'nepotismo':
|
||||
blob = await gerarTermoNepotismo(funcionario);
|
||||
nomeArquivo = `Termo_Nepotismo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Termo_Nepotismo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
case "opcao_remuneracao":
|
||||
case 'opcao_remuneracao':
|
||||
blob = await gerarTermoOpcaoRemuneracao(funcionario);
|
||||
nomeArquivo = `Termo_Opcao_Remuneracao_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
||||
nomeArquivo = `Termo_Opcao_Remuneracao_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||
break;
|
||||
|
||||
default:
|
||||
alert("Modelo não encontrado");
|
||||
alert('Modelo não encontrado');
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, nomeArquivo);
|
||||
} catch (error) {
|
||||
console.error("Erro ao gerar declaração:", error);
|
||||
alert("Erro ao gerar declaração preenchida");
|
||||
console.error('Erro ao gerar declaração:', error);
|
||||
alert('Erro ao gerar declaração preenchida');
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
@@ -82,38 +82,33 @@
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title text-xl border-b pb-3">
|
||||
<h2 class="card-title border-b pb-3 text-xl">
|
||||
<FileText class="h-5 w-5" strokeWidth={2} />
|
||||
Modelos de Declarações
|
||||
</h2>
|
||||
|
||||
<div class="alert alert-info shadow-sm mb-4">
|
||||
<Info class="stroke-current shrink-0 h-5 w-5" strokeWidth={2} />
|
||||
<div class="alert alert-info mb-4 shadow-sm">
|
||||
<Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold">
|
||||
Baixe os modelos, preencha, assine e faça upload no sistema
|
||||
</p>
|
||||
<p class="text-xs opacity-80 mt-1">
|
||||
Estes documentos são necessários para completar o cadastro do
|
||||
funcionário
|
||||
<p class="font-semibold">Baixe os modelos, preencha, assine e faça upload no sistema</p>
|
||||
<p class="mt-1 text-xs opacity-80">
|
||||
Estes documentos são necessários para completar o cadastro do funcionário
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each modelosDeclaracoes as modelo}
|
||||
<div
|
||||
class="card bg-base-200 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div class="card bg-base-200 shadow-sm transition-shadow hover:shadow-md">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone PDF -->
|
||||
<div
|
||||
class="shrink-0 w-12 h-12 bg-error/10 rounded-lg flex items-center justify-center"
|
||||
class="bg-error/10 flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 text-error"
|
||||
class="text-error h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -128,11 +123,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-sm mb-1 line-clamp-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="mb-1 line-clamp-2 text-sm font-semibold">
|
||||
{modelo.nome}
|
||||
</h3>
|
||||
<p class="text-xs text-base-content/70 mb-3 line-clamp-2">
|
||||
<p class="text-base-content/70 mb-3 line-clamp-2 text-xs">
|
||||
{modelo.descricao}
|
||||
</p>
|
||||
|
||||
@@ -197,10 +192,9 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 text-xs text-base-content/60 text-center">
|
||||
<div class="text-base-content/60 mt-4 text-center text-xs">
|
||||
<p>
|
||||
💡 Dica: Após preencher e assinar os documentos, faça upload na seção
|
||||
"Documentação Anexa"
|
||||
💡 Dica: Após preencher e assinar os documentos, faça upload na seção "Documentação Anexa"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<script lang="ts">
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { maskCPF, maskCEP, maskPhone } from '$lib/utils/masks';
|
||||
import { CheckCircle2, Printer, X } from 'lucide-svelte';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import {
|
||||
SEXO_OPTIONS,
|
||||
APOSENTADO_OPTIONS,
|
||||
ESTADO_CIVIL_OPTIONS,
|
||||
FATOR_RH_OPTIONS,
|
||||
GRAU_INSTRUCAO_OPTIONS,
|
||||
GRUPO_SANGUINEO_OPTIONS,
|
||||
FATOR_RH_OPTIONS,
|
||||
APOSENTADO_OPTIONS
|
||||
SEXO_OPTIONS
|
||||
} from '$lib/utils/constants';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import { CheckCircle2, X, Printer } from 'lucide-svelte';
|
||||
import { maskCEP, maskCPF, maskPhone } from '$lib/utils/masks';
|
||||
|
||||
interface Props {
|
||||
funcionario: any;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { funcionario, onClose }: Props = $props();
|
||||
const { funcionario, onClose }: Props = $props();
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
let generating = $state(false);
|
||||
@@ -113,7 +113,9 @@
|
||||
// Título da ficha
|
||||
doc.setFontSize(18);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, { align: 'center' });
|
||||
doc.text('FICHA CADASTRAL DE FUNCIONÁRIO', 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
yPosition += 8;
|
||||
doc.setFontSize(10);
|
||||
@@ -445,7 +447,7 @@
|
||||
doc.setFontSize(9);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text('SGSE - Sistema de Gerenciamento da Secretaria de Esportes', 105, 285, {
|
||||
doc.text('SGSE - Sistema de Gerenciamento de Secretaria', 105, 285, {
|
||||
align: 'center'
|
||||
});
|
||||
doc.text(`Página ${i} de ${pageCount}`, 195, 285, { align: 'right' });
|
||||
|
||||
@@ -1,45 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { onMount } from 'svelte';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
const {
|
||||
children,
|
||||
requireAuth = true,
|
||||
allowedRoles = [],
|
||||
maxLevel = 3,
|
||||
redirectTo = '/'
|
||||
}: {
|
||||
children: Snippet;
|
||||
requireAuth?: boolean;
|
||||
allowedRoles?: string[];
|
||||
maxLevel?: number;
|
||||
redirectTo?: string;
|
||||
} = $props();
|
||||
|
||||
let isChecking = $state(true);
|
||||
let hasAccess = $state(false);
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let hasCheckedOnce = $state(false);
|
||||
let lastUserState = $state<typeof currentUser | undefined>(undefined);
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
onMount(() => {
|
||||
checkAccess();
|
||||
});
|
||||
|
||||
function checkAccess() {
|
||||
isChecking = true;
|
||||
|
||||
// Aguardar um pouco para o authStore carregar do localStorage
|
||||
setTimeout(() => {
|
||||
// Verificar autenticação
|
||||
if (requireAuth && !currentUser?.data) {
|
||||
const currentPath = window.location.pathname;
|
||||
window.location.href = `${redirectTo}?error=auth_required&redirect=${encodeURIComponent(currentPath)}`;
|
||||
// Usar $effect para reagir apenas às mudanças na query currentUser
|
||||
$effect(() => {
|
||||
// Não verificar novamente se já tem acesso concedido e usuário está autenticado
|
||||
if (hasAccess && currentUser?.data) {
|
||||
lastUserState = currentUser;
|
||||
return;
|
||||
}
|
||||
|
||||
// Evitar loop: só verificar se currentUser realmente mudou
|
||||
// Comparar dados, não o objeto proxy
|
||||
const currentData = currentUser?.data;
|
||||
const lastData = lastUserState?.data;
|
||||
if (currentData !== lastData || (currentUser === undefined) !== (lastUserState === undefined)) {
|
||||
lastUserState = currentUser;
|
||||
checkAccess();
|
||||
}
|
||||
});
|
||||
|
||||
function checkAccess() {
|
||||
// Limpar timeout anterior se existir
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = null;
|
||||
}
|
||||
|
||||
// Se a query ainda está carregando (undefined), aguardar
|
||||
if (currentUser === undefined) {
|
||||
isChecking = true;
|
||||
hasAccess = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Marcar que já verificou pelo menos uma vez
|
||||
hasCheckedOnce = true;
|
||||
|
||||
// Se a query retornou dados, verificar autenticação
|
||||
if (currentUser?.data) {
|
||||
// Verificar roles
|
||||
if (allowedRoles.length > 0 && currentUser?.data) {
|
||||
if (allowedRoles.length > 0) {
|
||||
const hasRole = allowedRoles.includes(currentUser.data.role?.nome ?? '');
|
||||
if (!hasRole) {
|
||||
const currentPath = window.location.pathname;
|
||||
@@ -48,20 +69,43 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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)}`;
|
||||
// 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;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,33 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { useQuery } from "convex-svelte";
|
||||
import { onMount } from 'svelte';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import {
|
||||
registrarServiceWorker,
|
||||
solicitarPushSubscription,
|
||||
subscriptionToJSON,
|
||||
removerPushSubscription,
|
||||
} from "$lib/utils/notifications";
|
||||
removerPushSubscription
|
||||
} from '$lib/utils/notifications';
|
||||
|
||||
const client = useConvexClient();
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
// Capturar erros de Promise não tratados relacionados a message channel
|
||||
// Este erro geralmente vem de extensões do Chrome ou comunicação com Service Worker
|
||||
if (typeof window !== "undefined") {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener(
|
||||
"unhandledrejection",
|
||||
'unhandledrejection',
|
||||
(event: PromiseRejectionEvent) => {
|
||||
const reason = event.reason;
|
||||
const errorMessage = reason?.message || reason?.toString() || "";
|
||||
const errorMessage = reason?.message || reason?.toString() || '';
|
||||
|
||||
// Filtrar apenas erros relacionados a message channel fechado
|
||||
if (
|
||||
errorMessage.includes("message channel closed") ||
|
||||
errorMessage.includes("asynchronous response") ||
|
||||
(errorMessage.includes("message channel") &&
|
||||
errorMessage.includes("closed"))
|
||||
errorMessage.includes('message channel closed') ||
|
||||
errorMessage.includes('asynchronous response') ||
|
||||
(errorMessage.includes('message channel') && errorMessage.includes('closed'))
|
||||
) {
|
||||
// Prevenir que o erro apareça no console
|
||||
event.preventDefault();
|
||||
@@ -35,7 +33,7 @@
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ capture: true },
|
||||
{ capture: true }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -52,11 +50,8 @@
|
||||
await registrarPushSubscription();
|
||||
} catch (error) {
|
||||
// Silenciar erros de push subscription para evitar spam no console
|
||||
if (
|
||||
error instanceof Error &&
|
||||
!error.message.includes("message channel")
|
||||
) {
|
||||
console.error("Erro ao configurar push notifications:", error);
|
||||
if (error instanceof Error && !error.message.includes('message channel')) {
|
||||
console.error('Erro ao configurar push notifications:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -82,20 +77,15 @@
|
||||
async function registrarPushSubscription() {
|
||||
try {
|
||||
// Verificar se Service Worker está disponível antes de tentar
|
||||
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Solicitar subscription com timeout para evitar travamentos
|
||||
const subscriptionPromise = solicitarPushSubscription();
|
||||
const timeoutPromise = new Promise<null>((resolve) =>
|
||||
setTimeout(() => resolve(null), 5000),
|
||||
);
|
||||
const timeoutPromise = new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000));
|
||||
|
||||
const subscription = await Promise.race([
|
||||
subscriptionPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
const subscription = await Promise.race([subscriptionPromise, timeoutPromise]);
|
||||
|
||||
if (!subscription) {
|
||||
// Não logar para evitar spam no console quando VAPID key não está configurada
|
||||
@@ -106,41 +96,30 @@
|
||||
const subscriptionData = subscriptionToJSON(subscription);
|
||||
|
||||
// Registrar no backend com timeout
|
||||
const mutationPromise = client.mutation(
|
||||
api.pushNotifications.registrarPushSubscription,
|
||||
{
|
||||
const mutationPromise = client.mutation(api.pushNotifications.registrarPushSubscription, {
|
||||
endpoint: subscriptionData.endpoint,
|
||||
keys: subscriptionData.keys,
|
||||
userAgent: navigator.userAgent,
|
||||
},
|
||||
);
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
|
||||
const timeoutMutationPromise = new Promise<{
|
||||
sucesso: false;
|
||||
erro: string;
|
||||
}>((resolve) =>
|
||||
setTimeout(() => resolve({ sucesso: false, erro: "Timeout" }), 5000),
|
||||
);
|
||||
}>((resolve) => setTimeout(() => resolve({ sucesso: false, erro: 'Timeout' }), 5000));
|
||||
|
||||
const resultado = await Promise.race([
|
||||
mutationPromise,
|
||||
timeoutMutationPromise,
|
||||
]);
|
||||
const resultado = await Promise.race([mutationPromise, timeoutMutationPromise]);
|
||||
|
||||
if (resultado.sucesso) {
|
||||
console.log("✅ Push subscription registrada com sucesso");
|
||||
} else if (resultado.erro && !resultado.erro.includes("Timeout")) {
|
||||
console.error(
|
||||
"❌ Erro ao registrar push subscription:",
|
||||
resultado.erro,
|
||||
);
|
||||
console.log('✅ Push subscription registrada com sucesso');
|
||||
} else if (resultado.erro && !resultado.erro.includes('Timeout')) {
|
||||
console.error('❌ Erro ao registrar push subscription:', resultado.erro);
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignorar erros relacionados a message channel fechado
|
||||
if (error instanceof Error && error.message.includes("message channel")) {
|
||||
if (error instanceof Error && error.message.includes('message channel')) {
|
||||
return;
|
||||
}
|
||||
console.error("❌ Erro ao configurar push notifications:", error);
|
||||
console.error('❌ Erro ao configurar push notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +127,7 @@
|
||||
$effect(() => {
|
||||
if (!currentUser?.data) {
|
||||
removerPushSubscription().then(() => {
|
||||
console.log("Push subscription removida");
|
||||
console.log('Push subscription removida');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
121
apps/web/src/lib/components/RelogioPrazo.svelte
Normal file
121
apps/web/src/lib/components/RelogioPrazo.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
const { dueDate, startedAt, finishedAt, status, expectedDuration } = $props<{
|
||||
dueDate: number | undefined;
|
||||
startedAt: number | undefined;
|
||||
finishedAt: number | undefined;
|
||||
status: 'pending' | 'in_progress' | 'completed' | 'blocked';
|
||||
expectedDuration: number | undefined;
|
||||
}>();
|
||||
|
||||
let now = $state(Date.now());
|
||||
|
||||
// Atualizar a cada minuto
|
||||
$effect(() => {
|
||||
const interval = setInterval(() => {
|
||||
now = Date.now();
|
||||
}, 60000); // Atualizar a cada minuto
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
let tempoInfo = $derived.by(() => {
|
||||
// Para etapas concluídas
|
||||
if (status === 'completed' && finishedAt && startedAt) {
|
||||
const tempoExecucao = finishedAt - startedAt;
|
||||
const diasExecucao = Math.floor(tempoExecucao / (1000 * 60 * 60 * 24));
|
||||
const horasExecucao = Math.floor((tempoExecucao % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
// Verificar se foi dentro ou fora do prazo
|
||||
const dentroDoPrazo = dueDate ? finishedAt <= dueDate : true;
|
||||
const diasAtrasado =
|
||||
!dentroDoPrazo && dueDate ? Math.floor((finishedAt - dueDate) / (1000 * 60 * 60 * 24)) : 0;
|
||||
|
||||
return {
|
||||
tipo: 'concluida',
|
||||
dias: diasExecucao,
|
||||
horas: horasExecucao,
|
||||
dentroDoPrazo,
|
||||
diasAtrasado
|
||||
};
|
||||
}
|
||||
|
||||
// Para etapas em andamento
|
||||
if (status === 'in_progress' && startedAt && expectedDuration) {
|
||||
// Calcular prazo baseado em startedAt + expectedDuration
|
||||
const prazoCalculado = startedAt + expectedDuration * 24 * 60 * 60 * 1000;
|
||||
const diff = prazoCalculado - now;
|
||||
const dias = Math.floor(Math.abs(diff) / (1000 * 60 * 60 * 24));
|
||||
const horas = Math.floor((Math.abs(diff) % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
|
||||
return {
|
||||
tipo: 'andamento',
|
||||
atrasado: diff < 0,
|
||||
dias,
|
||||
horas
|
||||
};
|
||||
}
|
||||
|
||||
// Para etapas pendentes ou bloqueadas, não mostrar nada
|
||||
return null;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if tempoInfo}
|
||||
{@const info = tempoInfo}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if info.tipo === 'concluida'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 {info.dentroDoPrazo ? 'text-info' : 'text-error'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium {info.dentroDoPrazo ? 'text-info' : 'text-error'}">
|
||||
Concluída em {info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
|
||||
{info.horas}
|
||||
{info.horas === 1 ? 'hora' : 'horas'}
|
||||
{#if !info.dentroDoPrazo && info.diasAtrasado > 0}
|
||||
<span>
|
||||
({info.diasAtrasado} {info.diasAtrasado === 1 ? 'dia' : 'dias'} fora do prazo)</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
{:else if info.tipo === 'andamento'}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 {info.atrasado ? 'text-error' : 'text-success'}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-sm font-medium {info.atrasado ? 'text-error' : 'text-success'}">
|
||||
{#if info.atrasado}
|
||||
{info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
|
||||
{info.horas}
|
||||
{info.horas === 1 ? 'hora' : 'horas'} atrasado
|
||||
{:else}
|
||||
{info.dias > 0 ? `${info.dias} ${info.dias === 1 ? 'dia' : 'dias'} e ` : ''}
|
||||
{info.horas}
|
||||
{info.horas === 1 ? 'hora' : 'horas'} para concluir
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
14
apps/web/src/lib/components/ShineEffect.svelte
Normal file
14
apps/web/src/lib/components/ShineEffect.svelte
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'via-base-content/20 absolute inset-0 -translate-x-full bg-linear-to-r from-transparent to-transparent transition-transform duration-1000 group-hover:translate-x-full',
|
||||
className
|
||||
]}
|
||||
></div>
|
||||
@@ -1,621 +1,424 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import logo from '$lib/assets/logo_governo_PE.png';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { loginModalStore } from '$lib/stores/loginModal.svelte';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
||||
import ChatWidget from '$lib/components/chat/ChatWidget.svelte';
|
||||
import PresenceManager from '$lib/components/chat/PresenceManager.svelte';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { Menu, User, Home, UserPlus, XCircle, LogIn, Tag, Plus, Check } from 'lucide-svelte';
|
||||
import { authClient } from '$lib/auth';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import {
|
||||
ChevronDown,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
GitMerge,
|
||||
Home,
|
||||
Settings,
|
||||
Tag,
|
||||
Users,
|
||||
Briefcase,
|
||||
UserPlus
|
||||
} from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const currentPath = $derived(page.url.pathname);
|
||||
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
// Função para obter a URL do avatar/foto do usuário
|
||||
const avatarUrlDoUsuario = $derived(() => {
|
||||
if (!currentUser.data) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (currentUser.data.fotoPerfilUrl) {
|
||||
return currentUser.data.fotoPerfilUrl;
|
||||
interface MenuItemPermission {
|
||||
recurso: string;
|
||||
acao: string;
|
||||
}
|
||||
|
||||
if (currentUser.data.avatar) {
|
||||
return currentUser.data.avatar;
|
||||
interface SubMenuItem {
|
||||
label: string;
|
||||
link: string;
|
||||
permission?: MenuItemPermission;
|
||||
excludePaths?: string[];
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
// Fallback: gerar avatar baseado no nome
|
||||
return getAvatarUrl(currentUser.data.nome);
|
||||
});
|
||||
|
||||
// Função para gerar classes do menu ativo
|
||||
function getMenuClasses(isActive: boolean) {
|
||||
const baseClasses =
|
||||
'group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105';
|
||||
|
||||
if (isActive) {
|
||||
return `${baseClasses} border-primary bg-primary text-white shadow-lg scale-105`;
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
link: string;
|
||||
permission?: MenuItemPermission;
|
||||
submenus?: SubMenuItem[];
|
||||
excludePaths?: string[];
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
return `${baseClasses} border-primary/30 bg-linear-to-br from-base-100 to-base-200 text-base-content hover:from-primary hover:to-primary/80 hover:text-white`;
|
||||
}
|
||||
|
||||
// Função para gerar classes do botão "Solicitar Acesso"
|
||||
function getSolicitarClasses(isActive: boolean) {
|
||||
const baseClasses =
|
||||
'group font-semibold flex items-center justify-center gap-2 text-center p-3.5 rounded-xl border-2 transition-all duration-300 shadow-md hover:shadow-lg hover:scale-105';
|
||||
|
||||
if (isActive) {
|
||||
return `${baseClasses} border-success bg-success text-white shadow-lg scale-105`;
|
||||
}
|
||||
|
||||
return `${baseClasses} border-success/30 bg-linear-to-br from-success/10 to-success/20 text-base-content hover:from-success hover:to-success/80 hover:text-white`;
|
||||
}
|
||||
|
||||
const setores = [
|
||||
{ nome: 'Recursos Humanos', link: '/recursos-humanos' },
|
||||
{ nome: 'Financeiro', link: '/financeiro' },
|
||||
{ nome: 'Controladoria', link: '/controladoria' },
|
||||
{ nome: 'Licitações', link: '/licitacoes' },
|
||||
{ nome: 'Compras', link: '/compras' },
|
||||
{ nome: 'Jurídico', link: '/juridico' },
|
||||
{ nome: 'Comunicação', link: '/comunicacao' },
|
||||
{ nome: 'Programas Esportivos', link: '/programas-esportivos' },
|
||||
{ nome: 'Secretaria Executiva', link: '/secretaria-executiva' },
|
||||
// Estrutura do menu definida no frontend
|
||||
const MENU_STRUCTURE = [
|
||||
{
|
||||
nome: 'Secretaria de Gestão de Pessoas',
|
||||
link: '/gestao-pessoas'
|
||||
label: 'Dashboard',
|
||||
icon: 'Home',
|
||||
link: '/'
|
||||
},
|
||||
{ nome: 'Tecnologia da Informação', link: '/ti' }
|
||||
];
|
||||
|
||||
let showAboutModal = $state(false);
|
||||
let matricula = $state('');
|
||||
let senha = $state('');
|
||||
let erroLogin = $state('');
|
||||
let carregandoLogin = $state(false);
|
||||
|
||||
// Sincronizar com o store global
|
||||
$effect(() => {
|
||||
if (loginModalStore.showModal && !matricula && !senha) {
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
{
|
||||
label: 'Gestão de Pessoas',
|
||||
icon: 'Users',
|
||||
link: '/recursos-humanos',
|
||||
permission: { recurso: 'gestao_pessoas', acao: 'ver' },
|
||||
submenus: [
|
||||
{
|
||||
label: 'Funcionários',
|
||||
link: '/recursos-humanos/funcionarios',
|
||||
permission: { recurso: 'funcionarios', acao: 'listar' },
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
label: 'Cadastro de Funcionários',
|
||||
link: '/recursos-humanos/funcionarios/cadastro',
|
||||
permission: { recurso: 'funcionarios', acao: 'criar' }
|
||||
},
|
||||
{
|
||||
label: 'Exclusão de Funcionários',
|
||||
link: '/recursos-humanos/funcionarios/excluir',
|
||||
permission: { recurso: 'funcionarios', acao: 'excluir' }
|
||||
},
|
||||
{
|
||||
label: 'Férias',
|
||||
link: '/recursos-humanos/ferias',
|
||||
permission: { recurso: 'ferias', acao: 'dashboard' }
|
||||
},
|
||||
{
|
||||
label: 'Atestados de Licenças',
|
||||
link: '/recursos-humanos/atestados-licencas',
|
||||
permission: { recurso: 'atestados_licencas', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
label: 'Controle de Ponto',
|
||||
link: '/recursos-humanos/controle-ponto',
|
||||
permission: { recurso: 'ponto', acao: 'ver' },
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
label: 'Banco de Horas',
|
||||
link: '/recursos-humanos/controle-ponto/banco-horas',
|
||||
permission: { recurso: 'banco_horas', acao: 'ver' }
|
||||
},
|
||||
{
|
||||
label: 'Registro de Ponto',
|
||||
link: '/recursos-humanos/registro-pontos',
|
||||
permission: { recurso: 'ponto', acao: 'ver' }
|
||||
},
|
||||
{
|
||||
label: 'Símbolos',
|
||||
link: '/recursos-humanos/simbolos',
|
||||
permission: { recurso: 'simbolos', acao: 'listar' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Pedidos',
|
||||
icon: 'ClipboardCheck',
|
||||
link: '/pedidos',
|
||||
permission: { recurso: 'pedidos', acao: 'listar' },
|
||||
submenus: [
|
||||
{
|
||||
label: 'Novo Pedido',
|
||||
link: '/pedidos/novo',
|
||||
permission: { recurso: 'pedidos', acao: 'criar' }
|
||||
},
|
||||
{
|
||||
label: 'Planejamentos',
|
||||
link: '/pedidos/planejamento',
|
||||
permission: { recurso: 'pedidos', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
label: 'Meus Pedidos',
|
||||
link: '/pedidos',
|
||||
permission: { recurso: 'pedidos', acao: 'listar' },
|
||||
excludePaths: [
|
||||
'/pedidos/aceite',
|
||||
'/pedidos/minhas-analises',
|
||||
'/pedidos/novo',
|
||||
'/pedidos/planejamento'
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Pedidos para Aceite',
|
||||
link: '/pedidos/aceite',
|
||||
permission: { recurso: 'pedidos', acao: 'aceitar' }
|
||||
},
|
||||
{
|
||||
label: 'Minhas Análises',
|
||||
link: '/pedidos/minhas-analises',
|
||||
permission: { recurso: 'pedidos', acao: 'aceitar' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Objetos',
|
||||
icon: 'Tag',
|
||||
link: '/compras/objetos',
|
||||
permission: { recurso: 'objetos', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
label: 'Atas de Registro',
|
||||
icon: 'FileText',
|
||||
link: '/compras/atas',
|
||||
permission: { recurso: 'atas', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
label: 'Contratos',
|
||||
icon: 'FileText',
|
||||
link: '/licitacoes/contratos',
|
||||
permission: { recurso: 'contratos', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
label: 'Empresas',
|
||||
icon: 'Briefcase',
|
||||
link: '/licitacoes/empresas',
|
||||
permission: { recurso: 'empresas', acao: 'listar' }
|
||||
},
|
||||
{
|
||||
label: 'Fluxos & Processos',
|
||||
icon: 'GitMerge',
|
||||
link: '/fluxos',
|
||||
permission: { recurso: 'fluxos_instancias', acao: 'listar' },
|
||||
submenus: [
|
||||
{
|
||||
label: 'Meus Processos',
|
||||
link: '/fluxos',
|
||||
permission: { recurso: 'fluxos_instancias', acao: 'listar' },
|
||||
exact: true
|
||||
},
|
||||
{
|
||||
label: 'Modelos de Fluxo',
|
||||
link: '/fluxos/templates',
|
||||
permission: { recurso: 'fluxos_templates', acao: 'listar' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
label: 'Painel de TI',
|
||||
icon: 'Settings',
|
||||
link: '/ti',
|
||||
permission: { recurso: 'ti_painel_administrativo', acao: 'ver' }
|
||||
}
|
||||
] as const satisfies readonly MenuItem[];
|
||||
|
||||
type IconType = typeof Home;
|
||||
|
||||
type SidebarProps = {
|
||||
onNavigate?: () => void;
|
||||
};
|
||||
|
||||
const { onNavigate }: SidebarProps = $props();
|
||||
|
||||
let currentPath = $derived(page.url.pathname);
|
||||
const permissionsQuery = useQuery(api.menu.getUserPermissions as FunctionReference<'query'>, {});
|
||||
|
||||
// Filtrar menu baseado nas permissões do usuário
|
||||
function filterSubmenusByPermissions(
|
||||
items: readonly SubMenuItem[],
|
||||
isMaster: boolean,
|
||||
permissionsSet: Set<string>
|
||||
): SubMenuItem[] {
|
||||
if (isMaster) return [...items];
|
||||
|
||||
return items.filter((item) => {
|
||||
if (!item.permission) return true;
|
||||
const key = `${item.permission.recurso}.${item.permission.acao}`;
|
||||
return permissionsSet.has(key);
|
||||
});
|
||||
}
|
||||
|
||||
function filterMenuByPermissions(
|
||||
items: readonly MenuItem[],
|
||||
isMaster: boolean,
|
||||
permissionsSet: Set<string>
|
||||
): MenuItem[] {
|
||||
if (isMaster) return [...items];
|
||||
|
||||
const filtered: MenuItem[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
// Verifica permissão do item atual
|
||||
let hasPermission = true;
|
||||
if (item.permission) {
|
||||
const key = `${item.permission.recurso}.${item.permission.acao}`;
|
||||
hasPermission = permissionsSet.has(key);
|
||||
}
|
||||
|
||||
if (!hasPermission) continue;
|
||||
|
||||
// Se tiver submenus, filtra e só mantém se sobrar algo
|
||||
let filteredSubmenus: SubMenuItem[] | undefined = undefined;
|
||||
if (item.submenus) {
|
||||
const subs = filterSubmenusByPermissions(item.submenus, isMaster, permissionsSet);
|
||||
filteredSubmenus = subs.length > 0 ? subs : undefined;
|
||||
}
|
||||
|
||||
filtered.push({
|
||||
...item,
|
||||
submenus: filteredSubmenus
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// Menu filtrado reativo
|
||||
let menuItems = $derived.by(() => {
|
||||
const data = permissionsQuery.data;
|
||||
if (!data) return [];
|
||||
|
||||
const permissionsSet = new Set((data.permissions ?? []) as string[]);
|
||||
return filterMenuByPermissions(MENU_STRUCTURE, data.isMaster, permissionsSet);
|
||||
});
|
||||
|
||||
function openLoginModal() {
|
||||
loginModalStore.open();
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
const iconMap: Record<string, IconType> = {
|
||||
Home,
|
||||
UserPlus,
|
||||
Users,
|
||||
ClipboardCheck,
|
||||
FileText,
|
||||
Briefcase,
|
||||
ChevronDown,
|
||||
GitMerge,
|
||||
Settings,
|
||||
Tag
|
||||
};
|
||||
|
||||
function getIconComponent(name: string): IconType {
|
||||
return iconMap[name] || Home;
|
||||
}
|
||||
|
||||
function closeLoginModal() {
|
||||
loginModalStore.close();
|
||||
matricula = '';
|
||||
senha = '';
|
||||
erroLogin = '';
|
||||
}
|
||||
function isRouteActive(path: string, options: { exact?: boolean; excludePaths?: string[] } = {}) {
|
||||
const { exact = false, excludePaths = [] } = options;
|
||||
|
||||
function openAboutModal() {
|
||||
showAboutModal = true;
|
||||
}
|
||||
|
||||
function closeAboutModal() {
|
||||
showAboutModal = false;
|
||||
}
|
||||
|
||||
async function handleLogin(e: Event) {
|
||||
e.preventDefault();
|
||||
erroLogin = '';
|
||||
carregandoLogin = true;
|
||||
|
||||
// const browserInfo = await getBrowserInfo();
|
||||
|
||||
const result = await authClient.signIn.email(
|
||||
{ email: matricula.trim(), password: senha },
|
||||
{
|
||||
onError: (ctx) => {
|
||||
alert(ctx.error.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (result.data) {
|
||||
closeLoginModal();
|
||||
goto(resolve('/'));
|
||||
} else {
|
||||
erroLogin = 'Erro ao fazer login';
|
||||
if (excludePaths.length > 0) {
|
||||
if (excludePaths.some((excludePath) => currentPath.startsWith(excludePath))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const result = await authClient.signOut();
|
||||
if (result.error) {
|
||||
console.error('Sign out error:', result.error);
|
||||
if (exact) return currentPath === path;
|
||||
return currentPath === path || currentPath.startsWith(path + '/');
|
||||
}
|
||||
goto(resolve('/'));
|
||||
|
||||
function getMenuClasses(active: boolean, isSub = false, isParent = false) {
|
||||
const base =
|
||||
'flex items-center gap-3 rounded-r-md border-l-4 px-3 py-2.5 text-base font-medium transition-all duration-200';
|
||||
|
||||
// Premium active state with indicator
|
||||
const activeLeafClass = 'border-primary bg-primary/5 text-primary font-semibold';
|
||||
const activeParentClass = 'border-transparent text-primary font-semibold';
|
||||
|
||||
const inactiveClass =
|
||||
'border-transparent text-base-content/70 hover:bg-primary/10 hover:text-primary';
|
||||
const subClass = isSub ? 'pl-8 text-sm' : '';
|
||||
|
||||
if (active) {
|
||||
return `${base} ${isParent ? activeParentClass : activeLeafClass} ${subClass}`;
|
||||
}
|
||||
|
||||
return `${base} ${inactiveClass} ${subClass}`;
|
||||
}
|
||||
|
||||
function getSolicitarClasses(active: boolean) {
|
||||
return getMenuClasses(active);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Header Fixo acima de tudo -->
|
||||
<div
|
||||
class="navbar from-primary/30 via-primary/20 to-primary/30 border-primary/10 fixed top-0 right-0 left-0 z-50 min-h-24 border-b bg-linear-to-r px-6 shadow-lg backdrop-blur-sm lg:px-8"
|
||||
<nav
|
||||
class="menu text-base-content bg-base-200 border-base-100 h-[calc(100vh-64px)] w-full flex-col gap-2 overflow-y-auto p-4"
|
||||
>
|
||||
<div class="flex-none lg:hidden">
|
||||
<label
|
||||
for="my-drawer-3"
|
||||
class="group relative flex h-14 w-14 cursor-pointer items-center justify-center overflow-hidden rounded-2xl transition-all duration-300 hover:scale-105"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
aria-label="Abrir menu"
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
{#snippet menuItem(item: MenuItem)}
|
||||
{@const Icon = getIconComponent(item.icon)}
|
||||
{@const isActive = isRouteActive(item.link, {
|
||||
exact: item.link === '/',
|
||||
excludePaths: item.excludePaths
|
||||
})}
|
||||
{@const hasSubmenus = item.submenus && item.submenus.length > 0}
|
||||
|
||||
<!-- Ícone de menu hambúrguer -->
|
||||
<Menu
|
||||
class="relative z-10 h-7 w-7 text-white transition-transform duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
strokeWidth={2.5}
|
||||
<li class="mb-1">
|
||||
{#if hasSubmenus}
|
||||
<details open={isActive} class="group/details">
|
||||
<summary
|
||||
class="{getMenuClasses(
|
||||
isActive,
|
||||
false,
|
||||
true
|
||||
)} cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<Icon class="h-5 w-5" strokeWidth={2} />
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
<ChevronDown
|
||||
class="h-4 w-4 opacity-50 transition-transform duration-200 group-open/details:rotate-180"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-1 items-center gap-4 lg:gap-6">
|
||||
<!-- Logo MODERNO do Governo -->
|
||||
<div class="avatar">
|
||||
<div
|
||||
class="group relative w-16 overflow-hidden rounded-2xl p-2 shadow-xl transition-all duration-300 hover:scale-105 lg:w-20"
|
||||
style="background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%); border: 2px solid rgba(102, 126, 234, 0.1);"
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="from-primary/5 absolute inset-0 bg-linear-to-br to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
|
||||
<!-- Logo -->
|
||||
<img
|
||||
src={logo}
|
||||
alt="Logo do Governo de PE"
|
||||
class="relative z-10 h-full w-full object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.08));"
|
||||
/>
|
||||
|
||||
<!-- Brilho sutil no canto -->
|
||||
<div
|
||||
class="absolute top-0 right-0 h-8 w-8 rounded-bl-full bg-linear-to-br from-white/40 to-transparent opacity-70"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<h1 class="text-primary text-xl font-bold tracking-tight lg:text-3xl">SGSE</h1>
|
||||
<p
|
||||
class="text-base-content/80 hidden text-xs leading-tight font-medium sm:block lg:text-base"
|
||||
>
|
||||
Sistema de Gerenciamento da<br class="lg:hidden" /> Secretaria de Esportes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto flex flex-none items-center gap-4">
|
||||
{#if currentUser.data}
|
||||
<!-- Sino de notificações no canto superior direito -->
|
||||
<div class="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
|
||||
<div class="mr-2 hidden flex-col items-end lg:flex">
|
||||
<span class="text-primary text-sm font-semibold">{currentUser.data.nome}</span>
|
||||
<span class="text-base-content/60 text-xs">{currentUser.data.role?.nome}</span>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<!-- Botão de Perfil ULTRA MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="group relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-2xl transition-all duration-300 hover:scale-105"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
></div>
|
||||
|
||||
<!-- Anel de pulso sutil -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl"
|
||||
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
|
||||
></div>
|
||||
|
||||
<!-- Avatar/Foto do usuário ou ícone padrão -->
|
||||
{#if avatarUrlDoUsuario()}
|
||||
<img
|
||||
src={avatarUrlDoUsuario()}
|
||||
alt={currentUser.data?.nome || 'Usuário'}
|
||||
class="relative z-10 h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Ícone de usuário moderno (fallback) -->
|
||||
<User
|
||||
class="relative z-10 h-7 w-7 text-white transition-transform duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Badge de status online -->
|
||||
<div
|
||||
class="bg-success absolute top-1 right-1 z-20 h-3 w-3 rounded-full border-2 border-white shadow-lg"
|
||||
style="animation: pulse-dot 2s ease-in-out infinite;"
|
||||
></div>
|
||||
</button>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-primary/20 z-1 mt-4 w-52 border p-2 shadow-xl"
|
||||
>
|
||||
<li class="menu-title">
|
||||
<span class="text-primary font-bold">{currentUser.data?.nome}</span>
|
||||
</li>
|
||||
<li><a href={resolve('/perfil')}>Meu Perfil</a></li>
|
||||
<li><a href={resolve('/alterar-senha')}>Alterar Senha</a></li>
|
||||
<div class="divider my-0"></div>
|
||||
</summary>
|
||||
<ul class="border-base-200 mt-1 ml-4 space-y-1 pl-2">
|
||||
{#if item.submenus}
|
||||
{#each item.submenus as sub (sub.link)}
|
||||
{@const isSubActive = isRouteActive(sub.link, {
|
||||
excludePaths: sub.excludePaths,
|
||||
exact: sub.exact
|
||||
})}
|
||||
<li>
|
||||
<button type="button" onclick={handleLogout} class="text-error">Sair</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-lg hover:shadow-primary/30 group from-primary via-primary to-primary/80 hover:from-primary/90 hover:via-primary/80 hover:to-primary/70 relative overflow-hidden border-0 bg-linear-to-br shadow-2xl transition-all duration-500 hover:scale-110"
|
||||
style="width: 4rem; height: 4rem; border-radius: 9999px;"
|
||||
onclick={() => openLoginModal()}
|
||||
aria-label="Login"
|
||||
>
|
||||
<!-- Efeito de brilho animado -->
|
||||
<div
|
||||
class="absolute inset-0 -translate-x-full bg-linear-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full"
|
||||
></div>
|
||||
|
||||
<!-- Anel pulsante de fundo -->
|
||||
<div class="absolute inset-0 rounded-full bg-white/10 group-hover:animate-ping"></div>
|
||||
|
||||
<!-- Ícone de login premium -->
|
||||
<User
|
||||
class="relative z-10 h-8 w-8 text-white transition-all duration-500 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3));"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer lg:drawer-open" style="margin-top: 96px;">
|
||||
<input id="my-drawer-3" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content flex flex-col lg:ml-72" style="min-height: calc(100vh - 96px);">
|
||||
<!-- Page content -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer
|
||||
class="footer footer-center from-primary/30 via-primary/20 to-primary/30 text-base-content border-primary/20 shrink-0 border-t-2 bg-linear-to-r p-6 shadow-inner backdrop-blur-sm"
|
||||
>
|
||||
<div class="grid grid-flow-col gap-6 text-sm font-medium">
|
||||
<button
|
||||
type="button"
|
||||
class="link link-hover hover:text-primary transition-colors"
|
||||
onclick={() => openAboutModal()}>Sobre</button
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors"
|
||||
>Contato</a
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors"
|
||||
>Suporte</a
|
||||
>
|
||||
<span class="text-base-content/30">•</span>
|
||||
<a href={resolve('/')} class="link link-hover hover:text-primary transition-colors"
|
||||
>Privacidade</a
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-3">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-lg bg-white p-1.5 shadow-md">
|
||||
<img src={logo} alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-left">
|
||||
<p class="text-primary text-xs font-bold">Governo do Estado de Pernambuco</p>
|
||||
<p class="text-base-content/70 text-xs">Secretaria de Esportes</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-base-content/60 mt-2 text-xs">
|
||||
© {new Date().getFullYear()} - Todos os direitos reservados
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="drawer-side fixed z-40" style="margin-top: 96px;">
|
||||
<label for="my-drawer-3" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<div
|
||||
class="menu from-primary/25 to-primary/15 border-primary/20 flex h-[calc(100vh-96px)] w-72 flex-col gap-2 overflow-y-auto border-r-2 bg-linear-to-b p-4 shadow-xl backdrop-blur-sm"
|
||||
>
|
||||
<!-- Sidebar menu items -->
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li class="rounded-xl">
|
||||
<a href={resolve('/')} class={getMenuClasses(currentPath === '/')}>
|
||||
<Home class="h-5 w-5 transition-transform group-hover:scale-110" strokeWidth={2} />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
</li>
|
||||
{#each setores as s}
|
||||
{@const isActive = currentPath.startsWith(s.link)}
|
||||
<li class="rounded-xl">
|
||||
<a
|
||||
href={resolve(s.link)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class={getMenuClasses(isActive)}
|
||||
href={resolve(sub.link as any)}
|
||||
class={getMenuClasses(isSubActive, true)}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<span>{s.nome}</span>
|
||||
<span>{sub.label}</span>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
<li class="mt-auto rounded-xl">
|
||||
{/if}
|
||||
</ul>
|
||||
</details>
|
||||
{:else}
|
||||
<a
|
||||
href={resolve('/solicitar-acesso')}
|
||||
class={getSolicitarClasses(currentPath === '/solicitar-acesso')}
|
||||
href={resolve(item.link as any)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
class={getMenuClasses(isActive)}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<Icon class="h-5 w-5" strokeWidth={2} />
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/if}
|
||||
</li>
|
||||
{/snippet}
|
||||
|
||||
<ul class="menu w-full flex-1 p-0 px-2">
|
||||
{#if permissionsQuery.isLoading}
|
||||
<div class="flex flex-col gap-2 p-4">
|
||||
{#each Array(5)}
|
||||
<div class="skeleton h-12 w-full rounded-lg"></div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
{#each menuItems as item (item.link)}
|
||||
{@render menuItem(item)}
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
<ul class="menu mt-auto w-full p-0 px-2">
|
||||
<div class="divider before:bg-base-300 after:bg-base-300 my-2 px-2"></div>
|
||||
|
||||
<li class="px-2">
|
||||
<a
|
||||
href={resolve('/abrir-chamado')}
|
||||
class={getSolicitarClasses(currentPath === '/abrir-chamado')}
|
||||
onclick={() => onNavigate?.()}
|
||||
>
|
||||
<UserPlus class="h-5 w-5" strokeWidth={2} />
|
||||
<span>Solicitar acesso</span>
|
||||
<span>Abrir Chamado</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal de Login -->
|
||||
{#if loginModalStore.showModal}
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box bg-base-100 relative max-w-md overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="p-4">
|
||||
<div class="mb-6 text-center">
|
||||
<div class="avatar mb-4">
|
||||
<div class="bg-primary/10 w-20 rounded-lg p-3">
|
||||
<img src={logo} alt="Logo" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="text-primary text-3xl font-bold">Login</h3>
|
||||
<p class="text-base-content/60 mt-2 text-sm">Acesse o sistema com suas credenciais</p>
|
||||
</div>
|
||||
|
||||
{#if erroLogin}
|
||||
<div class="alert alert-error mb-4">
|
||||
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||
<span>{erroLogin}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form class="space-y-4" onsubmit={handleLogin}>
|
||||
<div class="form-control">
|
||||
<label class="label" for="login-matricula">
|
||||
<span class="label-text font-semibold">Matrícula ou E-mail</span>
|
||||
</label>
|
||||
<input
|
||||
id="login-matricula"
|
||||
type="text"
|
||||
placeholder="Digite sua matrícula ou e-mail"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={matricula}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="login-password">
|
||||
<span class="label-text font-semibold">Senha</span>
|
||||
</label>
|
||||
<input
|
||||
id="login-password"
|
||||
type="password"
|
||||
placeholder="Digite sua senha"
|
||||
class="input input-bordered input-primary w-full"
|
||||
bind:value={senha}
|
||||
required
|
||||
disabled={carregandoLogin}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-control mt-6">
|
||||
<button type="submit" class="btn btn-primary w-full" disabled={carregandoLogin}>
|
||||
{#if carregandoLogin}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Entrando...
|
||||
{:else}
|
||||
<LogIn class="h-5 w-5" strokeWidth={2} />
|
||||
Entrar
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-4 space-y-2 text-center">
|
||||
<a
|
||||
href={resolve('/solicitar-acesso')}
|
||||
class="link link-primary block text-sm"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Não tem acesso? Solicite aqui
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/esqueci-senha')}
|
||||
class="link link-secondary block text-sm"
|
||||
onclick={closeLoginModal}
|
||||
>
|
||||
Esqueceu sua senha?
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="divider text-base-content/40 text-xs">Credenciais de teste</div>
|
||||
<div class="bg-base-200 rounded-lg p-3 text-xs">
|
||||
<p class="mb-1 font-semibold">Admin:</p>
|
||||
<p>
|
||||
Matrícula: <code class="bg-base-300 rounded px-2 py-1">0000</code>
|
||||
</p>
|
||||
<p>
|
||||
Senha: <code class="bg-base-300 rounded px-2 py-1">Admin@123</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<form method="dialog" class="modal-backdrop" onclick={closeLoginModal}>
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<!-- Modal Sobre -->
|
||||
{#if showAboutModal}
|
||||
<dialog class="modal modal-open">
|
||||
<div
|
||||
class="modal-box from-base-100 to-base-200 relative max-w-2xl overflow-hidden bg-linear-to-br"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
onclick={closeAboutModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<div class="space-y-6 py-4 text-center">
|
||||
<!-- Logo e Título -->
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="avatar">
|
||||
<div class="w-24 rounded-xl bg-white p-3 shadow-lg">
|
||||
<img src={logo} alt="Logo SGSE" class="h-full w-full object-contain" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-primary mb-2 text-3xl font-bold">SGSE</h3>
|
||||
<p class="text-base-content/80 text-lg font-semibold">
|
||||
Sistema de Gerenciamento da<br />Secretaria de Esportes
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Informações de Versão -->
|
||||
<div class="bg-primary/10 space-y-3 rounded-xl p-6">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Tag class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
<p class="text-base-content/70 text-sm font-medium">Versão</p>
|
||||
</div>
|
||||
<p class="text-primary text-2xl font-bold">1.0 26_2025</p>
|
||||
<div class="badge badge-warning badge-lg gap-2">
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Desenvolvido por -->
|
||||
<div class="space-y-2">
|
||||
<p class="text-base-content/60 text-sm font-medium">Desenvolvido por</p>
|
||||
<p class="text-primary text-lg font-bold">Secretaria de Esportes de Pernambuco</p>
|
||||
</div>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Informações Adicionais -->
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<p class="text-primary font-semibold">Governo</p>
|
||||
<p class="text-base-content/70 text-xs">Estado de Pernambuco</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3">
|
||||
<p class="text-primary font-semibold">Ano</p>
|
||||
<p class="text-base-content/70 text-xs">2025</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Botão OK -->
|
||||
<div class="pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-lg mx-auto w-full max-w-xs shadow-lg transition-all duration-300 hover:shadow-xl"
|
||||
onclick={closeAboutModal}
|
||||
>
|
||||
<Check class="h-6 w-6" strokeWidth={2} />
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
onclick={closeAboutModal}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onkeydown={(e) => e.key === 'Escape' && closeAboutModal()}
|
||||
></div>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<!-- Componentes de Chat (apenas se autenticado) -->
|
||||
{#if currentUser.data}
|
||||
<PresenceManager />
|
||||
<ChatWidget />
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<style>
|
||||
/* Animação de pulso sutil para o anel do botão de perfil */
|
||||
@keyframes pulse-ring-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
/* Remove default details marker */
|
||||
details > summary {
|
||||
list-style: none;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Animação de pulso para o badge de status online */
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
/* Remove DaisyUI default arrow */
|
||||
details > summary::after {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
|
||||
interface Periodo {
|
||||
id: string;
|
||||
@@ -15,7 +15,7 @@
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
const { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { Calendar } from "@fullcalendar/core";
|
||||
import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import multiMonthPlugin from "@fullcalendar/multimonth";
|
||||
import ptBrLocale from "@fullcalendar/core/locales/pt-br";
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
import { Calendar } from '@fullcalendar/core';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import multiMonthPlugin from '@fullcalendar/multimonth';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
dataInicio?: string;
|
||||
@@ -12,13 +14,10 @@
|
||||
ausenciasExistentes?: Array<{
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
status: "aguardando_aprovacao" | "aprovado" | "reprovado";
|
||||
status: 'aguardando_aprovacao' | 'aprovado' | 'reprovado';
|
||||
}>;
|
||||
onPeriodoSelecionado?: (periodo: {
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
}) => void;
|
||||
modoVisualizacao?: "month" | "multiMonth";
|
||||
onPeriodoSelecionado?: (periodo: { dataInicio: string; dataFim: string }) => void;
|
||||
modoVisualizacao?: 'month' | 'multiMonth';
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
@@ -27,38 +26,23 @@
|
||||
dataFim,
|
||||
ausenciasExistentes = [],
|
||||
onPeriodoSelecionado,
|
||||
modoVisualizacao = "month",
|
||||
readonly = false,
|
||||
modoVisualizacao = 'month',
|
||||
readonly = false
|
||||
}: Props = $props();
|
||||
|
||||
let calendarEl: HTMLDivElement;
|
||||
let calendar: Calendar | null = null;
|
||||
let selecionando = $state(false); // Flag para evitar atualizações durante seleção
|
||||
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<
|
||||
string,
|
||||
{ bg: string; border: string; text: string }
|
||||
> = {
|
||||
aguardando_aprovacao: { bg: "#f59e0b", border: "#d97706", text: "#ffffff" }, // Laranja
|
||||
aprovado: { bg: "#10b981", border: "#059669", text: "#ffffff" }, // Verde
|
||||
reprovado: { bg: "#ef4444", border: "#dc2626", text: "#ffffff" }, // Vermelho
|
||||
const coresStatus: Record<string, { bg: string; border: string; text: string }> = {
|
||||
aguardando_aprovacao: { bg: '#f59e0b', border: '#d97706', text: '#ffffff' }, // Laranja
|
||||
aprovado: { bg: '#10b981', border: '#059669', text: '#ffffff' }, // Verde
|
||||
reprovado: { bg: '#ef4444', border: '#dc2626', text: '#ffffff' } // Vermelho
|
||||
};
|
||||
|
||||
// Converter ausências existentes em eventos
|
||||
function atualizarEventos() {
|
||||
let eventos = $derived.by(() => {
|
||||
const novosEventos: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -71,8 +55,7 @@
|
||||
status: string;
|
||||
};
|
||||
}> = ausenciasExistentes.map((ausencia, index) => {
|
||||
const cor =
|
||||
coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao;
|
||||
const cor = coresStatus[ausencia.status] || coresStatus.aguardando_aprovacao;
|
||||
return {
|
||||
id: `ausencia-${index}`,
|
||||
title: `${getStatusTexto(ausencia.status)} - ${calcularDias(ausencia.dataInicio, ausencia.dataFim)} dias`,
|
||||
@@ -82,50 +65,50 @@
|
||||
borderColor: cor.border,
|
||||
textColor: cor.text,
|
||||
extendedProps: {
|
||||
status: ausencia.status,
|
||||
},
|
||||
status: ausencia.status
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Adicionar período selecionado atual se existir
|
||||
if (dataInicio && dataFim) {
|
||||
novosEventos.push({
|
||||
id: "periodo-selecionado",
|
||||
id: 'periodo-selecionado',
|
||||
title: `Selecionado - ${calcularDias(dataInicio, dataFim)} dias`,
|
||||
start: dataInicio,
|
||||
end: calcularDataFim(dataFim),
|
||||
backgroundColor: "#667eea",
|
||||
borderColor: "#5568d3",
|
||||
textColor: "#ffffff",
|
||||
backgroundColor: '#667eea',
|
||||
borderColor: '#5568d3',
|
||||
textColor: '#ffffff',
|
||||
extendedProps: {
|
||||
status: "selecionado",
|
||||
},
|
||||
status: 'selecionado'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
eventos = novosEventos;
|
||||
}
|
||||
return novosEventos;
|
||||
});
|
||||
|
||||
function getStatusTexto(status: string): string {
|
||||
const textos: Record<string, string> = {
|
||||
aguardando_aprovacao: "Aguardando",
|
||||
aprovado: "Aprovado",
|
||||
reprovado: "Reprovado",
|
||||
aguardando_aprovacao: 'Aguardando',
|
||||
aprovado: 'Aprovado',
|
||||
reprovado: 'Reprovado'
|
||||
};
|
||||
return textos[status] || status;
|
||||
}
|
||||
|
||||
// Helper: Adicionar 1 dia à data fim (FullCalendar usa exclusive end)
|
||||
function calcularDataFim(dataFim: string): string {
|
||||
const data = new Date(dataFim);
|
||||
const data = new SvelteDate(dataFim);
|
||||
data.setDate(data.getDate() + 1);
|
||||
return data.toISOString().split("T")[0];
|
||||
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,80 +116,83 @@
|
||||
|
||||
// Helper: Verificar se há sobreposição de datas
|
||||
function verificarSobreposicao(
|
||||
inicio1: Date,
|
||||
fim1: Date,
|
||||
inicio1: SvelteDate,
|
||||
fim1: SvelteDate,
|
||||
inicio2: string,
|
||||
fim2: 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
|
||||
const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
(a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao'
|
||||
);
|
||||
|
||||
return ausenciasBloqueantes.some((ausencia) =>
|
||||
verificarSobreposicao(inicio, fim, ausencia.dataInicio, ausencia.dataFim),
|
||||
verificarSobreposicao(inicio, fim, ausencia.dataInicio, ausencia.dataFim)
|
||||
);
|
||||
}
|
||||
|
||||
interface FullCalendarDayCellInfo {
|
||||
el: HTMLElement;
|
||||
date: Date;
|
||||
}
|
||||
|
||||
// Helper: Atualizar classe de seleção em uma célula
|
||||
function atualizarClasseSelecionado(info: 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);
|
||||
fim.setHours(0, 0, 0, 0);
|
||||
|
||||
if (cellDate >= inicio && cellDate <= fim) {
|
||||
info.el.classList.add("fc-day-selected");
|
||||
info.el.classList.add('fc-day-selected');
|
||||
} else {
|
||||
info.el.classList.remove("fc-day-selected");
|
||||
info.el.classList.remove('fc-day-selected');
|
||||
}
|
||||
} else {
|
||||
info.el.classList.remove("fc-day-selected");
|
||||
info.el.classList.remove('fc-day-selected');
|
||||
}
|
||||
}
|
||||
|
||||
// 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");
|
||||
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
|
||||
const estaBloqueado = ausenciasExistentes
|
||||
.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
)
|
||||
.filter((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;
|
||||
});
|
||||
|
||||
if (estaBloqueado) {
|
||||
info.el.classList.add("fc-day-blocked");
|
||||
info.el.classList.add('fc-day-blocked');
|
||||
} else {
|
||||
info.el.classList.remove("fc-day-blocked");
|
||||
info.el.classList.remove('fc-day-blocked');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,31 +204,31 @@
|
||||
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);
|
||||
|
||||
// O FullCalendar renderiza as células, então podemos usar dayCellDidMount
|
||||
// Mas também precisamos atualizar células existentes
|
||||
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
|
||||
const cells = calendarEl.querySelectorAll('.fc-daygrid-day');
|
||||
cells.forEach((cell) => {
|
||||
// Remover classe primeiro
|
||||
cell.classList.remove("fc-day-selected");
|
||||
cell.classList.remove('fc-day-selected');
|
||||
|
||||
// Tentar obter a data do aria-label ou do elemento
|
||||
const ariaLabel = cell.getAttribute("aria-label");
|
||||
const ariaLabel = cell.getAttribute('aria-label');
|
||||
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");
|
||||
cell.classList.add('fc-day-selected');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Ignorar erros de parsing
|
||||
}
|
||||
}
|
||||
@@ -260,67 +246,68 @@
|
||||
) {
|
||||
// Remover classes de bloqueio se não houver ausências
|
||||
if (calendarEl) {
|
||||
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
|
||||
cells.forEach((cell) => cell.classList.remove("fc-day-blocked"));
|
||||
const cells = calendarEl.querySelectorAll('.fc-daygrid-day');
|
||||
cells.forEach((cell) => cell.classList.remove('fc-day-blocked'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const cells = calendarEl.querySelectorAll(".fc-daygrid-day");
|
||||
const calendarInstance = calendar;
|
||||
const cells = calendarEl.querySelectorAll('.fc-daygrid-day');
|
||||
const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
(a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao'
|
||||
);
|
||||
|
||||
if (ausenciasBloqueantes.length === 0) {
|
||||
cells.forEach((cell) => cell.classList.remove("fc-day-blocked"));
|
||||
cells.forEach((cell) => cell.classList.remove('fc-day-blocked'));
|
||||
return;
|
||||
}
|
||||
|
||||
cells.forEach((cell) => {
|
||||
cell.classList.remove("fc-day-blocked");
|
||||
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");
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Método 2: data-date attribute
|
||||
if (!cellDate) {
|
||||
const dataDate = cell.getAttribute("data-date");
|
||||
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) {
|
||||
const dayNumberEl = cell.querySelector(".fc-daygrid-day-number");
|
||||
if (!cellDate && calendarInstance.view) {
|
||||
const dayNumberEl = cell.querySelector('.fc-daygrid-day-number');
|
||||
if (dayNumberEl) {
|
||||
const dayNumber = parseInt(dayNumberEl.textContent || "0");
|
||||
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,15 +322,15 @@
|
||||
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) {
|
||||
cell.classList.add("fc-day-blocked");
|
||||
cell.classList.add('fc-day-blocked');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -354,9 +341,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(() => {
|
||||
@@ -379,9 +364,8 @@
|
||||
|
||||
const ausencias = ausenciasExistentes;
|
||||
const ausenciasBloqueantes =
|
||||
ausencias?.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
) || [];
|
||||
ausencias?.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao') ||
|
||||
[];
|
||||
|
||||
// Se houver ausências bloqueantes, forçar atualização
|
||||
if (ausenciasBloqueantes.length > 0) {
|
||||
@@ -398,35 +382,31 @@
|
||||
onMount(() => {
|
||||
if (!calendarEl) return;
|
||||
|
||||
atualizarEventos();
|
||||
|
||||
calendar = new Calendar(calendarEl, {
|
||||
plugins: [dayGridPlugin, interactionPlugin, multiMonthPlugin],
|
||||
initialView:
|
||||
modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
|
||||
initialView: modoVisualizacao === 'multiMonth' ? 'multiMonthYear' : 'dayGridMonth',
|
||||
locale: ptBrLocale,
|
||||
headerToolbar: {
|
||||
left: "prev,next today",
|
||||
center: "title",
|
||||
right:
|
||||
modoVisualizacao === "multiMonth" ? "multiMonthYear" : "dayGridMonth",
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: modoVisualizacao === 'multiMonth' ? 'multiMonthYear' : 'dayGridMonth'
|
||||
},
|
||||
height: "auto",
|
||||
height: 'auto',
|
||||
selectable: !readonly,
|
||||
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,
|
||||
|
||||
// Estilo customizado
|
||||
buttonText: {
|
||||
today: "Hoje",
|
||||
month: "Mês",
|
||||
multiMonthYear: "Ano",
|
||||
today: 'Hoje',
|
||||
month: 'Mês',
|
||||
multiMonthYear: 'Ano'
|
||||
},
|
||||
|
||||
// Seleção de período
|
||||
@@ -437,15 +417,15 @@
|
||||
|
||||
// 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");
|
||||
alert('A data de início não pode ser no passado');
|
||||
calendar?.unselect();
|
||||
selecionando = false;
|
||||
return;
|
||||
@@ -453,7 +433,7 @@
|
||||
|
||||
// Validar que fim >= início
|
||||
if (fim < inicio) {
|
||||
alert("A data de fim deve ser maior ou igual à data de início");
|
||||
alert('A data de fim deve ser maior ou igual à data de início');
|
||||
calendar?.unselect();
|
||||
selecionando = false;
|
||||
return;
|
||||
@@ -462,7 +442,7 @@
|
||||
// Validar sobreposição com ausências existentes
|
||||
if (verificarSobreposicaoComAusencias(inicio, fim)) {
|
||||
alert(
|
||||
"Este período sobrepõe com uma ausência já aprovada ou aguardando aprovação. Por favor, escolha outro período.",
|
||||
'Este período sobrepõe com uma ausência já aprovada ou aguardando aprovação. Por favor, escolha outro período.'
|
||||
);
|
||||
calendar?.unselect();
|
||||
selecionando = false;
|
||||
@@ -473,7 +453,7 @@
|
||||
if (onPeriodoSelecionado) {
|
||||
onPeriodoSelecionado({
|
||||
dataInicio: info.startStr,
|
||||
dataFim: fim.toISOString().split("T")[0],
|
||||
dataFim: fim.toISOString().split('T')[0]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -493,7 +473,7 @@
|
||||
const status = info.event.extendedProps.status;
|
||||
const texto = getStatusTexto(status);
|
||||
alert(
|
||||
`Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString("pt-BR")} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString("pt-BR")}`,
|
||||
`Ausência ${texto}\nPeríodo: ${new Date(info.event.startStr).toLocaleDateString('pt-BR')} até ${new Date(calcularDataFim(info.event.endStr)).toLocaleDateString('pt-BR')}`
|
||||
);
|
||||
}
|
||||
},
|
||||
@@ -501,32 +481,28 @@
|
||||
// Tooltip ao passar mouse
|
||||
eventDidMount: (info) => {
|
||||
const status = info.event.extendedProps.status;
|
||||
if (status === "selecionado") {
|
||||
if (status === 'selecionado') {
|
||||
info.el.title = `Período selecionado\n${info.event.title}`;
|
||||
} else {
|
||||
info.el.title = `${info.event.title}`;
|
||||
}
|
||||
info.el.style.cursor = readonly ? "default" : "pointer";
|
||||
info.el.style.cursor = readonly ? 'default' : 'pointer';
|
||||
},
|
||||
|
||||
// Desabilitar datas passadas e períodos que sobrepõem com ausências existentes
|
||||
selectAllow: (selectInfo) => {
|
||||
const hoje = new 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;
|
||||
}
|
||||
|
||||
// Verificar sobreposição com ausências existentes
|
||||
if (
|
||||
!readonly &&
|
||||
ausenciasExistentes &&
|
||||
ausenciasExistentes.length > 0
|
||||
) {
|
||||
const inicioSelecao = new Date(selectInfo.start);
|
||||
const fimSelecao = new Date(selectInfo.end);
|
||||
if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) {
|
||||
const inicioSelecao = new SvelteDate(selectInfo.start);
|
||||
const fimSelecao = new SvelteDate(selectInfo.end);
|
||||
fimSelecao.setDate(fimSelecao.getDate() - 1); // FullCalendar usa exclusive end
|
||||
|
||||
inicioSelecao.setHours(0, 0, 0, 0);
|
||||
@@ -569,38 +545,33 @@
|
||||
const classes: string[] = [];
|
||||
|
||||
if (arg.date.getDay() === 0 || arg.date.getDay() === 6) {
|
||||
classes.push("fc-day-weekend-custom");
|
||||
classes.push('fc-day-weekend-custom');
|
||||
}
|
||||
|
||||
// Verificar se o dia está bloqueado
|
||||
if (
|
||||
!readonly &&
|
||||
ausenciasExistentes &&
|
||||
ausenciasExistentes.length > 0
|
||||
) {
|
||||
const cellDate = new Date(arg.date);
|
||||
if (!readonly && ausenciasExistentes && ausenciasExistentes.length > 0) {
|
||||
const cellDate = new SvelteDate(arg.date);
|
||||
cellDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
(a) =>
|
||||
a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
(a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao'
|
||||
);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
if (estaBloqueado) {
|
||||
classes.push("fc-day-blocked");
|
||||
classes.push('fc-day-blocked');
|
||||
}
|
||||
}
|
||||
|
||||
return classes;
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
calendar.render();
|
||||
@@ -614,13 +585,13 @@
|
||||
<div class="calendario-ausencias-wrapper">
|
||||
<!-- Header com instruções -->
|
||||
{#if !readonly}
|
||||
<div class="space-y-4 mb-4">
|
||||
<div class="mb-4 space-y-4">
|
||||
<div class="alert alert-info shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-current shrink-0 w-6 h-6"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
@@ -631,28 +602,20 @@
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<p class="font-bold">Como usar:</p>
|
||||
<ul class="list-disc list-inside mt-1">
|
||||
<li>
|
||||
Clique e arraste no calendário para selecionar o período de
|
||||
ausência
|
||||
</li>
|
||||
<li>
|
||||
Você pode visualizar suas ausências já solicitadas no calendário
|
||||
</li>
|
||||
<ul class="mt-1 list-inside list-disc">
|
||||
<li>Clique e arraste no calendário para selecionar o período de ausência</li>
|
||||
<li>Você pode visualizar suas ausências já solicitadas no calendário</li>
|
||||
<li>A data de início não pode ser no passado</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alerta sobre dias bloqueados -->
|
||||
{#if ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
|
||||
{@const ausenciasBloqueantes = ausenciasExistentes.filter(
|
||||
(a) => a.status === "aprovado" || a.status === "aguardando_aprovacao",
|
||||
)}
|
||||
<div class="alert alert-warning shadow-lg border-2 border-warning/50">
|
||||
{#if ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao').length > 0}
|
||||
<div class="alert alert-warning border-warning/50 border-2 shadow-lg">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-current shrink-0 h-6 w-6"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
@@ -665,18 +628,16 @@
|
||||
</svg>
|
||||
<div class="flex-1">
|
||||
<h3 class="font-bold">Atenção: Períodos Indisponíveis</h3>
|
||||
<div class="text-sm mt-1">
|
||||
<div class="mt-1 text-sm">
|
||||
<p>
|
||||
Os dias marcados em <span class="font-bold text-error"
|
||||
>vermelho</span
|
||||
>
|
||||
Os dias marcados em <span class="text-error font-bold">vermelho</span>
|
||||
estão bloqueados porque você já possui solicitações
|
||||
<strong>aprovadas</strong>
|
||||
ou <strong>aguardando aprovação</strong> para esses períodos.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
Você não pode criar novas solicitações que sobreponham esses
|
||||
períodos. Escolha um período diferente.
|
||||
Você não pode criar novas solicitações que sobreponham esses períodos. Escolha um
|
||||
período diferente.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -688,53 +649,50 @@
|
||||
<!-- Calendário -->
|
||||
<div
|
||||
bind:this={calendarEl}
|
||||
class="calendario-ausencias shadow-2xl rounded-2xl overflow-hidden border-2 border-orange-500/10"
|
||||
class="calendario-ausencias overflow-hidden rounded-2xl border-2 border-orange-500/10 shadow-2xl"
|
||||
></div>
|
||||
|
||||
<!-- Legenda de status -->
|
||||
{#if ausenciasExistentes.length > 0 || readonly}
|
||||
<div class="mt-6 space-y-4">
|
||||
<div class="flex flex-wrap gap-4 justify-center">
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<div
|
||||
class="badge badge-lg gap-2"
|
||||
style="background-color: #f59e0b; border-color: #d97706; color: white;"
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full bg-white"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-white"></div>
|
||||
Aguardando Aprovação
|
||||
</div>
|
||||
<div
|
||||
class="badge badge-lg gap-2"
|
||||
style="background-color: #10b981; border-color: #059669; color: white;"
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full bg-white"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-white"></div>
|
||||
Aprovado
|
||||
</div>
|
||||
<div
|
||||
class="badge badge-lg gap-2"
|
||||
style="background-color: #ef4444; border-color: #dc2626; color: white;"
|
||||
>
|
||||
<div class="w-3 h-3 rounded-full bg-white"></div>
|
||||
<div class="h-3 w-3 rounded-full bg-white"></div>
|
||||
Reprovado
|
||||
</div>
|
||||
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
|
||||
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao').length > 0}
|
||||
<div
|
||||
class="badge badge-lg gap-2"
|
||||
style="background-color: rgba(239, 68, 68, 0.2); border-color: #ef4444; color: #dc2626;"
|
||||
>
|
||||
<div
|
||||
class="w-3 h-3 rounded-full"
|
||||
style="background-color: #ef4444;"
|
||||
></div>
|
||||
<div class="h-3 w-3 rounded-full" style="background-color: #ef4444;"></div>
|
||||
Dias Bloqueados (Indisponíveis)
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === "aprovado" || a.status === "aguardando_aprovacao").length > 0}
|
||||
{#if !readonly && ausenciasExistentes && ausenciasExistentes.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao').length > 0}
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-base-content/70">
|
||||
<span class="font-semibold text-error">Dias bloqueados</span> não podem
|
||||
ser selecionados para novas solicitações
|
||||
<p class="text-base-content/70 text-sm">
|
||||
<span class="text-error font-semibold">Dias bloqueados</span> não podem ser selecionados
|
||||
para novas solicitações
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -743,11 +701,9 @@
|
||||
|
||||
<!-- 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"
|
||||
>
|
||||
<div class="card mt-6 border border-orange-400 shadow-lg">
|
||||
<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"
|
||||
@@ -764,22 +720,22 @@
|
||||
</svg>
|
||||
Período Selecionado
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-2">
|
||||
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Data Início</p>
|
||||
<p class="font-bold text-lg">
|
||||
{new Date(dataInicio).toLocaleDateString("pt-BR")}
|
||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||
<p class="text-lg font-bold">
|
||||
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Data Fim</p>
|
||||
<p class="font-bold text-lg">
|
||||
{new Date(dataFim).toLocaleDateString("pt-BR")}
|
||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||
<p class="text-lg font-bold">
|
||||
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-base-content/70">Total de Dias</p>
|
||||
<p class="font-bold text-2xl text-orange-600 dark:text-orange-400">
|
||||
<p class="text-base-content/70 text-sm">Total de Dias</p>
|
||||
<p class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{calcularDias(dataInicio, dataFim)} dias
|
||||
</p>
|
||||
</div>
|
||||
@@ -793,10 +749,10 @@
|
||||
/* Calendário Premium */
|
||||
.calendario-ausencias {
|
||||
font-family:
|
||||
"Inter",
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
@@ -924,18 +880,12 @@
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
:global(
|
||||
.calendario-ausencias .fc-daygrid-day.fc-day-blocked .fc-daygrid-day-frame
|
||||
) {
|
||||
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked .fc-daygrid-day-frame) {
|
||||
background-color: rgba(239, 68, 68, 0.2) !important;
|
||||
border-color: rgba(239, 68, 68, 0.4) !important;
|
||||
}
|
||||
|
||||
:global(
|
||||
.calendario-ausencias
|
||||
.fc-daygrid-day.fc-day-blocked
|
||||
.fc-daygrid-day-number
|
||||
) {
|
||||
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked .fc-daygrid-day-number) {
|
||||
color: #dc2626 !important;
|
||||
font-weight: 700 !important;
|
||||
text-decoration: line-through !important;
|
||||
@@ -945,7 +895,7 @@
|
||||
}
|
||||
|
||||
:global(.calendario-ausencias .fc-daygrid-day.fc-day-blocked::before) {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
<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 { SvelteDate } from 'svelte/reactivity';
|
||||
import { Check, ChevronLeft, ChevronRight, Calendar, AlertTriangle, CheckCircle } from 'lucide-svelte';
|
||||
import { parseLocalDate } from '$lib/utils/datas';
|
||||
import type { toast } from 'svelte-sonner';
|
||||
import ErrorModal from '../ErrorModal.svelte';
|
||||
import CalendarioAusencias from './CalendarioAusencias.svelte';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
@@ -12,7 +15,7 @@
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
const { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
// Cliente Convex
|
||||
const client = useConvexClient();
|
||||
@@ -38,7 +41,7 @@
|
||||
});
|
||||
|
||||
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
||||
const ausenciasExistentes = $derived(
|
||||
let ausenciasExistentes = $derived(
|
||||
(ausenciasExistentesQuery?.data || [])
|
||||
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
|
||||
.map((a) => ({
|
||||
@@ -57,7 +60,7 @@
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
||||
}
|
||||
|
||||
const totalDias = $derived(calcularDias(dataInicio, dataFim));
|
||||
let totalDias = $derived(calcularDias(dataInicio, dataFim));
|
||||
|
||||
// Funções de navegação
|
||||
function proximoPasso() {
|
||||
@@ -67,16 +70,16 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const hoje = new Date();
|
||||
const hoje = new SvelteDate();
|
||||
hoje.setHours(0, 0, 0, 0);
|
||||
const inicio = new Date(dataInicio);
|
||||
const inicio = parseLocalDate(dataInicio);
|
||||
|
||||
if (inicio < hoje) {
|
||||
toast.error('A data de início não pode ser no passado');
|
||||
return;
|
||||
}
|
||||
|
||||
if (new Date(dataFim) < new Date(dataInicio)) {
|
||||
if (parseLocalDate(dataFim) < parseLocalDate(dataInicio)) {
|
||||
toast.error('A data de fim deve ser maior ou igual à data de início');
|
||||
return;
|
||||
}
|
||||
@@ -132,7 +135,7 @@
|
||||
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.`;
|
||||
detalhesErroModal = `Já existe uma solicitação aprovada ou pendente para o período selecionado:\n\nPeríodo selecionado: ${parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até ${parseLocalDate(dataFim).toLocaleDateString('pt-BR')}\n\nPor favor, escolha um período diferente ou aguarde a resposta da solicitação existente.`;
|
||||
mostrarModalErro = true;
|
||||
} else {
|
||||
// Outros erros continuam usando toast
|
||||
@@ -167,20 +170,7 @@
|
||||
<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>
|
||||
<Check class="h-6 w-6" strokeWidth={2} />
|
||||
{:else}
|
||||
{passoAtual}
|
||||
{/if}
|
||||
@@ -195,20 +185,7 @@
|
||||
<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>
|
||||
<Check class="h-6 w-6" strokeWidth={2} />
|
||||
{:else}
|
||||
2
|
||||
{/if}
|
||||
@@ -250,24 +227,12 @@
|
||||
|
||||
{#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>
|
||||
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<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)
|
||||
De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até
|
||||
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,38 +250,23 @@
|
||||
|
||||
<!-- Resumo do período -->
|
||||
{#if dataInicio && dataFim}
|
||||
<div
|
||||
class="card border-2 border-orange-500/30 bg-linear-to-br from-orange-50 to-amber-50 dark:from-orange-950 dark:to-amber-950"
|
||||
>
|
||||
<div class="card border-base-content/20 border-2">
|
||||
<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>
|
||||
<Calendar class="h-5 w-5" strokeWidth={2} />
|
||||
Resumo do Período
|
||||
</h4>
|
||||
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||
<p class="font-bold">
|
||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
||||
{parseLocalDate(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')}
|
||||
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -345,7 +295,7 @@
|
||||
bind:value={motivo}
|
||||
maxlength={500}
|
||||
></textarea>
|
||||
<label class="label">
|
||||
<label class="label" for="motivo">
|
||||
<span class="label-text-alt text-base-content/70">
|
||||
Mínimo 10 caracteres. Seja claro e objetivo.
|
||||
</span>
|
||||
@@ -354,19 +304,7 @@
|
||||
|
||||
{#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>
|
||||
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -381,20 +319,7 @@
|
||||
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>
|
||||
<ChevronLeft class="mr-2 h-5 w-5" strokeWidth={2} />
|
||||
Voltar
|
||||
</button>
|
||||
|
||||
@@ -406,20 +331,7 @@
|
||||
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>
|
||||
<ChevronRight class="ml-2 h-5 w-5" strokeWidth={2} />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -432,20 +344,7 @@
|
||||
<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>
|
||||
<Check class="mr-2 h-5 w-5" strokeWidth={2} />
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
141
apps/web/src/lib/components/call/CallControls.svelte
Normal file
141
apps/web/src/lib/components/call/CallControls.svelte
Normal file
@@ -0,0 +1,141 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
Mic,
|
||||
MicOff,
|
||||
Video,
|
||||
VideoOff,
|
||||
Radio,
|
||||
Square,
|
||||
Settings,
|
||||
PhoneOff,
|
||||
Circle
|
||||
} from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
gravando: boolean;
|
||||
ehAnfitriao: boolean;
|
||||
duracaoSegundos: number;
|
||||
onToggleAudio: () => void;
|
||||
onToggleVideo: () => void;
|
||||
onIniciarGravacao: () => void;
|
||||
onPararGravacao: () => void;
|
||||
onAbrirConfiguracoes: () => void;
|
||||
onEncerrar: () => void;
|
||||
}
|
||||
|
||||
const {
|
||||
audioHabilitado,
|
||||
videoHabilitado,
|
||||
gravando,
|
||||
ehAnfitriao,
|
||||
duracaoSegundos,
|
||||
onToggleAudio,
|
||||
onToggleVideo,
|
||||
onIniciarGravacao,
|
||||
onPararGravacao,
|
||||
onAbrirConfiguracoes,
|
||||
onEncerrar
|
||||
}: Props = $props();
|
||||
|
||||
// Formatar duração para HH:MM:SS
|
||||
function formatarDuracao(segundos: number): string {
|
||||
const horas = Math.floor(segundos / 3600);
|
||||
const minutos = Math.floor((segundos % 3600) / 60);
|
||||
const segs = segundos % 60;
|
||||
|
||||
if (horas > 0) {
|
||||
return `${horas.toString().padStart(2, '0')}:${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
let duracaoFormatada = $derived(formatarDuracao(duracaoSegundos));
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200 flex items-center justify-between gap-2 px-4 py-3">
|
||||
<!-- Contador de duração -->
|
||||
<div class="text-base-content flex items-center gap-2 font-mono text-sm">
|
||||
<Circle class="text-error h-2 w-2 fill-current" />
|
||||
<span>{duracaoFormatada}</span>
|
||||
</div>
|
||||
|
||||
<!-- Controles principais -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Toggle Áudio -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm"
|
||||
class:btn-primary={audioHabilitado}
|
||||
class:btn-error={!audioHabilitado}
|
||||
onclick={onToggleAudio}
|
||||
title={audioHabilitado ? 'Desabilitar áudio' : 'Habilitar áudio'}
|
||||
aria-label={audioHabilitado ? 'Desabilitar áudio' : 'Habilitar áudio'}
|
||||
>
|
||||
{#if audioHabilitado}
|
||||
<Mic class="h-4 w-4" />
|
||||
{:else}
|
||||
<MicOff class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Toggle Vídeo -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm"
|
||||
class:btn-primary={videoHabilitado}
|
||||
class:btn-error={!videoHabilitado}
|
||||
onclick={onToggleVideo}
|
||||
title={videoHabilitado ? 'Desabilitar vídeo' : 'Habilitar vídeo'}
|
||||
aria-label={videoHabilitado ? 'Desabilitar vídeo' : 'Habilitar vídeo'}
|
||||
>
|
||||
{#if videoHabilitado}
|
||||
<Video class="h-4 w-4" />
|
||||
{:else}
|
||||
<VideoOff class="h-4 w-4" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Gravação (apenas anfitrião) -->
|
||||
{#if ehAnfitriao}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm"
|
||||
class:btn-primary={!gravando}
|
||||
class:btn-error={gravando}
|
||||
onclick={gravando ? onPararGravacao : onIniciarGravacao}
|
||||
title={gravando ? 'Parar gravação' : 'Iniciar gravação'}
|
||||
aria-label={gravando ? 'Parar gravação' : 'Iniciar gravação'}
|
||||
>
|
||||
{#if gravando}
|
||||
<Square class="h-4 w-4" />
|
||||
{:else}
|
||||
<Radio class="h-4 w-4 fill-current" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Configurações -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm btn-ghost"
|
||||
onclick={onAbrirConfiguracoes}
|
||||
title="Configurações"
|
||||
aria-label="Configurações"
|
||||
>
|
||||
<Settings class="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<!-- Encerrar chamada -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-sm btn-error"
|
||||
onclick={onEncerrar}
|
||||
title="Encerrar chamada"
|
||||
aria-label="Encerrar chamada"
|
||||
>
|
||||
<PhoneOff class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
301
apps/web/src/lib/components/call/CallSettings.svelte
Normal file
301
apps/web/src/lib/components/call/CallSettings.svelte
Normal file
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import { Check, Volume2, X } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import type { DispositivoMedia } from '$lib/utils/jitsi';
|
||||
import { obterDispositivosDisponiveis, solicitarPermissaoMidia } from '$lib/utils/jitsi';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
dispositivoAtual: {
|
||||
microphoneId: string | null;
|
||||
cameraId: string | null;
|
||||
speakerId: string | null;
|
||||
};
|
||||
onClose: () => void;
|
||||
onAplicar: (dispositivos: {
|
||||
microphoneId: string | null;
|
||||
cameraId: string | null;
|
||||
speakerId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
let { open, dispositivoAtual, onClose, onAplicar }: Props = $props();
|
||||
|
||||
let dispositivos = $state<{
|
||||
microphones: DispositivoMedia[];
|
||||
speakers: DispositivoMedia[];
|
||||
cameras: DispositivoMedia[];
|
||||
}>({
|
||||
microphones: [],
|
||||
speakers: [],
|
||||
cameras: []
|
||||
});
|
||||
|
||||
let selecionados = $state({
|
||||
microphoneId: dispositivoAtual.microphoneId || null,
|
||||
cameraId: dispositivoAtual.cameraId || null,
|
||||
speakerId: dispositivoAtual.speakerId || null
|
||||
});
|
||||
|
||||
let carregando = $state(false);
|
||||
let previewStream: MediaStream | null = $state(null);
|
||||
let previewVideo: HTMLVideoElement | null = $state(null);
|
||||
let erro = $state<string | null>(null);
|
||||
|
||||
// Carregar dispositivos disponíveis
|
||||
async function carregarDispositivos(): Promise<void> {
|
||||
carregando = true;
|
||||
erro = null;
|
||||
try {
|
||||
dispositivos = await obterDispositivosDisponiveis();
|
||||
if (dispositivos.microphones.length === 0 && dispositivos.cameras.length === 0) {
|
||||
erro = 'Nenhum dispositivo de mídia encontrado. Verifique as permissões do navegador.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao carregar dispositivos:', error);
|
||||
erro = 'Erro ao carregar dispositivos de mídia.';
|
||||
} finally {
|
||||
carregando = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar preview quando mudar dispositivos
|
||||
async function atualizarPreview(): Promise<void> {
|
||||
if (previewStream) {
|
||||
previewStream.getTracks().forEach((track) => track.stop());
|
||||
previewStream = null;
|
||||
}
|
||||
|
||||
if (!previewVideo) return;
|
||||
|
||||
try {
|
||||
const audio = selecionados.microphoneId !== null;
|
||||
const video = selecionados.cameraId !== null;
|
||||
|
||||
if (audio || video) {
|
||||
const constraints: MediaStreamConstraints = {
|
||||
audio: audio
|
||||
? {
|
||||
deviceId: selecionados.microphoneId
|
||||
? { exact: selecionados.microphoneId }
|
||||
: undefined
|
||||
}
|
||||
: false,
|
||||
video: video
|
||||
? {
|
||||
deviceId: selecionados.cameraId ? { exact: selecionados.cameraId } : undefined
|
||||
}
|
||||
: false
|
||||
};
|
||||
|
||||
previewStream = await solicitarPermissaoMidia(audio, video);
|
||||
if (previewStream && previewVideo) {
|
||||
previewVideo.srcObject = previewStream;
|
||||
}
|
||||
} else {
|
||||
previewVideo.srcObject = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao atualizar preview:', error);
|
||||
erro = 'Erro ao acessar dispositivo de mídia.';
|
||||
}
|
||||
}
|
||||
|
||||
// Testar áudio
|
||||
async function testarAudio(): Promise<void> {
|
||||
if (!selecionados.microphoneId) {
|
||||
erro = 'Selecione um microfone primeiro.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await solicitarPermissaoMidia(true, false);
|
||||
if (stream) {
|
||||
// Criar elemento de áudio temporário para teste
|
||||
const audio = new Audio();
|
||||
const audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length > 0) {
|
||||
// O áudio será reproduzido automaticamente se conectado
|
||||
setTimeout(() => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao testar áudio:', error);
|
||||
erro = 'Erro ao testar microfone.';
|
||||
}
|
||||
}
|
||||
|
||||
function handleFechar(): void {
|
||||
// Parar preview ao fechar
|
||||
if (previewStream) {
|
||||
previewStream.getTracks().forEach((track) => track.stop());
|
||||
previewStream = null;
|
||||
}
|
||||
if (previewVideo) {
|
||||
previewVideo.srcObject = null;
|
||||
}
|
||||
erro = null;
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleAplicar(): void {
|
||||
onAplicar({
|
||||
microphoneId: selecionados.microphoneId,
|
||||
cameraId: selecionados.cameraId,
|
||||
speakerId: selecionados.speakerId
|
||||
});
|
||||
handleFechar();
|
||||
}
|
||||
|
||||
// Carregar dispositivos quando abrir
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (open) {
|
||||
carregarDispositivos();
|
||||
} else {
|
||||
// Limpar preview ao fechar
|
||||
if (previewStream) {
|
||||
previewStream.getTracks().forEach((track) => track.stop());
|
||||
previewStream = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Atualizar preview quando mudar seleção
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (open && (selecionados.microphoneId || selecionados.cameraId)) {
|
||||
atualizarPreview();
|
||||
}
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
return () => {
|
||||
// Cleanup ao desmontar
|
||||
if (previewStream) {
|
||||
previewStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<dialog
|
||||
class="modal modal-open"
|
||||
onclick={(e) => e.target === e.currentTarget && handleFechar()}
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div class="modal-box max-w-2xl" onclick={(e) => e.stopPropagation()}>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 id="modal-title" class="text-xl font-semibold">Configurações de Mídia</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle"
|
||||
onclick={handleFechar}
|
||||
aria-label="Fechar"
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="max-h-[70vh] space-y-6 overflow-y-auto p-6">
|
||||
{#if erro}
|
||||
<div class="alert alert-error">
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if carregando}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Seleção de Microfone -->
|
||||
<div>
|
||||
<label class="text-base-content mb-2 block text-sm font-medium"> Microfone </label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={selecionados.microphoneId}
|
||||
onchange={atualizarPreview}
|
||||
>
|
||||
<option value={null}>Padrão do Sistema</option>
|
||||
{#each dispositivos.microphones as microfone}
|
||||
<option value={microfone.deviceId}>{microfone.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if selecionados.microphoneId}
|
||||
<button type="button" class="btn btn-sm btn-ghost mt-2" onclick={testarAudio}>
|
||||
<Volume2 class="h-4 w-4" />
|
||||
Testar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Seleção de Câmera -->
|
||||
<div>
|
||||
<label class="text-base-content mb-2 block text-sm font-medium"> Câmera </label>
|
||||
<select
|
||||
class="select select-bordered w-full"
|
||||
bind:value={selecionados.cameraId}
|
||||
onchange={atualizarPreview}
|
||||
>
|
||||
<option value={null}>Padrão do Sistema</option>
|
||||
{#each dispositivos.cameras as camera}
|
||||
<option value={camera.deviceId}>{camera.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Preview de Vídeo -->
|
||||
{#if selecionados.cameraId}
|
||||
<div>
|
||||
<label class="text-base-content mb-2 block text-sm font-medium"> Preview </label>
|
||||
<div class="bg-base-300 aspect-video w-full overflow-hidden rounded-lg">
|
||||
<video
|
||||
bind:this={previewVideo}
|
||||
autoplay
|
||||
muted
|
||||
playsinline
|
||||
class="h-full w-full object-cover"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Seleção de Alto-falante (se disponível) -->
|
||||
{#if dispositivos.speakers.length > 0}
|
||||
<div>
|
||||
<label class="text-base-content mb-2 block text-sm font-medium"> Alto-falante </label>
|
||||
<select class="select select-bordered w-full" bind:value={selecionados.speakerId}>
|
||||
<option value={null}>Padrão do Sistema</option>
|
||||
{#each dispositivos.speakers as speaker}
|
||||
<option value={speaker.deviceId}>{speaker.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="modal-action border-base-300 border-t px-6 py-4">
|
||||
<button type="button" class="btn btn-ghost" onclick={handleFechar}> Cancelar </button>
|
||||
<button type="button" class="btn btn-primary" onclick={handleAplicar} disabled={carregando}>
|
||||
<Check class="h-4 w-4" />
|
||||
Aplicar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button type="button" onclick={handleFechar}>fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
1537
apps/web/src/lib/components/call/CallWindow.svelte
Normal file
1537
apps/web/src/lib/components/call/CallWindow.svelte
Normal file
File diff suppressed because it is too large
Load Diff
99
apps/web/src/lib/components/call/HostControls.svelte
Normal file
99
apps/web/src/lib/components/call/HostControls.svelte
Normal file
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { Mic, MicOff, Shield, User, Video, VideoOff } from 'lucide-svelte';
|
||||
import UserAvatar from '../chat/UserAvatar.svelte';
|
||||
|
||||
interface ParticipanteHost {
|
||||
usuarioId: Id<'usuarios'>;
|
||||
nome: string;
|
||||
avatar?: string;
|
||||
audioHabilitado: boolean;
|
||||
videoHabilitado: boolean;
|
||||
forcadoPeloAnfitriao?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
participantes: ParticipanteHost[];
|
||||
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
|
||||
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
|
||||
}
|
||||
|
||||
const { participantes, onToggleParticipanteAudio, onToggleParticipanteVideo }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-base-200 border-base-300 flex flex-col border-t">
|
||||
<div class="bg-base-300 border-base-300 flex items-center gap-2 border-b px-4 py-2">
|
||||
<Shield class="text-primary h-4 w-4" />
|
||||
<span class="text-base-content text-sm font-semibold">Controles do Anfitrião</span>
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto p-4">
|
||||
{#if participantes.length === 0}
|
||||
<div class="text-base-content/70 flex items-center justify-center py-8 text-sm">
|
||||
Nenhum participante na chamada
|
||||
</div>
|
||||
{:else}
|
||||
{#each participantes as participante}
|
||||
<div class="bg-base-100 flex items-center justify-between rounded-lg p-3 shadow-sm">
|
||||
<!-- Informações do participante -->
|
||||
<div class="flex items-center gap-3">
|
||||
<UserAvatar usuarioId={participante.usuarioId} avatar={participante.avatar} />
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base-content text-sm font-medium">
|
||||
{participante.nome}
|
||||
</span>
|
||||
{#if participante.forcadoPeloAnfitriao}
|
||||
<span class="text-base-content/60 text-xs"> Controlado pelo anfitrião </span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controles do participante -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Toggle Áudio -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-xs"
|
||||
class:btn-primary={participante.audioHabilitado}
|
||||
class:btn-error={!participante.audioHabilitado}
|
||||
onclick={() => onToggleParticipanteAudio(participante.usuarioId)}
|
||||
title={participante.audioHabilitado
|
||||
? `Desabilitar áudio de ${participante.nome}`
|
||||
: `Habilitar áudio de ${participante.nome}`}
|
||||
aria-label={participante.audioHabilitado
|
||||
? `Desabilitar áudio de ${participante.nome}`
|
||||
: `Habilitar áudio de ${participante.nome}`}
|
||||
>
|
||||
{#if participante.audioHabilitado}
|
||||
<Mic class="h-3 w-3" />
|
||||
{:else}
|
||||
<MicOff class="h-3 w-3" />
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<!-- Toggle Vídeo -->
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-circle btn-xs"
|
||||
class:btn-primary={participante.videoHabilitado}
|
||||
class:btn-error={!participante.videoHabilitado}
|
||||
onclick={() => onToggleParticipanteVideo(participante.usuarioId)}
|
||||
title={participante.videoHabilitado
|
||||
? `Desabilitar vídeo de ${participante.nome}`
|
||||
: `Habilitar vídeo de ${participante.nome}`}
|
||||
aria-label={participante.videoHabilitado
|
||||
? `Desabilitar vídeo de ${participante.nome}`
|
||||
: `Habilitar vídeo de ${participante.nome}`}
|
||||
>
|
||||
{#if participante.videoHabilitado}
|
||||
<Video class="h-3 w-3" />
|
||||
{:else}
|
||||
<VideoOff class="h-3 w-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
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;
|
||||
}
|
||||
|
||||
const { gravando, iniciadoPor }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if gravando}
|
||||
<div
|
||||
class="bg-error/90 text-error-content flex items-center gap-2 px-4 py-2 text-sm font-semibold"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<div class="animate-pulse">
|
||||
<div class="bg-error-content h-3 w-3 rounded-full"></div>
|
||||
</div>
|
||||
<span>
|
||||
{iniciadoPor ? `Gravando iniciada por ${iniciadoPor}` : 'Chamada está sendo gravada'}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
182
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
182
apps/web/src/lib/components/chamados/SlaChart.svelte
Normal file
@@ -0,0 +1,182 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
type Props = {
|
||||
dadosSla: {
|
||||
statusSla: {
|
||||
dentroPrazo: number;
|
||||
proximoVencimento: number;
|
||||
vencido: number;
|
||||
semPrazo: number;
|
||||
};
|
||||
porPrioridade: {
|
||||
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
media: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
alta: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||
};
|
||||
taxaCumprimento: number;
|
||||
totalComPrazo: number;
|
||||
atualizadoEm: number;
|
||||
};
|
||||
height?: number;
|
||||
};
|
||||
|
||||
let { dadosSla, height = 400 }: Props = $props();
|
||||
|
||||
let canvas: HTMLCanvasElement;
|
||||
let chart: Chart | null = null;
|
||||
|
||||
function prepararDados() {
|
||||
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
|
||||
const cores = {
|
||||
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
|
||||
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
|
||||
vencido: 'rgba(239, 68, 68, 0.8)' // vermelho
|
||||
};
|
||||
|
||||
return {
|
||||
labels: prioridades,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Dentro do Prazo',
|
||||
data: [
|
||||
dadosSla.porPrioridade.baixa.dentroPrazo,
|
||||
dadosSla.porPrioridade.media.dentroPrazo,
|
||||
dadosSla.porPrioridade.alta.dentroPrazo,
|
||||
dadosSla.porPrioridade.critica.dentroPrazo
|
||||
],
|
||||
backgroundColor: cores.dentroPrazo,
|
||||
borderColor: 'rgba(34, 197, 94, 1)',
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'Próximo ao Vencimento',
|
||||
data: [
|
||||
dadosSla.porPrioridade.baixa.proximoVencimento,
|
||||
dadosSla.porPrioridade.media.proximoVencimento,
|
||||
dadosSla.porPrioridade.alta.proximoVencimento,
|
||||
dadosSla.porPrioridade.critica.proximoVencimento
|
||||
],
|
||||
backgroundColor: cores.proximoVencimento,
|
||||
borderColor: 'rgba(251, 191, 36, 1)',
|
||||
borderWidth: 2
|
||||
},
|
||||
{
|
||||
label: 'Vencido',
|
||||
data: [
|
||||
dadosSla.porPrioridade.baixa.vencido,
|
||||
dadosSla.porPrioridade.media.vencido,
|
||||
dadosSla.porPrioridade.alta.vencido,
|
||||
dadosSla.porPrioridade.critica.vencido
|
||||
],
|
||||
backgroundColor: cores.vencido,
|
||||
borderColor: 'rgba(239, 68, 68, 1)',
|
||||
borderWidth: 2
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
const chartData = prepararDados();
|
||||
chart = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: '#a6adbb',
|
||||
font: {
|
||||
size: 12,
|
||||
family: "'Inter', sans-serif"
|
||||
},
|
||||
usePointStyle: true,
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: '#570df8',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
const prioridade = context.label;
|
||||
return `${label}: ${value} chamado(s)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a6adbb',
|
||||
font: {
|
||||
size: 11,
|
||||
weight: '500'
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(255, 255, 255, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#a6adbb',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 800,
|
||||
easing: 'easeInOutQuart'
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (chart && dadosSla) {
|
||||
const chartData = prepararDados();
|
||||
chart.data = chartData;
|
||||
chart.update('active');
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chart) {
|
||||
chart.destroy();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div style="height: {height}px; position: relative;">
|
||||
<canvas bind:this={canvas}></canvas>
|
||||
</div>
|
||||
106
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
106
apps/web/src/lib/components/chamados/TicketCard.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import {
|
||||
corPrazo,
|
||||
formatarData,
|
||||
getStatusBadge,
|
||||
getStatusDescription,
|
||||
getStatusLabel,
|
||||
prazoRestante
|
||||
} from '$lib/utils/chamados';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
type Ticket = Doc<'tickets'>;
|
||||
|
||||
interface Props {
|
||||
ticket: Ticket;
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{ select: { ticketId: Id<'tickets'> } }>();
|
||||
const props: Props = $props();
|
||||
const ticket = $derived(props.ticket);
|
||||
const selected = $derived(props.selected ?? false);
|
||||
|
||||
const prioridadeClasses: Record<string, string> = {
|
||||
baixa: 'badge badge-sm bg-base-200 text-base-content/70',
|
||||
media: 'badge badge-sm badge-info badge-outline',
|
||||
alta: 'badge badge-sm badge-warning',
|
||||
critica: 'badge badge-sm badge-error'
|
||||
};
|
||||
|
||||
function handleSelect() {
|
||||
dispatch('select', { ticketId: ticket._id });
|
||||
}
|
||||
|
||||
function getPrazoBadges() {
|
||||
const badges: Array<{ label: string; classe: string }> = [];
|
||||
if (ticket.prazoResposta) {
|
||||
const cor = corPrazo(ticket.prazoResposta);
|
||||
badges.push({
|
||||
label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ''}`,
|
||||
classe: `badge badge-xs ${
|
||||
cor === 'error' ? 'badge-error' : cor === 'warning' ? 'badge-warning' : 'badge-success'
|
||||
}`
|
||||
});
|
||||
}
|
||||
if (ticket.prazoConclusao) {
|
||||
const cor = corPrazo(ticket.prazoConclusao);
|
||||
badges.push({
|
||||
label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ''}`,
|
||||
classe: `badge badge-xs ${
|
||||
cor === 'error' ? 'badge-error' : cor === 'warning' ? 'badge-warning' : 'badge-success'
|
||||
}`
|
||||
});
|
||||
}
|
||||
return badges;
|
||||
}
|
||||
</script>
|
||||
|
||||
<article
|
||||
class={`rounded-2xl border p-4 transition-all duration-200 ${
|
||||
selected
|
||||
? 'border-primary bg-primary/5 shadow-lg'
|
||||
: 'border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md'
|
||||
}`}
|
||||
>
|
||||
<button class="w-full text-left" type="button" onclick={handleSelect}>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-base-content/50 text-xs tracking-wide uppercase">
|
||||
Ticket {ticket.numero}
|
||||
</p>
|
||||
<h3 class="text-base-content text-lg font-semibold">{ticket.titulo}</h3>
|
||||
</div>
|
||||
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||
</div>
|
||||
|
||||
<p class="text-base-content/60 mt-2 line-clamp-2 text-sm">{ticket.descricao}</p>
|
||||
|
||||
<div class="text-base-content/60 mt-3 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span class={prioridadeClasses[ticket.prioridade] ?? 'badge badge-sm'}>
|
||||
Prioridade {ticket.prioridade}
|
||||
</span>
|
||||
<span class="badge badge-xs badge-outline">
|
||||
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
|
||||
</span>
|
||||
{#if ticket.setorResponsavel}
|
||||
<span class="badge badge-xs badge-outline badge-ghost">
|
||||
{ticket.setorResponsavel}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-base-content/50 mt-4 space-y-1 text-xs">
|
||||
<p>
|
||||
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
|
||||
</p>
|
||||
<p>{getStatusDescription(ticket.status)}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each getPrazoBadges() as badge (badge.label)}
|
||||
<span class={badge.classe}>{badge.label}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
261
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
261
apps/web/src/lib/components/chamados/TicketForm.svelte
Normal file
@@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { Paperclip, X, Send } from 'lucide-svelte';
|
||||
|
||||
interface FormValues {
|
||||
titulo: string;
|
||||
descricao: string;
|
||||
tipo: Doc<'tickets'>['tipo'];
|
||||
prioridade: Doc<'tickets'>['prioridade'];
|
||||
categoria: string;
|
||||
canalOrigem?: string;
|
||||
anexos: File[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
|
||||
const props = $props<Props>();
|
||||
const loading = $derived(props.loading ?? false);
|
||||
|
||||
let titulo = $state('');
|
||||
let descricao = $state('');
|
||||
let tipo = $state<Doc<'tickets'>['tipo']>('chamado');
|
||||
let prioridade = $state<Doc<'tickets'>['prioridade']>('media');
|
||||
let categoria = $state('');
|
||||
let canalOrigem = $state('Portal SGSE');
|
||||
let anexos = $state<Array<File>>([]);
|
||||
let errors = $state<Record<string, string>>({});
|
||||
function validate(): boolean {
|
||||
const novoErros: Record<string, string> = {};
|
||||
if (!titulo.trim()) novoErros.titulo = 'Informe um título para o chamado.';
|
||||
if (!descricao.trim()) novoErros.descricao = 'Descrição é obrigatória.';
|
||||
if (!categoria.trim()) novoErros.categoria = 'Informe uma categoria.';
|
||||
errors = novoErros;
|
||||
return Object.keys(novoErros).length === 0;
|
||||
}
|
||||
|
||||
function handleFiles(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const files = Array.from(target.files ?? []);
|
||||
anexos = files.slice(0, 5); // limitar para 5 anexos
|
||||
}
|
||||
|
||||
function removeFile(index: number) {
|
||||
anexos = anexos.filter((_, idx) => idx !== index);
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
titulo = '';
|
||||
descricao = '';
|
||||
categoria = '';
|
||||
tipo = 'chamado';
|
||||
prioridade = 'media';
|
||||
anexos = [];
|
||||
errors = {};
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!validate()) return;
|
||||
|
||||
dispatch('submit', {
|
||||
values: {
|
||||
titulo: titulo.trim(),
|
||||
descricao: descricao.trim(),
|
||||
tipo,
|
||||
prioridade,
|
||||
categoria: categoria.trim(),
|
||||
canalOrigem,
|
||||
anexos
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<form class="space-y-8" onsubmit={handleSubmit}>
|
||||
<!-- Título do Chamado -->
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-base-content font-semibold">Título do chamado</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered input-primary w-full"
|
||||
placeholder="Ex: Erro ao acessar o módulo de licitações"
|
||||
bind:value={titulo}
|
||||
/>
|
||||
{#if errors.titulo}
|
||||
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Tipo de Solicitação e Prioridade -->
|
||||
<section class="grid gap-6 md:grid-cols-2">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-base-content font-semibold">Tipo de solicitação</span>
|
||||
</label>
|
||||
<div class="border-base-300 bg-base-200/30 grid grid-cols-2 gap-2 rounded-xl border p-3">
|
||||
{#each [{ value: 'chamado', label: 'Chamado', icon: '📋' }, { value: 'reclamacao', label: 'Reclamação', icon: '⚠️' }, { value: 'elogio', label: 'Elogio', icon: '⭐' }, { value: 'sugestao', label: 'Sugestão', icon: '💡' }] as opcao}
|
||||
<label
|
||||
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
|
||||
tipo === opcao.value
|
||||
? 'border-primary bg-primary/10 shadow-md'
|
||||
: 'border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="tipo"
|
||||
class="radio radio-primary radio-sm shrink-0"
|
||||
value={opcao.value}
|
||||
checked={tipo === opcao.value}
|
||||
onclick={() => (tipo = opcao.value as typeof tipo)}
|
||||
/>
|
||||
<span class="shrink-0 text-base">{opcao.icon}</span>
|
||||
<span class="flex-1 text-center text-sm font-medium">{opcao.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-base-content font-semibold">Prioridade</span>
|
||||
</label>
|
||||
<div class="border-base-300 bg-base-200/30 grid grid-cols-2 gap-2 rounded-xl border p-3">
|
||||
{#each [{ value: 'baixa', label: 'Baixa', color: 'badge-success' }, { value: 'media', label: 'Média', color: 'badge-info' }, { value: 'alta', label: 'Alta', color: 'badge-warning' }, { value: 'critica', label: 'Crítica', color: 'badge-error' }] as opcao}
|
||||
<label
|
||||
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
|
||||
prioridade === opcao.value
|
||||
? 'border-primary bg-primary/10 shadow-md'
|
||||
: 'border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="prioridade"
|
||||
class={`radio radio-sm shrink-0 ${
|
||||
opcao.value === 'baixa'
|
||||
? 'radio-success'
|
||||
: opcao.value === 'media'
|
||||
? 'radio-info'
|
||||
: opcao.value === 'alta'
|
||||
? 'radio-warning'
|
||||
: 'radio-error'
|
||||
}`}
|
||||
value={opcao.value}
|
||||
checked={prioridade === opcao.value}
|
||||
onclick={() => (prioridade = opcao.value as typeof prioridade)}
|
||||
/>
|
||||
<span class={`badge badge-sm ${opcao.color} flex-1 justify-center`}>{opcao.label}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Categoria -->
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-base-content font-semibold">Categoria</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-bordered w-full"
|
||||
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
|
||||
bind:value={categoria}
|
||||
/>
|
||||
{#if errors.categoria}
|
||||
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Descrição Detalhada -->
|
||||
<section class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-base-content font-semibold">Descrição detalhada</span>
|
||||
<span class="label-text-alt text-base-content/50">Obrigatório</span>
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-lg min-h-[180px]"
|
||||
placeholder="Descreva o problema, erro ou sugestão com o máximo de detalhes possível..."
|
||||
bind:value={descricao}
|
||||
></textarea>
|
||||
{#if errors.descricao}
|
||||
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Anexos -->
|
||||
<section class="border-base-300 bg-base-200/30 space-y-4 rounded-xl border p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-base-content font-semibold">Anexos (opcional)</p>
|
||||
<p class="text-base-content/60 text-sm">Suporte a PDF e imagens (máx. 10MB por arquivo)</p>
|
||||
</div>
|
||||
<label class="btn btn-outline btn-sm">
|
||||
<Paperclip class="h-4 w-4" strokeWidth={2} />
|
||||
Selecionar arquivos
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
multiple
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
onchange={handleFiles}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if anexos.length > 0}
|
||||
<div class="border-base-200 bg-base-100/70 space-y-2 rounded-2xl border p-4">
|
||||
{#each anexos as file, index (file.name + index)}
|
||||
<div
|
||||
class="border-base-200 bg-base-100 flex items-center justify-between gap-3 rounded-xl border px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-medium">{file.name}</p>
|
||||
<p class="text-base-content/60 text-xs">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm text-error"
|
||||
onclick={() => removeFile(index)}
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="border-base-300 bg-base-100/50 text-base-content/60 rounded-2xl border border-dashed p-6 text-center text-sm"
|
||||
>
|
||||
Nenhum arquivo selecionado.
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Ações do Formulário -->
|
||||
<section class="border-base-300 flex flex-wrap gap-3 border-t pt-6">
|
||||
<button type="submit" class="btn btn-primary min-w-[200px] flex-1 shadow-lg" disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<Send class="h-5 w-5" strokeWidth={2} />
|
||||
Registrar chamado
|
||||
{/if}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" onclick={resetForm} disabled={loading}>
|
||||
<X class="h-5 w-5" strokeWidth={2} />
|
||||
Limpar
|
||||
</button>
|
||||
</section>
|
||||
</form>
|
||||
85
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
85
apps/web/src/lib/components/chamados/TicketTimeline.svelte
Normal file
@@ -0,0 +1,85 @@
|
||||
<script lang="ts">
|
||||
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import {
|
||||
formatarData,
|
||||
formatarTimelineEtapa,
|
||||
prazoRestante,
|
||||
timelineStatus
|
||||
} from '$lib/utils/chamados';
|
||||
|
||||
type Ticket = Doc<'tickets'>;
|
||||
type TimelineEntry = NonNullable<Ticket['timeline']>[number];
|
||||
|
||||
interface Props {
|
||||
timeline?: Array<TimelineEntry>;
|
||||
}
|
||||
|
||||
const props: Props = $props();
|
||||
let timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
|
||||
|
||||
const badgeClasses: Record<string, string> = {
|
||||
success: 'bg-success/20 text-success border-success/40',
|
||||
warning: 'bg-warning/20 text-warning border-warning/40',
|
||||
error: 'bg-error/20 text-error border-error/40',
|
||||
info: 'bg-info/20 text-info border-info/40'
|
||||
};
|
||||
|
||||
function getBadgeClass(entry: TimelineEntry) {
|
||||
const status = timelineStatus(entry);
|
||||
return badgeClasses[status] ?? badgeClasses.info;
|
||||
}
|
||||
|
||||
function getStatusLabel(entry: TimelineEntry) {
|
||||
if (entry.status === 'concluido') return 'Concluído';
|
||||
if (entry.status === 'em_andamento') return 'Em andamento';
|
||||
if (entry.status === 'vencido') return 'Vencido';
|
||||
return 'Pendente';
|
||||
}
|
||||
|
||||
function getPrazoDescricao(entry: TimelineEntry) {
|
||||
if (entry.status === 'concluido' && entry.concluidoEm) {
|
||||
return `Concluído em ${formatarData(entry.concluidoEm)}`;
|
||||
}
|
||||
if (!entry.prazo) return 'Sem prazo definido';
|
||||
return `${formatarData(entry.prazo)} • ${prazoRestante(entry.prazo) ?? ''}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if timeline.length === 0}
|
||||
<div class="alert alert-info">
|
||||
<span>Nenhuma etapa registrada ainda.</span>
|
||||
</div>
|
||||
{:else}
|
||||
{#each timeline as entry (entry.etapa + entry.prazo)}
|
||||
<div class="flex gap-3">
|
||||
<div class="relative flex flex-col items-center">
|
||||
<div class={`badge border ${getBadgeClass(entry)}`}>
|
||||
{formatarTimelineEtapa(entry.etapa)}
|
||||
</div>
|
||||
{#if entry !== timeline[timeline.length - 1]}
|
||||
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="border-base-200 bg-base-100/80 flex-1 rounded-2xl border p-4 shadow-sm">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-base-content text-sm font-semibold">
|
||||
{getStatusLabel(entry)}
|
||||
</span>
|
||||
{#if entry.status !== 'concluido' && entry.prazo}
|
||||
<span class="badge badge-sm badge-outline">
|
||||
{prazoRestante(entry.prazo)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if entry.observacao}
|
||||
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
|
||||
{/if}
|
||||
<p class="text-base-content/50 mt-3 text-xs tracking-wide uppercase">
|
||||
{getPrazoDescricao(entry)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,12 +1,14 @@
|
||||
<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 { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
||||
import UserAvatar from "./UserAvatar.svelte";
|
||||
import NewConversationModal from "./NewConversationModal.svelte";
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { abrirConversa } from '$lib/stores/chatStore';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import NewConversationModal from './NewConversationModal.svelte';
|
||||
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
|
||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -19,81 +21,68 @@
|
||||
// Buscar conversas (grupos e salas de reunião)
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
|
||||
let searchQuery = $state("");
|
||||
let activeTab = $state<"usuarios" | "conversas">("usuarios");
|
||||
let searchQuery = $state('');
|
||||
let activeTab = $state<'usuarios' | 'conversas'>('usuarios');
|
||||
|
||||
// Debug: monitorar carregamento de dados
|
||||
$effect(() => {
|
||||
console.log(
|
||||
"📊 [ChatList] Usuários carregados:",
|
||||
usuarios?.data?.length || 0,
|
||||
);
|
||||
console.log(
|
||||
"👤 [ChatList] Meu perfil:",
|
||||
meuPerfil?.data?.nome || "Carregando...",
|
||||
);
|
||||
console.log(
|
||||
"🆔 [ChatList] Meu ID:",
|
||||
meuPerfil?.data?._id || "Não encontrado",
|
||||
);
|
||||
console.log('📊 [ChatList] Usuários carregados:', usuarios?.data?.length || 0);
|
||||
console.log('👤 [ChatList] Meu perfil:', meuPerfil?.data?.nome || 'Carregando...');
|
||||
console.log('🆔 [ChatList] Meu ID:', meuPerfil?.data?._id || 'Não encontrado');
|
||||
if (usuarios?.data) {
|
||||
const meuId = meuPerfil?.data?._id;
|
||||
const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId);
|
||||
const meusDadosNaLista = usuarios.data.find((u) => u._id === meuId);
|
||||
if (meusDadosNaLista) {
|
||||
console.warn(
|
||||
"⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!",
|
||||
meusDadosNaLista.nome,
|
||||
'⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!',
|
||||
meusDadosNaLista.nome
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const usuariosFiltrados = $derived.by(() => {
|
||||
let usuariosFiltrados = $derived.by(() => {
|
||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
||||
|
||||
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
|
||||
if (!meuPerfil?.data) {
|
||||
console.log("⏳ [ChatList] Aguardando perfil do usuário...");
|
||||
console.log('⏳ [ChatList] Aguardando perfil do usuário...');
|
||||
return [];
|
||||
}
|
||||
|
||||
const meuId = meuPerfil.data._id;
|
||||
|
||||
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
|
||||
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
|
||||
let listaFiltrada = usuarios.data.filter((u) => u._id !== meuId);
|
||||
|
||||
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
||||
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId);
|
||||
const aindaNaLista = listaFiltrada.find((u) => u._id === meuId);
|
||||
if (aindaNaLista) {
|
||||
console.error(
|
||||
"❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!",
|
||||
);
|
||||
console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!');
|
||||
}
|
||||
|
||||
// Aplicar busca por nome/email/matrícula
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
listaFiltrada = listaFiltrada.filter(
|
||||
(u: any) =>
|
||||
(u) =>
|
||||
u.nome?.toLowerCase().includes(query) ||
|
||||
u.email?.toLowerCase().includes(query) ||
|
||||
u.matricula?.toLowerCase().includes(query),
|
||||
u.matricula?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar: Online primeiro, depois por nome
|
||||
return listaFiltrada.sort((a: any, b: any) => {
|
||||
return listaFiltrada.sort((a, b) => {
|
||||
const statusOrder = {
|
||||
online: 0,
|
||||
ausente: 1,
|
||||
externo: 2,
|
||||
em_reuniao: 3,
|
||||
offline: 4,
|
||||
offline: 4
|
||||
};
|
||||
const statusA =
|
||||
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||
const statusB =
|
||||
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 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);
|
||||
@@ -101,14 +90,14 @@
|
||||
});
|
||||
|
||||
function formatarTempo(timestamp: number | undefined): string {
|
||||
if (!timestamp) return "";
|
||||
if (!timestamp) return '';
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
locale: ptBR
|
||||
});
|
||||
} catch {
|
||||
return "";
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,40 +106,35 @@
|
||||
|
||||
async function handleClickUsuario(usuario: any) {
|
||||
if (processando) {
|
||||
console.log("⏳ Já está processando uma ação, aguarde...");
|
||||
console.log('⏳ Já está processando uma ação, aguarde...');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
processando = true;
|
||||
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
|
||||
console.log('🔄 Clicou no usuário:', usuario.nome, 'ID:', usuario._id);
|
||||
|
||||
// Criar ou buscar conversa individual com este usuário
|
||||
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
|
||||
const conversaId = await client.mutation(
|
||||
api.chat.criarOuBuscarConversaIndividual,
|
||||
{
|
||||
outroUsuarioId: usuario._id,
|
||||
},
|
||||
);
|
||||
console.log('📞 Chamando mutation criarOuBuscarConversaIndividual...');
|
||||
const conversaId = await client.mutation(api.chat.criarOuBuscarConversaIndividual, {
|
||||
outroUsuarioId: usuario._id
|
||||
});
|
||||
|
||||
console.log("✅ Conversa criada/encontrada. ID:", conversaId);
|
||||
console.log('✅ Conversa criada/encontrada. ID:', conversaId);
|
||||
|
||||
// Abrir a conversa
|
||||
console.log("📂 Abrindo conversa...");
|
||||
abrirConversa(conversaId as any);
|
||||
console.log('📂 Abrindo conversa...');
|
||||
abrirConversa(conversaId as Id<'conversas'>);
|
||||
|
||||
console.log("✅ Conversa aberta com sucesso!");
|
||||
console.log('✅ Conversa aberta com sucesso!');
|
||||
} catch (error) {
|
||||
console.error("❌ Erro ao abrir conversa:", error);
|
||||
console.error("Detalhes do erro:", {
|
||||
console.error('❌ Erro ao abrir conversa:', error);
|
||||
console.error('Detalhes do erro:', {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
usuario: usuario,
|
||||
usuario: usuario
|
||||
});
|
||||
alert(
|
||||
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
@@ -158,97 +142,95 @@
|
||||
|
||||
function getStatusLabel(status: string | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
ausente: "Ausente",
|
||||
externo: "Externo",
|
||||
em_reuniao: "Em Reunião",
|
||||
online: 'Online',
|
||||
offline: 'Offline',
|
||||
ausente: 'Ausente',
|
||||
externo: 'Externo',
|
||||
em_reuniao: 'Em Reunião'
|
||||
};
|
||||
return labels[status || "offline"] || "Offline";
|
||||
return labels[status || 'offline'] || 'Offline';
|
||||
}
|
||||
|
||||
// Filtrar conversas por tipo e busca
|
||||
const conversasFiltradas = $derived(() => {
|
||||
let conversasFiltradas = $derived.by(() => {
|
||||
if (!conversas?.data) return [];
|
||||
|
||||
let lista = conversas.data.filter(
|
||||
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao",
|
||||
(c: Doc<'conversas'>) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao'
|
||||
);
|
||||
|
||||
// Aplicar busca
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
|
||||
lista = lista.filter((c: Doc<'conversas'>) => c.nome?.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
return lista;
|
||||
});
|
||||
|
||||
function handleClickConversa(conversa: any) {
|
||||
interface Conversa {
|
||||
_id: Id<'conversas'>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function handleClickConversa(conversa: Conversa) {
|
||||
if (processando) return;
|
||||
try {
|
||||
processando = true;
|
||||
abrirConversa(conversa._id);
|
||||
} catch (error) {
|
||||
console.error("Erro ao abrir conversa:", error);
|
||||
alert(
|
||||
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
console.error('Erro ao abrir conversa:', error);
|
||||
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
processando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Search bar -->
|
||||
<div class="p-4 border-b border-base-300">
|
||||
<div class="border-base-300 border-b p-4">
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar usuários (nome, email, matrícula)..."
|
||||
class="input input-bordered w-full pl-10"
|
||||
bind:value={searchQuery}
|
||||
aria-label="Buscar usuários ou conversas"
|
||||
aria-describedby="search-help"
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
|
||||
<span id="search-help" class="sr-only"
|
||||
>Digite para buscar usuários por nome, email ou matrícula</span
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
||||
<Search
|
||||
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs e Título -->
|
||||
<div class="border-b border-base-300 bg-base-200">
|
||||
<div class="border-base-300 bg-base-200 border-b">
|
||||
<!-- Tabs -->
|
||||
<div class="tabs tabs-boxed p-2">
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "usuarios")}
|
||||
class={`tab flex-1 ${activeTab === 'usuarios' ? 'tab-active' : ''}`}
|
||||
onclick={() => (activeTab = 'usuarios')}
|
||||
>
|
||||
👥 Usuários ({usuariosFiltrados.length})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
|
||||
onclick={() => (activeTab = "conversas")}
|
||||
class={`tab flex-1 ${activeTab === 'conversas' ? 'tab-active' : ''}`}
|
||||
onclick={() => (activeTab = 'conversas')}
|
||||
>
|
||||
💬 Conversas ({conversasFiltradas().length})
|
||||
💬 Conversas ({conversasFiltradas.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Botão Nova Conversa -->
|
||||
<div class="px-4 pb-2 flex justify-end">
|
||||
<div class="flex justify-end px-4 pb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
@@ -256,20 +238,7 @@
|
||||
title="Nova conversa (grupo ou sala de reunião)"
|
||||
aria-label="Nova conversa"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-4 h-4 mr-1"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
<Plus class="mr-1 h-4 w-4" strokeWidth={2} />
|
||||
Nova Conversa
|
||||
</button>
|
||||
</div>
|
||||
@@ -277,66 +246,50 @@
|
||||
|
||||
<!-- Lista de conteúdo -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if activeTab === "usuarios"}
|
||||
{#if activeTab === 'usuarios'}
|
||||
<!-- Lista de usuários -->
|
||||
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
||||
{#each usuariosFiltrados as usuario (usuario._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando
|
||||
? 'opacity-50 cursor-wait'
|
||||
class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
|
||||
? 'cursor-wait opacity-50'
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickUsuario(usuario)}
|
||||
disabled={processando}
|
||||
aria-label="Abrir conversa com {usuario.nome}"
|
||||
aria-describedby="usuario-status-{usuario._id}"
|
||||
>
|
||||
<!-- Ícone de mensagem -->
|
||||
<div
|
||||
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
|
||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="w-5 h-5 text-primary"
|
||||
>
|
||||
<path
|
||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
||||
/>
|
||||
<path d="M9 10h.01M15 10h.01" />
|
||||
</svg>
|
||||
<MessageSquare class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<!-- Avatar -->
|
||||
<div class="relative shrink-0">
|
||||
<UserAvatar
|
||||
avatar={usuario.avatar}
|
||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||
nome={usuario.nome}
|
||||
size="md"
|
||||
userId={usuario._id}
|
||||
/>
|
||||
<!-- Status badge -->
|
||||
<div class="absolute bottom-0 right-0">
|
||||
<UserStatusBadge
|
||||
status={usuario.statusPresenca || "offline"}
|
||||
size="sm"
|
||||
/>
|
||||
<div class="absolute right-0 bottom-0">
|
||||
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{usuario.nome}
|
||||
</p>
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {usuario.statusPresenca ===
|
||||
'online'
|
||||
class="rounded-full px-2 py-0.5 text-xs {usuario.statusPresenca === 'online'
|
||||
? 'bg-success/20 text-success'
|
||||
: usuario.statusPresenca === 'ausente'
|
||||
? 'bg-warning/20 text-warning'
|
||||
@@ -348,124 +301,79 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-base-content/70 truncate">
|
||||
<p class="text-base-content/70 truncate text-sm">
|
||||
{usuario.statusMensagem || usuario.email}
|
||||
</p>
|
||||
</div>
|
||||
<span id="usuario-status-{usuario._id}" class="sr-only">
|
||||
Status: {getStatusLabel(usuario.statusPresenca)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if !usuarios?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhum usuário encontrado -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-center px-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<UsersRound class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
|
||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Lista de conversas (grupos e salas) -->
|
||||
{#if conversas?.data && conversasFiltradas().length > 0}
|
||||
{#each conversasFiltradas() as conversa (conversa._id)}
|
||||
{#if conversas?.data && conversasFiltradas.length > 0}
|
||||
{#each conversasFiltradas as conversa (conversa._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 border-b border-base-300 transition-colors flex items-center gap-3 {processando
|
||||
? 'opacity-50 cursor-wait'
|
||||
class="hover:bg-base-200 border-base-300 flex w-full items-center gap-3 border-b px-4 py-3 text-left transition-colors {processando
|
||||
? 'cursor-wait opacity-50'
|
||||
: 'cursor-pointer'}"
|
||||
onclick={() => handleClickConversa(conversa)}
|
||||
disabled={processando}
|
||||
>
|
||||
<!-- Ícone de grupo/sala -->
|
||||
<div
|
||||
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
||||
'sala_reuniao'
|
||||
? 'bg-linear-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
|
||||
: 'bg-linear-to-br from-primary/20 to-secondary/20 border border-primary/30'}"
|
||||
? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
|
||||
: 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
|
||||
>
|
||||
{#if conversa.tipo === "sala_reuniao"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-blue-500"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
||||
/>
|
||||
</svg>
|
||||
{#if conversa.tipo === 'sala_reuniao'}
|
||||
<UsersRound class="h-5 w-5 text-blue-500" strokeWidth={2} />
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="w-5 h-5 text-primary"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
|
||||
/>
|
||||
</svg>
|
||||
<Users class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<p class="font-semibold text-base-content truncate">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center justify-between">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{conversa.nome ||
|
||||
(conversa.tipo === "sala_reuniao"
|
||||
? "Sala sem nome"
|
||||
: "Grupo sem nome")}
|
||||
(conversa.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome')}
|
||||
</p>
|
||||
{#if conversa.naoLidas > 0}
|
||||
<span class="badge badge-primary badge-sm"
|
||||
>{conversa.naoLidas}</span
|
||||
>
|
||||
<span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo ===
|
||||
'sala_reuniao'
|
||||
class="rounded-full px-2 py-0.5 text-xs {conversa.tipo === 'sala_reuniao'
|
||||
? 'bg-blue-500/20 text-blue-500'
|
||||
: 'bg-primary/20 text-primary'}"
|
||||
>
|
||||
{conversa.tipo === "sala_reuniao"
|
||||
? "👑 Sala de Reunião"
|
||||
: "👥 Grupo"}
|
||||
{conversa.tipo === 'sala_reuniao' ? '👑 Sala de Reunião' : '👥 Grupo'}
|
||||
</span>
|
||||
{#if conversa.participantesInfo}
|
||||
<span class="text-xs text-base-content/50">
|
||||
{conversa.participantesInfo.length} participante{conversa
|
||||
.participantesInfo.length !== 1
|
||||
? "s"
|
||||
: ""}
|
||||
<span class="text-base-content/50 text-xs">
|
||||
{conversa.participantesInfo.length} participante{conversa.participantesInfo
|
||||
.length !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -474,34 +382,15 @@
|
||||
{/each}
|
||||
{:else if !conversas?.data}
|
||||
<!-- Loading -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Nenhuma conversa encontrada -->
|
||||
<div
|
||||
class="flex flex-col items-center justify-center h-full text-center px-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-16 h-16 text-base-content/30 mb-4"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-base-content/70 font-medium mb-2">
|
||||
Nenhuma conversa encontrada
|
||||
</p>
|
||||
<p class="text-sm text-base-content/50">
|
||||
Crie um grupo ou sala de reunião para começar
|
||||
</p>
|
||||
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||
<MessageSquare class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
|
||||
<p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
|
||||
<p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,48 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import {
|
||||
abrirChat,
|
||||
abrirConversa,
|
||||
chatAberto,
|
||||
chatMinimizado,
|
||||
conversaAtiva,
|
||||
fecharChat,
|
||||
minimizarChat,
|
||||
maximizarChat,
|
||||
abrirChat,
|
||||
abrirConversa
|
||||
minimizarChat,
|
||||
notificacaoAtiva
|
||||
} from '$lib/stores/chatStore';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import ChatList from './ChatList.svelte';
|
||||
import ChatWindow from './ChatWindow.svelte';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
|
||||
|
||||
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
||||
|
||||
// Query para verificar o ID do usuário logado (usar como referência)
|
||||
// Query otimizada: usar apenas uma query para obter usuário atual
|
||||
// Priorizar obterPerfil pois retorna mais informações úteis
|
||||
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
// Derivar ID do usuário de forma otimizada (usar perfil primeiro, fallback para currentUser)
|
||||
const meuId = $derived(() => {
|
||||
if (meuPerfilQuery?.data?._id) {
|
||||
return String(meuPerfilQuery.data._id).trim();
|
||||
}
|
||||
if (currentUser?.data?._id) {
|
||||
return String(currentUser.data._id).trim();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
let isOpen = $derived(false);
|
||||
let isMinimized = $derived(false);
|
||||
let activeConversation = $state<string | null>(null);
|
||||
|
||||
// Função para obter a URL do avatar/foto do usuário logado
|
||||
const avatarUrlDoUsuario = $derived(() => {
|
||||
const usuario = currentUser?.data;
|
||||
if (!usuario) return null;
|
||||
// Função para obter a URL do avatar/foto do usuário logado (otimizada)
|
||||
let avatarUrlDoUsuario = $derived(() => {
|
||||
// Priorizar perfil (tem mais informações)
|
||||
const perfil = meuPerfilQuery?.data;
|
||||
if (perfil?.fotoPerfilUrl) {
|
||||
return perfil.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (usuario.fotoPerfilUrl) {
|
||||
// Fallback para currentUser
|
||||
const usuario = currentUser?.data;
|
||||
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)
|
||||
@@ -54,6 +67,13 @@
|
||||
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
|
||||
let hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
|
||||
let shouldPreventClick = $state(false); // Flag para prevenir clique após arrastar
|
||||
let isDoubleClicking = $state(false); // Flag para prevenir clique simples após duplo clique
|
||||
|
||||
// Suporte a gestos touch (swipe)
|
||||
let touchStart = $state<{ x: number; y: number; time: number } | null>(null);
|
||||
let touchCurrent = $state<{ x: number; y: number } | null>(null);
|
||||
let isTouching = $state(false);
|
||||
let swipeVelocity = $state(0); // Velocidade do swipe para animação
|
||||
|
||||
// Tamanho da janela (redimensionável)
|
||||
const MIN_WIDTH = 300;
|
||||
@@ -282,7 +302,7 @@
|
||||
}
|
||||
|
||||
// Garantir que X também está dentro dos limites
|
||||
let newX = Math.max(minX, Math.min(maxX, position.x));
|
||||
const newX = Math.max(minX, Math.min(maxX, position.x));
|
||||
|
||||
// Aplicar novos valores apenas se necessário
|
||||
if (newX !== position.x || newY !== position.y) {
|
||||
@@ -401,45 +421,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Throttle para evitar execuções muito frequentes do effect
|
||||
let ultimaExecucaoNotificacao = $state(0);
|
||||
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
|
||||
|
||||
$effect(() => {
|
||||
if (todasConversas?.data && currentUser?.data?._id) {
|
||||
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||
const agora = Date.now();
|
||||
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
|
||||
|
||||
// Encontrar conversas com novas mensagens
|
||||
// Obter ID do usuário logado de forma robusta
|
||||
// Prioridade: usar query do Convex (mais confiável) > authStore
|
||||
const usuarioLogado = currentUser?.data;
|
||||
const perfilConvex = meuPerfilQuery?.data;
|
||||
|
||||
// Usar ID do Convex se disponível, caso contrário usar authStore
|
||||
let meuId: string | null = null;
|
||||
|
||||
if (perfilConvex && perfilConvex._id) {
|
||||
// Usar ID retornado pela query do Convex (mais confiável)
|
||||
meuId = String(perfilConvex._id).trim();
|
||||
} else if (usuarioLogado && usuarioLogado._id) {
|
||||
// Fallback para authStore
|
||||
meuId = String(usuarioLogado._id).trim();
|
||||
}
|
||||
|
||||
if (!meuId) {
|
||||
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado:', {
|
||||
currentUser: !!usuarioLogado,
|
||||
currentUserId: usuarioLogado?._id,
|
||||
convexPerfil: !!perfilConvex,
|
||||
convexId: perfilConvex?._id
|
||||
});
|
||||
// Throttle: só executar se passou tempo suficiente
|
||||
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log para debug (apenas em desenvolvimento)
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 [ChatWidget] Usuário logado identificado:', {
|
||||
id: meuId,
|
||||
fonte: perfilConvex ? 'Convex Query' : 'CurrentUser',
|
||||
nome: usuarioLogado?.nome || perfilConvex?.nome,
|
||||
email: usuarioLogado?.email
|
||||
});
|
||||
if (todasConversas?.data && meuId()) {
|
||||
ultimaExecucaoNotificacao = agora;
|
||||
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||
const meuIdAtual = meuId();
|
||||
|
||||
if (!meuIdAtual) {
|
||||
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
|
||||
return;
|
||||
}
|
||||
|
||||
conversas.forEach((conv) => {
|
||||
@@ -451,21 +453,8 @@
|
||||
? String(conv.ultimaMensagemRemetenteId).trim()
|
||||
: null;
|
||||
|
||||
// Log para debug da comparação (apenas em desenvolvimento)
|
||||
if (import.meta.env.DEV && remetenteIdStr) {
|
||||
const ehMinhaMensagem = remetenteIdStr === meuId;
|
||||
if (ehMinhaMensagem) {
|
||||
console.log('✅ [ChatWidget] Mensagem identificada como própria (ignorada):', {
|
||||
conversaId: conv._id,
|
||||
meuId,
|
||||
remetenteId: remetenteIdStr,
|
||||
mensagem: conv.ultimaMensagem?.substring(0, 50)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Se a mensagem foi enviada pelo próprio usuário, ignorar completamente
|
||||
if (remetenteIdStr && remetenteIdStr === meuId) {
|
||||
if (remetenteIdStr && remetenteIdStr === meuIdAtual) {
|
||||
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
|
||||
// Marcar como notificada para evitar processamento futuro
|
||||
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
||||
@@ -486,14 +475,29 @@
|
||||
const conversaIdStr = String(conv._id).trim();
|
||||
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
|
||||
|
||||
// Verificar se outra notificação já está ativa para esta mensagem
|
||||
const notificacaoAtual = $notificacaoAtiva;
|
||||
const jaTemNotificacaoAtiva =
|
||||
notificacaoAtual &&
|
||||
notificacaoAtual.conversaId === conversaIdStr &&
|
||||
notificacaoAtual.mensagemId === mensagemId;
|
||||
|
||||
// Só mostrar notificação se:
|
||||
// 1. O chat não está aberto OU
|
||||
// 2. O chat está aberto mas não estamos vendo essa conversa específica
|
||||
if (!isOpen || !estaConversaEstaAberta) {
|
||||
// 3. E não há outra notificação ativa para esta mensagem
|
||||
if ((!isOpen || !estaConversaEstaAberta) && !jaTemNotificacaoAtiva) {
|
||||
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
|
||||
mensagensNotificadasGlobal.add(mensagemId);
|
||||
salvarMensagensNotificadasGlobal();
|
||||
|
||||
// Registrar notificação ativa no store global
|
||||
notificacaoAtiva.set({
|
||||
conversaId: conversaIdStr,
|
||||
mensagemId,
|
||||
componente: 'widget'
|
||||
});
|
||||
|
||||
// Tocar som de notificação (apenas uma vez)
|
||||
tocarSomNotificacaoGlobal();
|
||||
|
||||
@@ -505,13 +509,17 @@
|
||||
};
|
||||
showGlobalNotificationPopup = true;
|
||||
|
||||
// Ocultar popup após 5 segundos
|
||||
// Ocultar popup após 5 segundos - garantir limpeza
|
||||
if (globalNotificationTimeout) {
|
||||
clearTimeout(globalNotificationTimeout);
|
||||
globalNotificationTimeout = null;
|
||||
}
|
||||
globalNotificationTimeout = setTimeout(() => {
|
||||
showGlobalNotificationPopup = false;
|
||||
globalNotificationMessage = null;
|
||||
globalNotificationTimeout = null;
|
||||
// Limpar notificação ativa do store
|
||||
notificacaoAtiva.set(null);
|
||||
}, 5000);
|
||||
} else {
|
||||
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
|
||||
@@ -521,6 +529,14 @@
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Cleanup: limpar timeout quando o effect for desmontado
|
||||
return () => {
|
||||
if (globalNotificationTimeout) {
|
||||
clearTimeout(globalNotificationTimeout);
|
||||
globalNotificationTimeout = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function handleToggle() {
|
||||
@@ -579,6 +595,56 @@
|
||||
maximizarChat();
|
||||
}
|
||||
|
||||
// Handler para duplo clique no botão flutuante - abre e maximiza
|
||||
function handleDoubleClick() {
|
||||
// Marcar que estamos processando um duplo clique
|
||||
isDoubleClicking = true;
|
||||
|
||||
// Se o chat estiver fechado ou minimizado, abrir e maximizar
|
||||
if (!isOpen || isMinimized) {
|
||||
abrirChat();
|
||||
// Aguardar um pouco para garantir que o chat foi aberto antes de maximizar
|
||||
setTimeout(() => {
|
||||
if (position) {
|
||||
// Salvar tamanho e posição atuais antes de maximizar
|
||||
previousSize = { ...windowSize };
|
||||
previousPosition = { ...position };
|
||||
|
||||
// Maximizar completamente
|
||||
const winWidth =
|
||||
windowDimensions.width ||
|
||||
(typeof window !== 'undefined' ? window.innerWidth : DEFAULT_WIDTH);
|
||||
const winHeight =
|
||||
windowDimensions.height ||
|
||||
(typeof window !== 'undefined' ? window.innerHeight : DEFAULT_HEIGHT);
|
||||
|
||||
windowSize = {
|
||||
width: winWidth,
|
||||
height: winHeight
|
||||
};
|
||||
position = {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
isMaximized = true;
|
||||
saveSize();
|
||||
ajustarPosicao();
|
||||
maximizarChat();
|
||||
}
|
||||
// Resetar flag após processar
|
||||
setTimeout(() => {
|
||||
isDoubleClicking = false;
|
||||
}, 300);
|
||||
}, 50);
|
||||
} else {
|
||||
// Se já estiver aberto, apenas maximizar
|
||||
handleMaximize();
|
||||
setTimeout(() => {
|
||||
isDoubleClicking = false;
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Funcionalidade de arrastar
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
|
||||
@@ -615,6 +681,136 @@
|
||||
// Não prevenir default para permitir clique funcionar se não houver movimento
|
||||
}
|
||||
|
||||
// Handlers para gestos touch (swipe)
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
if (!position || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
touchStart = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now()
|
||||
};
|
||||
touchCurrent = { x: touch.clientX, y: touch.clientY };
|
||||
isTouching = true;
|
||||
isDragging = true;
|
||||
dragStart = {
|
||||
x: touch.clientX - position.x,
|
||||
y: touch.clientY - position.y
|
||||
};
|
||||
hasMoved = false;
|
||||
shouldPreventClick = false;
|
||||
document.body.classList.add('dragging');
|
||||
}
|
||||
|
||||
function handleTouchMove(e: TouchEvent) {
|
||||
if (!isTouching || !touchStart || !position || e.touches.length !== 1) return;
|
||||
const touch = e.touches[0];
|
||||
touchCurrent = { x: touch.clientX, y: touch.clientY };
|
||||
|
||||
// Calcular velocidade do swipe
|
||||
const deltaTime = Date.now() - touchStart.time;
|
||||
const deltaX = touch.clientX - touchStart.x;
|
||||
const deltaY = touch.clientY - touchStart.y;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
if (deltaTime > 0) {
|
||||
swipeVelocity = distance / deltaTime; // pixels por ms
|
||||
}
|
||||
|
||||
// Calcular nova posição
|
||||
const newX = touch.clientX - dragStart.x;
|
||||
const newY = touch.clientY - dragStart.y;
|
||||
|
||||
// Verificar se houve movimento significativo
|
||||
const deltaXAbs = Math.abs(newX - position.x);
|
||||
const deltaYAbs = Math.abs(newY - position.y);
|
||||
|
||||
if (deltaXAbs > dragThreshold || deltaYAbs > dragThreshold) {
|
||||
hasMoved = true;
|
||||
shouldPreventClick = true;
|
||||
}
|
||||
|
||||
// Dimensões do widget
|
||||
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
|
||||
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
|
||||
|
||||
const winWidth =
|
||||
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
const winHeight =
|
||||
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||
|
||||
const minX = -(widgetWidth - 100);
|
||||
const maxX = Math.max(0, winWidth - 100);
|
||||
const minY = -(widgetHeight - 100);
|
||||
const maxY = Math.max(0, winHeight - 100);
|
||||
|
||||
position = {
|
||||
x: Math.max(minX, Math.min(newX, maxX)),
|
||||
y: Math.max(minY, Math.min(newY, maxY))
|
||||
};
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
if (!isTouching || !touchStart || !position) return;
|
||||
|
||||
const hadMoved = hasMoved;
|
||||
|
||||
// Aplicar momentum se houver velocidade suficiente
|
||||
if (swipeVelocity > 0.5 && hadMoved) {
|
||||
const deltaX = touchCurrent ? touchCurrent.x - touchStart.x : 0;
|
||||
const deltaY = touchCurrent ? touchCurrent.y - touchStart.y : 0;
|
||||
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
|
||||
|
||||
if (distance > 10) {
|
||||
// Aplicar momentum suave
|
||||
const momentum = Math.min(swipeVelocity * 50, 200); // Limitar momentum
|
||||
const angle = Math.atan2(deltaY, deltaX);
|
||||
|
||||
let momentumX = position.x + Math.cos(angle) * momentum;
|
||||
let momentumY = position.y + Math.sin(angle) * momentum;
|
||||
|
||||
// Limitar dentro dos bounds
|
||||
const widgetWidth = isOpen && !isMinimized ? windowSize.width : 72;
|
||||
const widgetHeight = isOpen && !isMinimized ? windowSize.height : 72;
|
||||
const winWidth =
|
||||
windowDimensions.width || (typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
const winHeight =
|
||||
windowDimensions.height || (typeof window !== 'undefined' ? window.innerHeight : 0);
|
||||
const minX = -(widgetWidth - 100);
|
||||
const maxX = Math.max(0, winWidth - 100);
|
||||
const minY = -(widgetHeight - 100);
|
||||
const maxY = Math.max(0, winHeight - 100);
|
||||
|
||||
momentumX = Math.max(minX, Math.min(momentumX, maxX));
|
||||
momentumY = Math.max(minY, Math.min(momentumY, maxY));
|
||||
|
||||
position = { x: momentumX, y: momentumY };
|
||||
isAnimating = true;
|
||||
|
||||
setTimeout(() => {
|
||||
isAnimating = false;
|
||||
ajustarPosicao();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
ajustarPosicao();
|
||||
}
|
||||
|
||||
isDragging = false;
|
||||
isTouching = false;
|
||||
touchStart = null;
|
||||
touchCurrent = null;
|
||||
swipeVelocity = 0;
|
||||
document.body.classList.remove('dragging');
|
||||
|
||||
setTimeout(() => {
|
||||
hasMoved = false;
|
||||
shouldPreventClick = false;
|
||||
}, 100);
|
||||
|
||||
savePosition();
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (isResizing) {
|
||||
handleResizeMove(e);
|
||||
@@ -749,10 +945,14 @@
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
@@ -791,9 +991,16 @@
|
||||
onmouseup={(e) => {
|
||||
handleMouseUp(e);
|
||||
}}
|
||||
ontouchstart={handleTouchStart}
|
||||
onclick={(e) => {
|
||||
// Prevenir clique simples se estamos processando um duplo clique
|
||||
if (isDoubleClicking) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
// Só executar toggle se não houve movimento durante o arrastar
|
||||
if (!shouldPreventClick && !hasMoved) {
|
||||
if (!shouldPreventClick && !hasMoved && !isTouching) {
|
||||
handleToggle();
|
||||
} else {
|
||||
// Prevenir clique se houve movimento
|
||||
@@ -802,37 +1009,54 @@
|
||||
shouldPreventClick = false; // Resetar após prevenir
|
||||
}
|
||||
}}
|
||||
aria-label="Abrir chat"
|
||||
ondblclick={(e) => {
|
||||
// Prevenir que o clique simples seja executado após o duplo clique
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Executar maximização apenas se não houve movimento
|
||||
if (!shouldPreventClick && !hasMoved && !isTouching) {
|
||||
handleDoubleClick();
|
||||
}
|
||||
}}
|
||||
aria-label="Abrir chat (duplo clique para maximizar)"
|
||||
>
|
||||
<!-- Anel de brilho rotativo -->
|
||||
<!-- Anel de brilho rotativo melhorado com múltiplas camadas -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
||||
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.3) 50%, transparent 100%); animation: rotate 3s linear infinite;"
|
||||
style="background: conic-gradient(from 0deg, transparent 0%, rgba(255,255,255,0.4) 25%, rgba(255,255,255,0.6) 50%, rgba(255,255,255,0.4) 75%, transparent 100%); animation: rotate 3s linear infinite; transform-origin: center;"
|
||||
></div>
|
||||
|
||||
<!-- Ondas de pulso -->
|
||||
<!-- Segunda camada para efeito de profundidade -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-full"
|
||||
style="animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
|
||||
class="absolute inset-0 cursor-pointer rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60"
|
||||
style="background: conic-gradient(from 180deg, transparent 0%, rgba(255,255,255,0.2) 30%, transparent 60%); animation: rotate 4s linear infinite reverse; transform-origin: center;"
|
||||
onclick={(e) => {
|
||||
// Propagar o clique para o elemento pai
|
||||
e.stopPropagation();
|
||||
if (!isDoubleClicking && !shouldPreventClick && !hasMoved && !isTouching) {
|
||||
handleToggle();
|
||||
}
|
||||
}}
|
||||
ondblclick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (!shouldPreventClick && !hasMoved && !isTouching) {
|
||||
handleDoubleClick();
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
<!-- Efeito de brilho pulsante durante arrasto -->
|
||||
{#if isDragging || isTouching}
|
||||
<div
|
||||
class="absolute inset-0 animate-pulse rounded-full opacity-30"
|
||||
style="background: radial-gradient(circle at center, rgba(255,255,255,0.4) 0%, transparent 70%); animation: pulse-glow 1.5s ease-in-out infinite;"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Ícone de chat moderno com efeito 3D -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="relative z-10 h-7 w-7 text-white transition-all duration-500 group-hover:scale-110"
|
||||
<MessageSquare
|
||||
class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<circle cx="9" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="12" cy="10" r="1" fill="currentColor" />
|
||||
<circle cx="15" cy="10" r="1" fill="currentColor" />
|
||||
</svg>
|
||||
strokeWidth={2}
|
||||
/>
|
||||
|
||||
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
|
||||
{#if count?.data && count.data > 0}
|
||||
@@ -925,26 +1149,16 @@
|
||||
{#if avatarUrlDoUsuario()}
|
||||
<img
|
||||
src={avatarUrlDoUsuario()}
|
||||
alt={currentUser?.data?.nome || 'Usuário'}
|
||||
alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Fallback: ícone de chat genérico -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<MessageSquare
|
||||
class="h-5 w-5"
|
||||
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
|
||||
>
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<line x1="9" y1="10" x2="15" y2="10" />
|
||||
<line x1="9" y1="14" x2="13" y2="14" />
|
||||
</svg>
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<span
|
||||
@@ -966,19 +1180,11 @@
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
||||
></div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<Minus
|
||||
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Botão maximizar MODERNO -->
|
||||
@@ -992,21 +1198,11 @@
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
||||
></div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<Maximize2
|
||||
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||
>
|
||||
<path
|
||||
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Botão fechar MODERNO -->
|
||||
@@ -1020,20 +1216,11 @@
|
||||
<div
|
||||
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30"
|
||||
></div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<X
|
||||
class="relative z-10 h-5 w-5 transition-all duration-300 group-hover:scale-110 group-hover:rotate-90"
|
||||
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1128,6 +1315,8 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Indicador de Conexão -->
|
||||
|
||||
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
|
||||
{#if showGlobalNotificationPopup && globalNotificationMessage}
|
||||
{@const notificationMsg = globalNotificationMessage}
|
||||
@@ -1168,20 +1357,7 @@
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="bg-primary/20 flex h-10 w-10 shrink-0 items-center justify-center rounded-full">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="text-primary h-5 w-5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M14.857 17.082a23.848 23.848 0 0 0 5.454-1.31A8.967 8.967 0 0 1 18 9.75V9A6 6 0 0 0 6 9v.75a8.967 8.967 0 0 1-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 0 1-5.714 0m5.714 0a3 3 0 1 1-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
<Bell class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content mb-1 text-sm font-semibold">
|
||||
@@ -1205,16 +1381,7 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
class="h-4 w-4"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<X class="h-4 w-4" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1260,7 +1427,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Rotação para anel de brilho */
|
||||
/* Rotação para anel de brilho - suavizada */
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
@@ -1270,6 +1437,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Efeito de pulso de brilho durante arrasto */
|
||||
@keyframes pulse-glow {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
/* Efeito shimmer para o header */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { voltarParaLista } from '$lib/stores/chatStore';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import MessageList from './MessageList.svelte';
|
||||
import MessageInput from './MessageInput.svelte';
|
||||
@@ -9,14 +7,37 @@
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import ScheduleMessageModal from './ScheduleMessageModal.svelte';
|
||||
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
|
||||
import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||
import CallWindow from '../call/CallWindow.svelte';
|
||||
import ErrorModal from '../ErrorModal.svelte';
|
||||
import E2EManagementModal from './E2EManagementModal.svelte';
|
||||
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { browser } from '$app/environment';
|
||||
import { traduzirErro } from '$lib/utils/erroHelpers';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Bell,
|
||||
Clock,
|
||||
LogOut,
|
||||
Users,
|
||||
Phone,
|
||||
Video,
|
||||
Search,
|
||||
Lock,
|
||||
MoreVertical,
|
||||
XCircle,
|
||||
X
|
||||
} from 'lucide-svelte';
|
||||
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||
import { voltarParaLista } from '$lib/stores/chatStore';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
|
||||
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
conversaId: string;
|
||||
}
|
||||
|
||||
let { conversaId }: Props = $props();
|
||||
const { conversaId }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
@@ -26,12 +47,33 @@
|
||||
let showSalaManager = $state(false);
|
||||
let showAdminMenu = $state(false);
|
||||
let showNotificacaoModal = $state(false);
|
||||
let showE2EModal = $state(false);
|
||||
let iniciandoChamada = $state(false);
|
||||
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
|
||||
let showSearch = $state(false);
|
||||
let searchQuery = $state('');
|
||||
let searchResults = $state<Array<unknown | undefined>>([]);
|
||||
let searching = $state(false);
|
||||
let selectedSearchResult = $state<number>(-1);
|
||||
let showErrorModal = $state(false);
|
||||
let errorTitle = $state('Erro');
|
||||
let errorMessage = $state('');
|
||||
let errorInstructions = $state<string | undefined>(undefined);
|
||||
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
let chamadaAtual = $derived(chamadaAtivaQuery?.data);
|
||||
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
|
||||
// Verificar se a conversa tem criptografia E2E habilitada
|
||||
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, {
|
||||
conversaId: conversaId as Id<'conversas'>
|
||||
});
|
||||
|
||||
const conversa = $derived(() => {
|
||||
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
|
||||
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
|
||||
@@ -59,10 +101,7 @@
|
||||
const c = conversa();
|
||||
if (!c) return '💬';
|
||||
if (c.tipo === 'grupo') {
|
||||
return c.avatar || '👥';
|
||||
}
|
||||
if (c.outroUsuario?.avatar) {
|
||||
return c.outroUsuario.avatar;
|
||||
return '👥';
|
||||
}
|
||||
return '👤';
|
||||
}
|
||||
@@ -115,6 +154,112 @@
|
||||
alert(errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// Funções para chamadas
|
||||
async function iniciarChamada(
|
||||
tipo: 'audio' | 'video',
|
||||
abrirEmNovaJanela: boolean = false
|
||||
): Promise<void> {
|
||||
if (chamadaAtual) {
|
||||
errorTitle = 'Chamada já em andamento';
|
||||
errorMessage =
|
||||
'Já existe uma chamada ativa nesta conversa. Você precisa finalizar a chamada atual antes de iniciar uma nova.';
|
||||
errorInstructions = 'Finalize a chamada atual e tente novamente.';
|
||||
errorDetails = undefined;
|
||||
showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
iniciandoChamada = true;
|
||||
const chamadaId = await client.mutation(api.chamadas.criarChamada, {
|
||||
conversaId: conversaId as Id<'conversas'>,
|
||||
tipo,
|
||||
audioHabilitado: true,
|
||||
videoHabilitado: tipo === 'video'
|
||||
});
|
||||
|
||||
// Se deve abrir em nova janela
|
||||
if (abrirEmNovaJanela && browser) {
|
||||
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import(
|
||||
'$lib/utils/callWindowManager'
|
||||
);
|
||||
|
||||
if (!verificarSuportePopup()) {
|
||||
errorTitle = 'Popups bloqueados';
|
||||
errorMessage =
|
||||
'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.';
|
||||
errorInstructions = 'Verifique as configurações do seu navegador para permitir popups.';
|
||||
showErrorModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Buscar informações da chamada para obter roomName
|
||||
const chamadaInfo = await client.query(api.chamadas.obterChamada, { chamadaId });
|
||||
|
||||
if (!chamadaInfo) {
|
||||
throw new Error('Chamada não encontrada');
|
||||
}
|
||||
|
||||
const meuPerfil = await client.query(api.auth.getCurrentUser, {});
|
||||
const ehAnfitriao = chamadaInfo.criadoPor === meuPerfil?._id;
|
||||
|
||||
// Abrir em popup
|
||||
const popup = abrirCallWindowEmPopup({
|
||||
chamadaId: chamadaId as string,
|
||||
conversaId: conversaId as string,
|
||||
tipo,
|
||||
roomName: chamadaInfo.roomName,
|
||||
ehAnfitriao
|
||||
});
|
||||
|
||||
if (!popup) {
|
||||
throw new Error('Não foi possível abrir a janela de chamada');
|
||||
}
|
||||
|
||||
// Não definir chamadaAtiva aqui, pois será gerenciada pela janela popup
|
||||
return;
|
||||
}
|
||||
|
||||
chamadaAtiva = chamadaId;
|
||||
} catch (error) {
|
||||
console.error('Erro ao iniciar chamada:', error);
|
||||
|
||||
// Traduzir erro técnico para mensagem amigável
|
||||
const erroTraduzido = traduzirErro(error);
|
||||
|
||||
errorTitle = erroTraduzido.titulo;
|
||||
errorMessage = erroTraduzido.mensagem;
|
||||
errorInstructions = erroTraduzido.instrucoes;
|
||||
|
||||
// Apenas mostrar detalhes técnicos se solicitado e disponível
|
||||
errorDetails =
|
||||
erroTraduzido.mostrarDetalhesTecnicos && erroTraduzido.detalhesTecnicos
|
||||
? erroTraduzido.detalhesTecnicos
|
||||
: undefined;
|
||||
|
||||
showErrorModal = true;
|
||||
} finally {
|
||||
iniciandoChamada = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fecharErrorModal(): void {
|
||||
showErrorModal = false;
|
||||
errorMessage = '';
|
||||
errorInstructions = undefined;
|
||||
errorDetails = undefined;
|
||||
}
|
||||
|
||||
function fecharChamada(): void {
|
||||
chamadaAtiva = null;
|
||||
}
|
||||
|
||||
// Verificar se usuário é anfitrião da chamada atual
|
||||
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
||||
let souAnfitriao = $derived(
|
||||
chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
|
||||
@@ -138,10 +283,10 @@
|
||||
<div class="relative shrink-0">
|
||||
{#if conversa() && conversa()?.tipo === 'individual' && conversa()?.outroUsuario}
|
||||
<UserAvatar
|
||||
avatar={conversa()?.outroUsuario?.avatar}
|
||||
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
|
||||
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
|
||||
size="md"
|
||||
userId={conversa()?.outroUsuario?._id}
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
|
||||
@@ -156,9 +301,29 @@
|
||||
</div>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<!-- Nome da conversa com indicador de criptografia E2E -->
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-base-content truncate font-semibold">
|
||||
{getNomeConversa()}
|
||||
</p>
|
||||
{#if temCriptografiaE2E?.data}
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showE2EModal = true;
|
||||
}}
|
||||
title="Gerenciar criptografia end-to-end (E2E)"
|
||||
aria-label="Gerenciar criptografia E2E"
|
||||
>
|
||||
<Lock
|
||||
class="text-success hover:text-success/80 h-4 w-4 transition-colors"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if getStatusMensagem()}
|
||||
<p class="text-base-content/60 truncate text-xs">
|
||||
{getStatusMensagem()}
|
||||
@@ -181,7 +346,7 @@
|
||||
{conversa()?.participantesInfo?.length || 0}
|
||||
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
|
||||
</p>
|
||||
{#if conversa()?.participantesInfo && conversa()?.participantesInfo.length > 0}
|
||||
{#if conversa()?.participantesInfo && conversa()?.participantesInfo?.length > 0}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex -space-x-2">
|
||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||
@@ -195,18 +360,12 @@
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else if participante.avatar}
|
||||
<img
|
||||
src={getAvatarUrl(participante.avatar)}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
src={getAvatarUrl(participante.nome)}
|
||||
alt={participante.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
<div
|
||||
class="bg-base-200 flex h-full w-full items-center justify-center text-xs font-semibold"
|
||||
>
|
||||
{participante.nome.substring(0, 2).toUpperCase()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
@@ -233,6 +392,128 @@
|
||||
|
||||
<!-- Botões de ação -->
|
||||
<div class="flex items-center gap-1">
|
||||
<!-- Botão de Busca -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showSearch = !showSearch;
|
||||
if (!showSearch) {
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
}
|
||||
}}
|
||||
aria-label="Buscar mensagens"
|
||||
title="Buscar mensagens"
|
||||
aria-expanded={showSearch}
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
|
||||
></div>
|
||||
<Search
|
||||
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Botões de Chamada -->
|
||||
{#if !chamadaAtual && !chamadaAtiva}
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-primary"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
iniciarChamada('audio', false);
|
||||
}}
|
||||
disabled={iniciandoChamada}
|
||||
aria-label="Ligação de áudio"
|
||||
title="Iniciar ligação de áudio"
|
||||
>
|
||||
<Phone class="h-5 w-5 text-white" strokeWidth={2} />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
iniciarChamada('audio', false);
|
||||
}}
|
||||
disabled={iniciandoChamada}
|
||||
>
|
||||
<Phone class="h-4 w-4" />
|
||||
Áudio (nesta janela)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
iniciarChamada('audio', true);
|
||||
}}
|
||||
disabled={iniciandoChamada}
|
||||
>
|
||||
<Phone class="h-4 w-4" />
|
||||
Áudio (nova janela)
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="dropdown dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-primary"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
iniciarChamada('video', false);
|
||||
}}
|
||||
disabled={iniciandoChamada}
|
||||
aria-label="Ligação de vídeo"
|
||||
title="Iniciar ligação de vídeo"
|
||||
>
|
||||
<Video class="h-5 w-5 text-white" strokeWidth={2} />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
iniciarChamada('video', false);
|
||||
}}
|
||||
disabled={iniciandoChamada}
|
||||
>
|
||||
<Video class="h-4 w-4" />
|
||||
Vídeo (nesta janela)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
iniciarChamada('video', true);
|
||||
}}
|
||||
disabled={iniciandoChamada}
|
||||
>
|
||||
<Video class="h-4 w-4" />
|
||||
Vídeo (nova janela)
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Sair (apenas para grupos e salas de reunião) -->
|
||||
{#if conversa() && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
|
||||
<button
|
||||
@@ -352,6 +633,27 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Botão Gerenciar E2E -->
|
||||
<button
|
||||
type="button"
|
||||
class="group relative flex h-9 w-9 items-center justify-center overflow-hidden rounded-lg transition-all duration-300"
|
||||
style="background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2);"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
showE2EModal = true;
|
||||
}}
|
||||
aria-label="Gerenciar criptografia E2E"
|
||||
title="Gerenciar criptografia end-to-end"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-green-500/0 transition-colors duration-300 group-hover:bg-green-500/10"
|
||||
></div>
|
||||
<Lock
|
||||
class="relative z-10 h-5 w-5 text-green-500 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<!-- Botão Agendar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
@@ -372,6 +674,113 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Barra de Busca (quando ativa) -->
|
||||
{#if showSearch}
|
||||
<div
|
||||
class="border-base-300 bg-base-200 flex items-center gap-2 border-b px-4 py-2"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Search class="text-base-content/50 h-4 w-4" strokeWidth={2} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar mensagens nesta conversa..."
|
||||
class="input input-sm input-bordered flex-1"
|
||||
bind:value={searchQuery}
|
||||
onkeydown={handleSearchKeyDown}
|
||||
aria-label="Buscar mensagens"
|
||||
aria-describedby="search-results-info"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={() => {
|
||||
showSearch = false;
|
||||
searchQuery = '';
|
||||
searchResults = [];
|
||||
}}
|
||||
aria-label="Fechar busca"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Resultados da Busca -->
|
||||
{#if searchQuery.trim().length >= 2}
|
||||
<div
|
||||
class="border-base-300 bg-base-200 max-h-64 overflow-y-auto border-b"
|
||||
role="listbox"
|
||||
aria-label="Resultados da busca"
|
||||
id="search-results"
|
||||
>
|
||||
{#if searching}
|
||||
<div class="flex items-center justify-center p-4">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="text-base-content/50 ml-2 text-sm">Buscando...</span>
|
||||
</div>
|
||||
{:else if searchResults.length > 0}
|
||||
<p id="search-results-info" class="sr-only">
|
||||
{searchResults.length} resultado{searchResults.length !== 1 ? 's' : ''} encontrado{searchResults.length !==
|
||||
1
|
||||
? 's'
|
||||
: ''}
|
||||
</p>
|
||||
{#each searchResults as resultado, index (resultado._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-base-300 flex w-full items-start gap-3 px-4 py-3 text-left transition-colors {index ===
|
||||
selectedSearchResult
|
||||
? 'bg-primary/10'
|
||||
: ''}"
|
||||
onclick={() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('scrollToMessage', {
|
||||
detail: { mensagemId: resultado._id }
|
||||
})
|
||||
);
|
||||
showSearch = false;
|
||||
searchQuery = '';
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={index === selectedSearchResult}
|
||||
aria-label="Mensagem de {resultado.remetente?.nome || 'Usuário'}"
|
||||
>
|
||||
<div
|
||||
class="bg-primary/20 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
|
||||
>
|
||||
{#if resultado.remetente?.fotoPerfilUrl}
|
||||
<img
|
||||
src={resultado.remetente.fotoPerfilUrl}
|
||||
alt={resultado.remetente.nome}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold">
|
||||
{resultado.remetente?.nome?.charAt(0).toUpperCase() || 'U'}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content mb-1 text-xs font-semibold">
|
||||
{resultado.remetente?.nome || 'Usuário'}
|
||||
</p>
|
||||
<p class="text-base-content/70 line-clamp-2 text-xs">
|
||||
{resultado.conteudo}
|
||||
</p>
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{new Date(resultado.enviadaEm).toLocaleString('pt-BR')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else if searchQuery.trim().length >= 2}
|
||||
<div class="p-4 text-center">
|
||||
<p class="text-base-content/50 text-sm">Nenhuma mensagem encontrada</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Mensagens -->
|
||||
<div class="min-h-0 flex-1 overflow-hidden">
|
||||
<MessageList conversaId={conversaId as Id<'conversas'>} />
|
||||
@@ -389,6 +798,14 @@
|
||||
conversaId={conversaId as Id<'conversas'>}
|
||||
onClose={() => (showScheduleModal = false)}
|
||||
/>
|
||||
|
||||
<!-- Modal de Gerenciamento E2E -->
|
||||
{#if showE2EModal}
|
||||
<E2EManagementModal
|
||||
conversaId={conversaId as Id<'conversas'>}
|
||||
onClose={() => (showE2EModal = false)}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Gerenciamento de Sala -->
|
||||
@@ -400,6 +817,20 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Janela de Chamada -->
|
||||
{#if browser && chamadaAtiva && chamadaAtual}
|
||||
<div class="pointer-events-none fixed inset-0 z-[9999]">
|
||||
<CallWindow
|
||||
chamadaId={chamadaAtiva}
|
||||
conversaId={conversaId as Id<'conversas'>}
|
||||
tipo={chamadaAtual.tipo}
|
||||
roomName={chamadaAtual.roomName}
|
||||
ehAnfitriao={souAnfitriao}
|
||||
onClose={fecharChamada}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Enviar Notificação -->
|
||||
{#if showNotificacaoModal && conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
|
||||
<dialog
|
||||
@@ -491,3 +922,12 @@
|
||||
</form>
|
||||
</dialog>
|
||||
{/if}
|
||||
|
||||
<!-- Modal de Erro -->
|
||||
<ErrorModal
|
||||
open={showErrorModal}
|
||||
title={errorTitle}
|
||||
message={errorMessage}
|
||||
details={errorInstructions || errorDetails}
|
||||
onClose={fecharErrorModal}
|
||||
/>
|
||||
|
||||
90
apps/web/src/lib/components/chat/ConnectionIndicator.svelte
Normal file
90
apps/web/src/lib/components/chat/ConnectionIndicator.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { Wifi, WifiOff, AlertCircle } from 'lucide-svelte';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let isOnline = $state(true);
|
||||
let convexConnected = $state(true);
|
||||
let showIndicator = $state(false);
|
||||
|
||||
// Detectar status de conexão com internet
|
||||
function updateOnlineStatus() {
|
||||
isOnline = navigator.onLine;
|
||||
showIndicator = !isOnline || !convexConnected;
|
||||
}
|
||||
|
||||
// Detectar status de conexão com Convex
|
||||
function updateConvexStatus() {
|
||||
// Verificar se o client está conectado
|
||||
// O Convex client expõe o status de conexão
|
||||
const connectionState = (client as any).connectionState?.();
|
||||
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
|
||||
showIndicator = !isOnline || !convexConnected;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Verificar status inicial
|
||||
updateOnlineStatus();
|
||||
updateConvexStatus();
|
||||
|
||||
// Listeners para mudanças de conexão
|
||||
window.addEventListener('online', updateOnlineStatus);
|
||||
window.addEventListener('offline', updateOnlineStatus);
|
||||
|
||||
// Verificar status do Convex periodicamente
|
||||
const interval = setInterval(() => {
|
||||
updateConvexStatus();
|
||||
}, 5000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', updateOnlineStatus);
|
||||
window.removeEventListener('offline', updateOnlineStatus);
|
||||
clearInterval(interval);
|
||||
};
|
||||
});
|
||||
|
||||
// Observar mudanças no client do Convex
|
||||
$effect(() => {
|
||||
// Tentar acessar o estado de conexão do Convex
|
||||
try {
|
||||
const connectionState = (client as any).connectionState?.();
|
||||
if (connectionState !== undefined) {
|
||||
convexConnected = connectionState === 'Connected' || connectionState === 'Connecting';
|
||||
showIndicator = !isOnline || !convexConnected;
|
||||
}
|
||||
} catch {
|
||||
// Se não conseguir acessar, assumir conectado
|
||||
convexConnected = true;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if showIndicator}
|
||||
<div
|
||||
class="fixed bottom-4 left-4 z-[99998] flex items-center gap-2 rounded-lg px-3 py-2 shadow-lg transition-all"
|
||||
class:bg-error={!isOnline || !convexConnected}
|
||||
class:bg-warning={isOnline && !convexConnected}
|
||||
class:text-white={!isOnline || !convexConnected}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={!isOnline
|
||||
? 'Sem conexão com a internet'
|
||||
: !convexConnected
|
||||
? 'Reconectando ao servidor'
|
||||
: 'Conectado'}
|
||||
>
|
||||
{#if !isOnline}
|
||||
<WifiOff class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">Sem conexão</span>
|
||||
{:else if !convexConnected}
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">Reconectando...</span>
|
||||
{:else}
|
||||
<Wifi class="h-4 w-4" />
|
||||
<span class="text-sm font-medium">Conectado</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
267
apps/web/src/lib/components/chat/E2EManagementModal.svelte
Normal file
267
apps/web/src/lib/components/chat/E2EManagementModal.svelte
Normal file
@@ -0,0 +1,267 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { Lock, X, RefreshCw, Shield, AlertTriangle, CheckCircle } from 'lucide-svelte';
|
||||
import {
|
||||
generateEncryptionKey,
|
||||
exportKey,
|
||||
storeEncryptionKey,
|
||||
hasEncryptionKey,
|
||||
removeStoredEncryptionKey
|
||||
} from '$lib/utils/e2eEncryption';
|
||||
import { armazenarChaveCriptografia, removerChaveCriptografia } from '$lib/stores/chatStore';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
|
||||
const chaveAtual = useQuery(api.chat.obterChaveCriptografia, { conversaId });
|
||||
const conversa = useQuery(api.chat.listarConversas, {});
|
||||
|
||||
let ativando = $state(false);
|
||||
let regenerando = $state(false);
|
||||
let desativando = $state(false);
|
||||
|
||||
// Obter informações da conversa
|
||||
const conversaInfo = $derived(() => {
|
||||
if (!conversa?.data || !Array.isArray(conversa.data)) return null;
|
||||
return conversa.data.find((c: { _id: string }) => c._id === conversaId) || null;
|
||||
});
|
||||
|
||||
async function ativarE2E() {
|
||||
if (!confirm('Deseja ativar criptografia end-to-end para esta conversa?\n\nTodas as mensagens futuras serão criptografadas.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ativando = true;
|
||||
|
||||
// Gerar nova chave de criptografia
|
||||
const encryptionKey = await generateEncryptionKey();
|
||||
const keyData = await exportKey(encryptionKey.key);
|
||||
|
||||
// Armazenar localmente
|
||||
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
|
||||
armazenarChaveCriptografia(conversaId, encryptionKey.key);
|
||||
|
||||
// Compartilhar chave com outros participantes
|
||||
await client.mutation(api.chat.compartilharChaveCriptografia, {
|
||||
conversaId,
|
||||
chaveCompartilhada: keyData, // Em produção, isso deveria ser criptografado com chave pública de cada participante
|
||||
keyId: encryptionKey.keyId
|
||||
});
|
||||
|
||||
alert('Criptografia E2E ativada com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao ativar E2E:', error);
|
||||
alert('Erro ao ativar criptografia E2E');
|
||||
} finally {
|
||||
ativando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerarChave() {
|
||||
if (!confirm('Deseja regenerar a chave de criptografia?\n\nAs mensagens antigas continuarão legíveis, mas novas mensagens usarão a nova chave.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
regenerando = true;
|
||||
|
||||
// Gerar nova chave
|
||||
const encryptionKey = await generateEncryptionKey();
|
||||
const keyData = await exportKey(encryptionKey.key);
|
||||
|
||||
// Atualizar chave localmente
|
||||
storeEncryptionKey(conversaId, keyData, encryptionKey.keyId);
|
||||
armazenarChaveCriptografia(conversaId, encryptionKey.key);
|
||||
|
||||
// Compartilhar nova chave (desativa chaves antigas automaticamente)
|
||||
await client.mutation(api.chat.compartilharChaveCriptografia, {
|
||||
conversaId,
|
||||
chaveCompartilhada: keyData,
|
||||
keyId: encryptionKey.keyId
|
||||
});
|
||||
|
||||
alert('Chave regenerada com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro ao regenerar chave:', error);
|
||||
alert('Erro ao regenerar chave');
|
||||
} finally {
|
||||
regenerando = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function desativarE2E() {
|
||||
if (!confirm('Deseja desativar criptografia end-to-end para esta conversa?\n\nAs mensagens antigas continuarão criptografadas, mas novas mensagens não serão mais criptografadas.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
desativando = true;
|
||||
|
||||
// Remover chave localmente
|
||||
removeStoredEncryptionKey(conversaId);
|
||||
removerChaveCriptografia(conversaId);
|
||||
|
||||
// Desativar chave no servidor (marcar como inativa)
|
||||
// Nota: Não removemos a chave do servidor, apenas a marcamos como inativa
|
||||
// Isso permite que mensagens antigas ainda possam ser descriptografadas
|
||||
if (chaveAtual?.data) {
|
||||
// A mutation compartilharChaveCriptografia já desativa chaves antigas
|
||||
// Mas precisamos de uma mutation específica para desativar completamente
|
||||
// Por enquanto, vamos apenas remover localmente
|
||||
alert('Criptografia E2E desativada localmente. As mensagens antigas ainda podem ser descriptografadas se você tiver a chave.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao desativar E2E:', error);
|
||||
alert('Erro ao desativar criptografia E2E');
|
||||
} finally {
|
||||
desativando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatarData(timestamp: number): string {
|
||||
try {
|
||||
return format(new Date(timestamp), "dd/MM/yyyy 'às' HH:mm", { locale: ptBR });
|
||||
} catch {
|
||||
return 'Data inválida';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="modal modal-open"
|
||||
role="dialog"
|
||||
aria-labelledby="modal-title"
|
||||
aria-modal="true"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="modal-box max-w-2xl"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||
<h2 id="modal-title" class="flex items-center gap-2 text-xl font-bold">
|
||||
<Shield class="text-primary h-5 w-5" />
|
||||
Criptografia End-to-End (E2E)
|
||||
</h2>
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={onClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 space-y-6 overflow-y-auto p-6">
|
||||
<!-- Status da Criptografia -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if temCriptografiaE2E?.data}
|
||||
<CheckCircle class="text-success h-6 w-6 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="card-title text-lg text-success">Criptografia E2E Ativa</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Suas mensagens estão protegidas com criptografia end-to-end
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<AlertTriangle class="text-warning h-6 w-6 shrink-0" />
|
||||
<div class="flex-1">
|
||||
<h3 class="card-title text-lg text-warning">Criptografia E2E Desativada</h3>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Suas mensagens não estão criptografadas
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações da Chave -->
|
||||
{#if temCriptografiaE2E?.data && chaveAtual?.data}
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title text-lg">Informações da Chave</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">ID da Chave:</span>
|
||||
<span class="font-mono text-xs">{chaveAtual.data.keyId.substring(0, 16)}...</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Criada em:</span>
|
||||
<span>{formatarData(chaveAtual.data.criadoEm)}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-base-content/70">Chave local:</span>
|
||||
<span class="text-success">
|
||||
{hasEncryptionKey(conversaId) ? '✓ Armazenada' : '✗ Não encontrada'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Informações sobre E2E -->
|
||||
<div class="alert alert-info">
|
||||
<Lock class="h-5 w-5" />
|
||||
<div class="text-sm">
|
||||
<p class="font-semibold">Como funciona a criptografia E2E?</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-xs">
|
||||
<li>Suas mensagens são criptografadas no seu dispositivo antes de serem enviadas</li>
|
||||
<li>Apenas você e os participantes da conversa podem descriptografar as mensagens</li>
|
||||
<li>O servidor não consegue ler o conteúdo das mensagens criptografadas</li>
|
||||
<li>Mensagens antigas continuam legíveis mesmo após regenerar a chave</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ações -->
|
||||
<div class="flex flex-col gap-3">
|
||||
{#if temCriptografiaE2E?.data}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-warning"
|
||||
onclick={regenerarChave}
|
||||
disabled={regenerando || ativando || desativando}
|
||||
>
|
||||
<RefreshCw class="h-4 w-4 {regenerando ? 'animate-spin' : ''}" />
|
||||
{regenerando ? 'Regenerando...' : 'Regenerar Chave'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-error"
|
||||
onclick={desativarE2E}
|
||||
disabled={regenerando || ativando || desativando}
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
{desativando ? 'Desativando...' : 'Desativar E2E'}
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onclick={ativarE2E}
|
||||
disabled={regenerando || ativando || desativando}
|
||||
>
|
||||
<Lock class="h-4 w-4" />
|
||||
{ativando ? 'Ativando...' : 'Ativar Criptografia E2E'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient, useQuery } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
||||
import { onMount } from "svelte";
|
||||
import { Paperclip, Smile, Send } from "lucide-svelte";
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { onMount } from 'svelte';
|
||||
import { Paperclip, Smile, Send } from 'lucide-svelte';
|
||||
import {
|
||||
encryptMessage,
|
||||
encryptFile,
|
||||
loadEncryptionKey,
|
||||
storeEncryptionKey,
|
||||
exportKey,
|
||||
type EncryptedMessage
|
||||
} from '$lib/utils/e2eEncryption';
|
||||
import { obterChaveCriptografia, armazenarChaveCriptografia } from '$lib/stores/chatStore';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<"conversas">;
|
||||
conversaId: Id<'conversas'>;
|
||||
}
|
||||
|
||||
type ParticipanteInfo = {
|
||||
_id: Id<"usuarios">;
|
||||
_id: Id<'usuarios'>;
|
||||
nome: string;
|
||||
email?: string;
|
||||
fotoPerfilUrl?: string;
|
||||
@@ -18,8 +27,8 @@
|
||||
};
|
||||
|
||||
type ConversaComParticipantes = {
|
||||
_id: Id<"conversas">;
|
||||
tipo: "individual" | "grupo" | "sala_reuniao";
|
||||
_id: Id<'conversas'>;
|
||||
tipo: 'individual' | 'grupo' | 'sala_reuniao';
|
||||
participantesInfo?: ParticipanteInfo[];
|
||||
};
|
||||
|
||||
@@ -28,73 +37,110 @@
|
||||
const client = useConvexClient();
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
|
||||
let mensagem = $state("");
|
||||
// Verificar se a conversa tem criptografia E2E habilitada
|
||||
const temCriptografiaE2E = useQuery(api.chat.verificarCriptografiaE2E, { conversaId });
|
||||
|
||||
// Constantes de validação
|
||||
const MAX_MENSAGEM_LENGTH = 5000; // Limite de caracteres por mensagem
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
// Tipos de arquivo permitidos
|
||||
const TIPOS_PERMITIDOS = {
|
||||
imagens: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'],
|
||||
documentos: [
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain',
|
||||
'text/csv'
|
||||
],
|
||||
arquivos: [
|
||||
'application/zip',
|
||||
'application/x-rar-compressed',
|
||||
'application/x-7z-compressed',
|
||||
'application/x-tar',
|
||||
'application/gzip'
|
||||
]
|
||||
};
|
||||
const TODOS_TIPOS_PERMITIDOS = [
|
||||
...TIPOS_PERMITIDOS.imagens,
|
||||
...TIPOS_PERMITIDOS.documentos,
|
||||
...TIPOS_PERMITIDOS.arquivos
|
||||
];
|
||||
|
||||
let mensagem = $state('');
|
||||
let textarea: HTMLTextAreaElement;
|
||||
let enviando = $state(false);
|
||||
let uploadingFile = $state(false);
|
||||
let uploadProgress = $state(0); // Progresso do upload (0-100)
|
||||
let digitacaoTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let showEmojiPicker = $state(false);
|
||||
let mensagemRespondendo: {
|
||||
id: Id<"mensagens">;
|
||||
id: Id<'mensagens'>;
|
||||
conteudo: string;
|
||||
remetente: string;
|
||||
} | null = $state(null);
|
||||
let showMentionsDropdown = $state(false);
|
||||
let mentionQuery = $state("");
|
||||
let mentionQuery = $state('');
|
||||
let mentionStartPos = $state(0);
|
||||
let selectedMentionIndex = $state(0); // Índice do participante selecionado no dropdown
|
||||
let mensagemMuitoLonga = $state(false);
|
||||
|
||||
// Emojis mais usados
|
||||
const emojis = [
|
||||
"😀",
|
||||
"😃",
|
||||
"😄",
|
||||
"😁",
|
||||
"😅",
|
||||
"😂",
|
||||
"🤣",
|
||||
"😊",
|
||||
"😇",
|
||||
"🙂",
|
||||
"🙃",
|
||||
"😉",
|
||||
"😌",
|
||||
"😍",
|
||||
"🥰",
|
||||
"😘",
|
||||
"😗",
|
||||
"😙",
|
||||
"😚",
|
||||
"😋",
|
||||
"😛",
|
||||
"😝",
|
||||
"😜",
|
||||
"🤪",
|
||||
"🤨",
|
||||
"🧐",
|
||||
"🤓",
|
||||
"😎",
|
||||
"🥳",
|
||||
"😏",
|
||||
"👍",
|
||||
"👎",
|
||||
"👏",
|
||||
"🙌",
|
||||
"🤝",
|
||||
"🙏",
|
||||
"💪",
|
||||
"✨",
|
||||
"🎉",
|
||||
"🎊",
|
||||
"❤️",
|
||||
"💙",
|
||||
"💚",
|
||||
"💛",
|
||||
"🧡",
|
||||
"💜",
|
||||
"🖤",
|
||||
"🤍",
|
||||
"💯",
|
||||
"🔥",
|
||||
'😀',
|
||||
'😃',
|
||||
'😄',
|
||||
'😁',
|
||||
'😅',
|
||||
'😂',
|
||||
'🤣',
|
||||
'😊',
|
||||
'😇',
|
||||
'🙂',
|
||||
'🙃',
|
||||
'😉',
|
||||
'😌',
|
||||
'😍',
|
||||
'🥰',
|
||||
'😘',
|
||||
'😗',
|
||||
'😙',
|
||||
'😚',
|
||||
'😋',
|
||||
'😛',
|
||||
'😝',
|
||||
'😜',
|
||||
'🤪',
|
||||
'🤨',
|
||||
'🧐',
|
||||
'🤓',
|
||||
'😎',
|
||||
'🥳',
|
||||
'😏',
|
||||
'👍',
|
||||
'👎',
|
||||
'👏',
|
||||
'🙌',
|
||||
'🤝',
|
||||
'🙏',
|
||||
'💪',
|
||||
'✨',
|
||||
'🎉',
|
||||
'🎊',
|
||||
'❤️',
|
||||
'💙',
|
||||
'💚',
|
||||
'💛',
|
||||
'🧡',
|
||||
'💜',
|
||||
'🖤',
|
||||
'🤍',
|
||||
'💯',
|
||||
'🔥'
|
||||
];
|
||||
|
||||
function adicionarEmoji(emoji: string) {
|
||||
@@ -108,17 +154,13 @@
|
||||
// Obter conversa atual
|
||||
const conversa = $derived((): ConversaComParticipantes | null => {
|
||||
if (!conversas?.data) return null;
|
||||
return (
|
||||
(conversas.data as ConversaComParticipantes[]).find(
|
||||
(c) => c._id === conversaId,
|
||||
) || null
|
||||
);
|
||||
return (conversas.data as ConversaComParticipantes[]).find((c) => c._id === conversaId) || null;
|
||||
});
|
||||
|
||||
// Obter participantes para menções (apenas grupos e salas)
|
||||
const participantesParaMencoes = $derived((): ParticipanteInfo[] => {
|
||||
const c = conversa();
|
||||
if (!c || (c.tipo !== "grupo" && c.tipo !== "sala_reuniao")) return [];
|
||||
if (!c || (c.tipo !== 'grupo' && c.tipo !== 'sala_reuniao')) return [];
|
||||
return c.participantesInfo || [];
|
||||
});
|
||||
|
||||
@@ -130,7 +172,7 @@
|
||||
.filter(
|
||||
(p) =>
|
||||
p.nome?.toLowerCase().includes(query) ||
|
||||
(p.email && p.email.toLowerCase().includes(query)),
|
||||
(p.email && p.email.toLowerCase().includes(query))
|
||||
)
|
||||
.slice(0, 5);
|
||||
});
|
||||
@@ -138,20 +180,33 @@
|
||||
// Auto-resize do textarea e detectar menções
|
||||
function handleInput(e: Event) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
|
||||
// Validar tamanho da mensagem
|
||||
if (mensagem.length > MAX_MENSAGEM_LENGTH) {
|
||||
mensagemMuitoLonga = true;
|
||||
// Limitar ao tamanho máximo
|
||||
mensagem = mensagem.substring(0, MAX_MENSAGEM_LENGTH);
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + "px";
|
||||
textarea.value = mensagem;
|
||||
}
|
||||
} else {
|
||||
mensagemMuitoLonga = false;
|
||||
}
|
||||
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
|
||||
}
|
||||
|
||||
// Detectar menções (@)
|
||||
const cursorPos = target.selectionStart || 0;
|
||||
const textBeforeCursor = mensagem.substring(0, cursorPos);
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
|
||||
const lastAtIndex = textBeforeCursor.lastIndexOf('@');
|
||||
|
||||
if (lastAtIndex !== -1) {
|
||||
const textAfterAt = textBeforeCursor.substring(lastAtIndex + 1);
|
||||
// Se não há espaço após o @, mostrar dropdown
|
||||
if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) {
|
||||
if (!textAfterAt.includes(' ') && !textAfterAt.includes('\n')) {
|
||||
mentionQuery = textAfterAt;
|
||||
mentionStartPos = lastAtIndex;
|
||||
showMentionsDropdown = true;
|
||||
@@ -164,23 +219,24 @@
|
||||
// Indicador de digitação (debounce de 1s)
|
||||
if (digitacaoTimeout) {
|
||||
clearTimeout(digitacaoTimeout);
|
||||
digitacaoTimeout = null;
|
||||
}
|
||||
digitacaoTimeout = setTimeout(() => {
|
||||
if (mensagem.trim()) {
|
||||
client.mutation(api.chat.indicarDigitacao, { conversaId });
|
||||
}
|
||||
digitacaoTimeout = null;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function inserirMencao(participante: ParticipanteInfo) {
|
||||
const nome = participante.nome.split(" ")[0]; // Usar primeiro nome
|
||||
const nome = participante.nome.split(' ')[0]; // Usar primeiro nome
|
||||
const antes = mensagem.substring(0, mentionStartPos);
|
||||
const depois = mensagem.substring(
|
||||
textarea.selectionStart || mensagem.length,
|
||||
);
|
||||
const depois = mensagem.substring(textarea.selectionStart || mensagem.length);
|
||||
mensagem = antes + `@${nome} ` + depois;
|
||||
showMentionsDropdown = false;
|
||||
mentionQuery = "";
|
||||
mentionQuery = '';
|
||||
selectedMentionIndex = 0; // Resetar índice selecionado
|
||||
if (textarea) {
|
||||
textarea.focus();
|
||||
const newPos = antes.length + nome.length + 2;
|
||||
@@ -194,54 +250,119 @@
|
||||
const texto = mensagem.trim();
|
||||
if (!texto || enviando) return;
|
||||
|
||||
// Validar tamanho antes de enviar
|
||||
if (texto.length > MAX_MENSAGEM_LENGTH) {
|
||||
alert(`Mensagem muito longa. O limite é de ${MAX_MENSAGEM_LENGTH} caracteres.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extrair menções do texto (@nome)
|
||||
const mencoesIds: Id<"usuarios">[] = [];
|
||||
const mencoesIds: Id<'usuarios'>[] = [];
|
||||
const mentionRegex = /@(\w+)/g;
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(texto)) !== null) {
|
||||
const nomeMencionado = match[1];
|
||||
const participante = participantesParaMencoes().find(
|
||||
(p) =>
|
||||
p.nome.split(" ")[0].toLowerCase() === nomeMencionado.toLowerCase(),
|
||||
(p) => p.nome.split(' ')[0].toLowerCase() === nomeMencionado.toLowerCase()
|
||||
);
|
||||
if (participante) {
|
||||
mencoesIds.push(participante._id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("📤 [MessageInput] Enviando mensagem:", {
|
||||
// Verificar se a conversa tem criptografia E2E e criptografar mensagem se necessário
|
||||
const conversaTemE2E = temCriptografiaE2E?.data ?? false;
|
||||
let conteudoParaEnviar = texto;
|
||||
let criptografado = false;
|
||||
let iv: string | undefined;
|
||||
let keyId: string | undefined;
|
||||
|
||||
if (conversaTemE2E) {
|
||||
try {
|
||||
// Tentar obter chave do store primeiro
|
||||
let encryptionKey = obterChaveCriptografia(conversaId);
|
||||
|
||||
// Se não estiver no store, tentar carregar do localStorage
|
||||
if (!encryptionKey) {
|
||||
encryptionKey = await loadEncryptionKey(conversaId);
|
||||
if (encryptionKey) {
|
||||
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||
}
|
||||
}
|
||||
|
||||
// Se ainda não tiver chave, tentar obter do servidor
|
||||
if (!encryptionKey) {
|
||||
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
|
||||
conversaId
|
||||
});
|
||||
|
||||
if (chaveDoServidor?.chaveCompartilhada) {
|
||||
// Importar chave do servidor (assumindo que está em formato exportado)
|
||||
// Nota: Em produção, a chave do servidor deve ser criptografada com chave pública do usuário
|
||||
// Por enquanto, vamos assumir que a chave já está descriptografada no cliente
|
||||
const { importKey } = await import('$lib/utils/e2eEncryption');
|
||||
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
|
||||
|
||||
// Armazenar chave localmente
|
||||
const keyData = await exportKey(encryptionKey);
|
||||
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
|
||||
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (encryptionKey) {
|
||||
// Criptografar mensagem
|
||||
const encrypted: EncryptedMessage = await encryptMessage(texto, encryptionKey);
|
||||
conteudoParaEnviar = encrypted.encryptedContent;
|
||||
iv = encrypted.iv;
|
||||
keyId = encrypted.keyId;
|
||||
criptografado = true;
|
||||
} else {
|
||||
console.warn(
|
||||
'⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando sem criptografia.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [MessageInput] Erro ao criptografar mensagem:', error);
|
||||
alert('Erro ao criptografar mensagem. Tentando enviar sem criptografia...');
|
||||
// Continuar sem criptografia em caso de erro
|
||||
}
|
||||
}
|
||||
|
||||
console.log('📤 [MessageInput] Enviando mensagem:', {
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
conteudo: criptografado ? '[CRIPTOGRAFADO]' : texto,
|
||||
tipo: 'texto',
|
||||
criptografado,
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
mencoes: mencoesIds,
|
||||
mencoes: mencoesIds
|
||||
});
|
||||
|
||||
try {
|
||||
enviando = true;
|
||||
const result = await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: texto,
|
||||
tipo: "texto",
|
||||
conteudo: conteudoParaEnviar,
|
||||
tipo: 'texto',
|
||||
respostaPara: mensagemRespondendo?.id,
|
||||
mencoes: mencoesIds.length > 0 ? mencoesIds : undefined,
|
||||
criptografado: criptografado ? true : undefined,
|
||||
iv: iv,
|
||||
keyId: keyId
|
||||
});
|
||||
|
||||
console.log(
|
||||
"✅ [MessageInput] Mensagem enviada com sucesso! ID:",
|
||||
result,
|
||||
);
|
||||
console.log('✅ [MessageInput] Mensagem enviada com sucesso! ID:', result);
|
||||
|
||||
mensagem = "";
|
||||
mensagem = '';
|
||||
mensagemRespondendo = null;
|
||||
showMentionsDropdown = false;
|
||||
mentionQuery = "";
|
||||
mentionQuery = '';
|
||||
if (textarea) {
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = 'auto';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("❌ [MessageInput] Erro ao enviar mensagem:", error);
|
||||
alert("Erro ao enviar mensagem");
|
||||
console.error('❌ [MessageInput] Erro ao enviar mensagem:', error);
|
||||
alert('Erro ao enviar mensagem');
|
||||
} finally {
|
||||
enviando = false;
|
||||
}
|
||||
@@ -252,7 +373,7 @@
|
||||
}
|
||||
|
||||
type MensagemComRemetente = {
|
||||
_id: Id<"mensagens">;
|
||||
_id: Id<'mensagens'>;
|
||||
conteudo: string;
|
||||
remetente?: { nome: string } | null;
|
||||
};
|
||||
@@ -260,50 +381,69 @@
|
||||
// Escutar evento de resposta
|
||||
onMount(() => {
|
||||
const handler = (e: Event) => {
|
||||
const customEvent = e as CustomEvent<{ mensagemId: Id<"mensagens"> }>;
|
||||
const customEvent = e as CustomEvent<{ mensagemId: Id<'mensagens'> }>;
|
||||
// Buscar informações da mensagem para exibir preview
|
||||
client
|
||||
.query(api.chat.obterMensagens, { conversaId, limit: 100 })
|
||||
.then((mensagens) => {
|
||||
const msg = (mensagens as MensagemComRemetente[]).find(
|
||||
(m) => m._id === customEvent.detail.mensagemId,
|
||||
client.query(api.chat.obterMensagens, { conversaId, limit: 100 }).then((mensagens) => {
|
||||
const msg = (mensagens as unknown as { mensagens: MensagemComRemetente[] }).mensagens.find(
|
||||
(m) => m._id === customEvent.detail.mensagemId
|
||||
);
|
||||
if (msg) {
|
||||
mensagemRespondendo = {
|
||||
id: msg._id,
|
||||
conteudo: msg.conteudo.substring(0, 100),
|
||||
remetente: msg.remetente?.nome || "Usuário",
|
||||
remetente: msg.remetente?.nome || 'Usuário'
|
||||
};
|
||||
textarea?.focus();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener("responderMensagem", handler);
|
||||
window.addEventListener('responderMensagem', handler);
|
||||
return () => {
|
||||
window.removeEventListener("responderMensagem", handler);
|
||||
window.removeEventListener('responderMensagem', handler);
|
||||
// Limpar timeout de digitação ao desmontar
|
||||
if (digitacaoTimeout) {
|
||||
clearTimeout(digitacaoTimeout);
|
||||
digitacaoTimeout = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Navegar dropdown de menções
|
||||
if (showMentionsDropdown && participantesFiltrados().length > 0) {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp" || e.key === "Enter") {
|
||||
const participantes = participantesFiltrados();
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
// Implementação simples: selecionar primeiro participante
|
||||
if (e.key === "Enter") {
|
||||
inserirMencao(participantesFiltrados()[0]);
|
||||
selectedMentionIndex = Math.min(selectedMentionIndex + 1, participantes.length - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
selectedMentionIndex = Math.max(selectedMentionIndex - 1, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (participantes[selectedMentionIndex]) {
|
||||
inserirMencao(participantes[selectedMentionIndex]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
showMentionsDropdown = false;
|
||||
selectedMentionIndex = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Enter sem Shift = enviar
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleEnviar();
|
||||
}
|
||||
@@ -314,52 +454,182 @@
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Validar tamanho (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("Arquivo muito grande. O tamanho máximo é 10MB.");
|
||||
// Validar tamanho
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert(
|
||||
`Arquivo muito grande. O tamanho máximo é ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB.`
|
||||
);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar tipo de arquivo
|
||||
if (!TODOS_TIPOS_PERMITIDOS.includes(file.type)) {
|
||||
alert(
|
||||
`Tipo de arquivo não permitido. Tipos aceitos:\n- Imagens: JPEG, PNG, GIF, WebP, SVG\n- Documentos: PDF, Word, Excel, PowerPoint, TXT, CSV\n- Arquivos: ZIP, RAR, 7Z, TAR, GZIP`
|
||||
);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validar extensão do arquivo (segurança adicional)
|
||||
const extensao = file.name.split('.').pop()?.toLowerCase();
|
||||
const extensoesPermitidas = [
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'svg',
|
||||
'pdf',
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'txt',
|
||||
'csv',
|
||||
'zip',
|
||||
'rar',
|
||||
'7z',
|
||||
'tar',
|
||||
'gz'
|
||||
];
|
||||
if (extensao && !extensoesPermitidas.includes(extensao)) {
|
||||
alert(`Extensão de arquivo não permitida: .${extensao}`);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitizar nome do arquivo (remover caracteres perigosos)
|
||||
const nomeSanitizado = file.name.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
|
||||
// Verificar se a conversa tem criptografia E2E e criptografar arquivo se necessário
|
||||
const conversaTemE2E = temCriptografiaE2E?.data ?? false;
|
||||
let arquivoParaUpload: Blob = file;
|
||||
let arquivoCriptografado = false;
|
||||
let arquivoIv: string | undefined;
|
||||
let arquivoKeyId: string | undefined;
|
||||
|
||||
if (conversaTemE2E) {
|
||||
try {
|
||||
// Tentar obter chave de criptografia
|
||||
let encryptionKey = obterChaveCriptografia(conversaId);
|
||||
|
||||
if (!encryptionKey) {
|
||||
encryptionKey = await loadEncryptionKey(conversaId);
|
||||
if (encryptionKey) {
|
||||
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (!encryptionKey) {
|
||||
const chaveDoServidor = await client.query(api.chat.obterChaveCriptografia, {
|
||||
conversaId
|
||||
});
|
||||
|
||||
if (chaveDoServidor?.chaveCompartilhada) {
|
||||
const { importKey } = await import('$lib/utils/e2eEncryption');
|
||||
encryptionKey = await importKey(chaveDoServidor.chaveCompartilhada);
|
||||
|
||||
const keyData = await exportKey(encryptionKey);
|
||||
storeEncryptionKey(conversaId, keyData, chaveDoServidor.keyId);
|
||||
armazenarChaveCriptografia(conversaId, encryptionKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (encryptionKey) {
|
||||
// Criptografar arquivo
|
||||
const encrypted = await encryptFile(file, encryptionKey);
|
||||
arquivoParaUpload = encrypted.encryptedBlob;
|
||||
arquivoIv = encrypted.iv;
|
||||
arquivoKeyId = encrypted.keyId;
|
||||
arquivoCriptografado = true;
|
||||
} else {
|
||||
console.warn(
|
||||
'⚠️ [MessageInput] Criptografia E2E habilitada mas chave não encontrada. Enviando arquivo sem criptografia.'
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ [MessageInput] Erro ao criptografar arquivo:', error);
|
||||
alert('Erro ao criptografar arquivo. Tentando enviar sem criptografia...');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
uploadingFile = true;
|
||||
uploadProgress = 0;
|
||||
|
||||
// 1. Obter upload URL
|
||||
const uploadUrl = await client.mutation(api.chat.uploadArquivoChat, {
|
||||
conversaId,
|
||||
conversaId
|
||||
});
|
||||
|
||||
// 2. Upload do arquivo
|
||||
const result = await fetch(uploadUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
// 2. Upload do arquivo com progresso
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error("Falha no upload");
|
||||
// Promise para aguardar upload completo
|
||||
const uploadPromise = new Promise<string>((resolve, reject) => {
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadProgress = Math.round((e.loaded / e.total) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
const { storageId } = await result.json();
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
resolve(response.storageId);
|
||||
} catch {
|
||||
reject(new Error('Resposta inválida do servidor'));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Falha no upload: ${xhr.statusText}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Erro de rede durante upload'));
|
||||
});
|
||||
|
||||
xhr.addEventListener('abort', () => {
|
||||
reject(new Error('Upload cancelado'));
|
||||
});
|
||||
|
||||
xhr.open('POST', uploadUrl);
|
||||
// Se arquivo foi criptografado, usar tipo genérico
|
||||
xhr.setRequestHeader(
|
||||
'Content-Type',
|
||||
arquivoCriptografado ? 'application/octet-stream' : file.type
|
||||
);
|
||||
xhr.send(arquivoParaUpload);
|
||||
});
|
||||
|
||||
const storageId = await uploadPromise;
|
||||
|
||||
// 3. Enviar mensagem com o arquivo
|
||||
const tipo: "imagem" | "arquivo" = file.type.startsWith("image/")
|
||||
? "imagem"
|
||||
: "arquivo";
|
||||
const tipo: 'imagem' | 'arquivo' = file.type.startsWith('image/') ? 'imagem' : 'arquivo';
|
||||
await client.mutation(api.chat.enviarMensagem, {
|
||||
conversaId,
|
||||
conteudo: tipo === "imagem" ? "" : file.name,
|
||||
conteudo: tipo === 'imagem' ? '' : nomeSanitizado,
|
||||
tipo,
|
||||
arquivoId: storageId,
|
||||
arquivoNome: file.name,
|
||||
arquivoId: storageId as Id<'_storage'>,
|
||||
arquivoNome: nomeSanitizado,
|
||||
arquivoTamanho: file.size,
|
||||
arquivoTipo: file.type,
|
||||
// Campos de criptografia E2E para arquivos
|
||||
criptografado: arquivoCriptografado ? true : undefined,
|
||||
iv: arquivoIv,
|
||||
keyId: arquivoKeyId
|
||||
});
|
||||
|
||||
// Limpar input
|
||||
input.value = "";
|
||||
input.value = '';
|
||||
} catch (error) {
|
||||
console.error("Erro ao fazer upload:", error);
|
||||
alert("Erro ao enviar arquivo");
|
||||
console.error('Erro ao fazer upload:', error);
|
||||
alert('Erro ao enviar arquivo');
|
||||
} finally {
|
||||
uploadingFile = false;
|
||||
}
|
||||
@@ -375,14 +645,12 @@
|
||||
<div class="p-4">
|
||||
<!-- Preview da mensagem respondendo -->
|
||||
{#if mensagemRespondendo}
|
||||
<div
|
||||
class="mb-2 p-2 bg-base-200 rounded-lg flex items-center justify-between"
|
||||
>
|
||||
<div class="bg-base-200 mb-2 flex items-center justify-between rounded-lg p-2">
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-medium text-base-content/70">
|
||||
<p class="text-base-content/70 text-xs font-medium">
|
||||
Respondendo a {mensagemRespondendo.remetente}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 truncate">
|
||||
<p class="text-base-content/50 truncate text-xs">
|
||||
{mensagemRespondendo.conteudo}
|
||||
</p>
|
||||
</div>
|
||||
@@ -399,10 +667,12 @@
|
||||
|
||||
<div class="flex items-end gap-2">
|
||||
<!-- Botão de anexar arquivo MODERNO -->
|
||||
<div class="relative shrink-0">
|
||||
<label
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden cursor-pointer shrink-0"
|
||||
class="group relative flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
||||
style="background: rgba(102, 126, 234, 0.1); border: 1px solid rgba(102, 126, 234, 0.2);"
|
||||
title="Anexar arquivo"
|
||||
aria-label="Anexar arquivo"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
@@ -410,37 +680,54 @@
|
||||
onchange={handleFileUpload}
|
||||
disabled={uploadingFile || enviando}
|
||||
accept="*/*"
|
||||
aria-label="Selecionar arquivo para anexar"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0 bg-primary/0 group-hover:bg-primary/10 transition-colors duration-300"
|
||||
class="bg-primary/0 group-hover:bg-primary/10 absolute inset-0 transition-colors duration-300"
|
||||
></div>
|
||||
{#if uploadingFile}
|
||||
<span class="loading loading-spinner loading-sm relative z-10"></span>
|
||||
{:else}
|
||||
<!-- Ícone de clipe moderno -->
|
||||
<Paperclip
|
||||
class="w-5 h-5 text-primary relative z-10 group-hover:scale-110 transition-transform"
|
||||
class="text-primary relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<!-- Barra de progresso do upload -->
|
||||
{#if uploadingFile && uploadProgress > 0}
|
||||
<div
|
||||
class="bg-base-200 absolute right-0 -bottom-1 left-0 h-1 rounded-full"
|
||||
style="z-index: 20;"
|
||||
>
|
||||
<div
|
||||
class="bg-primary h-full rounded-full transition-all duration-300"
|
||||
style="width: {uploadProgress}%;"
|
||||
></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Botão de EMOJI MODERNO -->
|
||||
<div class="relative shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-xl transition-all duration-300 group relative overflow-hidden"
|
||||
class="group relative flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl transition-all duration-300"
|
||||
style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.2);"
|
||||
onclick={() => (showEmojiPicker = !showEmojiPicker)}
|
||||
disabled={enviando || uploadingFile}
|
||||
aria-label="Adicionar emoji"
|
||||
aria-expanded={showEmojiPicker}
|
||||
aria-haspopup="true"
|
||||
title="Adicionar emoji"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-warning/0 group-hover:bg-warning/10 transition-colors duration-300"
|
||||
class="bg-warning/0 group-hover:bg-warning/10 absolute inset-0 transition-colors duration-300"
|
||||
></div>
|
||||
<Smile
|
||||
class="w-5 h-5 text-warning relative z-10 group-hover:scale-110 transition-transform"
|
||||
class="text-warning relative z-10 h-5 w-5 transition-transform group-hover:scale-110"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</button>
|
||||
@@ -448,15 +735,20 @@
|
||||
<!-- Picker de Emojis -->
|
||||
{#if showEmojiPicker}
|
||||
<div
|
||||
class="absolute bottom-full left-0 mb-2 p-3 bg-base-100 rounded-xl shadow-2xl border border-base-300 z-50"
|
||||
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 rounded-xl border p-3 shadow-2xl"
|
||||
style="width: 280px; max-height: 200px; overflow-y-auto;"
|
||||
role="dialog"
|
||||
aria-label="Selecionar emoji"
|
||||
id="emoji-picker"
|
||||
>
|
||||
<div class="grid grid-cols-10 gap-1">
|
||||
{#each emojis as emoji}
|
||||
<div class="grid grid-cols-10 gap-1" role="grid">
|
||||
{#each emojis as emoji, index (emoji)}
|
||||
<button
|
||||
type="button"
|
||||
class="text-2xl hover:scale-125 transition-transform cursor-pointer p-1 hover:bg-base-200 rounded"
|
||||
class="hover:bg-base-200 cursor-pointer rounded p-1 text-2xl transition-transform hover:scale-125"
|
||||
onclick={() => adicionarEmoji(emoji)}
|
||||
aria-label="Adicionar emoji {emoji}"
|
||||
role="gridcell"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
@@ -467,37 +759,62 @@
|
||||
</div>
|
||||
|
||||
<!-- Textarea -->
|
||||
<div class="flex-1 relative">
|
||||
<div class="relative flex-1">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value={mensagem}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeyDown}
|
||||
placeholder="Digite uma mensagem... (use @ para mencionar)"
|
||||
class="textarea textarea-bordered w-full resize-none min-h-[44px] max-h-[120px] pr-10"
|
||||
class="textarea textarea-bordered max-h-[120px] min-h-[44px] w-full resize-none pr-10 {mensagemMuitoLonga
|
||||
? 'textarea-error'
|
||||
: ''}"
|
||||
rows="1"
|
||||
disabled={enviando || uploadingFile}
|
||||
maxlength={MAX_MENSAGEM_LENGTH}
|
||||
aria-label="Campo de mensagem"
|
||||
aria-describedby="mensagem-help"
|
||||
aria-invalid={mensagemMuitoLonga}
|
||||
></textarea>
|
||||
{#if mensagemMuitoLonga || mensagem.length > MAX_MENSAGEM_LENGTH * 0.9}
|
||||
<div
|
||||
class="absolute right-2 bottom-1 text-xs {mensagem.length > MAX_MENSAGEM_LENGTH
|
||||
? 'text-error'
|
||||
: 'text-base-content/50'}"
|
||||
>
|
||||
{mensagem.length}/{MAX_MENSAGEM_LENGTH}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Dropdown de Menções -->
|
||||
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === "grupo" || conversa()?.tipo === "sala_reuniao")}
|
||||
{#if showMentionsDropdown && participantesFiltrados().length > 0 && (conversa()?.tipo === 'grupo' || conversa()?.tipo === 'sala_reuniao')}
|
||||
<div
|
||||
class="absolute bottom-full left-0 mb-2 bg-base-100 rounded-lg shadow-xl border border-base-300 z-50 w-64 max-h-48 overflow-y-auto"
|
||||
class="bg-base-100 border-base-300 absolute bottom-full left-0 z-50 mb-2 max-h-48 w-64 overflow-y-auto rounded-lg border shadow-xl"
|
||||
role="listbox"
|
||||
aria-label="Lista de participantes para mencionar"
|
||||
id="mentions-dropdown"
|
||||
>
|
||||
{#each participantesFiltrados() as participante (participante._id)}
|
||||
{#each participantesFiltrados() as participante, index (participante._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-2 hover:bg-base-200 transition-colors flex items-center gap-2"
|
||||
class="hover:bg-base-200 flex w-full items-center gap-2 px-4 py-2 text-left transition-colors {index ===
|
||||
selectedMentionIndex
|
||||
? 'bg-primary/20'
|
||||
: ''}"
|
||||
onclick={() => inserirMencao(participante)}
|
||||
role="option"
|
||||
aria-selected={index === selectedMentionIndex}
|
||||
aria-label="Mencionar {participante.nome}"
|
||||
id="mention-option-{index}"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center overflow-hidden"
|
||||
class="bg-primary/20 flex h-8 w-8 items-center justify-center overflow-hidden rounded-full"
|
||||
>
|
||||
{#if participante.fotoPerfilUrl}
|
||||
<img
|
||||
src={participante.fotoPerfilUrl}
|
||||
alt={participante.nome}
|
||||
class="w-full h-full object-cover"
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="text-xs font-semibold"
|
||||
@@ -505,10 +822,10 @@
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium truncate">{participante.nome}</p>
|
||||
<p class="text-xs text-base-content/60 truncate">
|
||||
@{participante.nome.split(" ")[0]}
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="truncate text-sm font-medium">{participante.nome}</p>
|
||||
<p class="text-base-content/60 truncate text-xs">
|
||||
@{participante.nome.split(' ')[0]}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -520,30 +837,30 @@
|
||||
<!-- Botão de enviar MODERNO -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center justify-center w-12 h-12 rounded-xl transition-all duration-300 group relative overflow-hidden shrink-0 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="group relative flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-xl 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={handleEnviar}
|
||||
disabled={!mensagem.trim() || enviando || uploadingFile}
|
||||
aria-label="Enviar"
|
||||
aria-label="Enviar mensagem"
|
||||
aria-describedby="mensagem-help"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-white/0 group-hover:bg-white/10 transition-colors duration-300"
|
||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/10"
|
||||
></div>
|
||||
{#if enviando}
|
||||
<span
|
||||
class="loading loading-spinner loading-sm relative z-10 text-white"
|
||||
></span>
|
||||
<span class="loading loading-spinner loading-sm relative z-10 text-white"></span>
|
||||
{:else}
|
||||
<!-- Ícone de avião de papel moderno -->
|
||||
<Send
|
||||
class="w-5 h-5 text-white relative z-10 group-hover:scale-110 group-hover:translate-x-1 transition-all"
|
||||
class="relative z-10 h-5 w-5 text-white transition-all group-hover:translate-x-1 group-hover:scale-110"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Informação sobre atalhos -->
|
||||
<p class="text-xs text-base-content/50 mt-2 text-center">
|
||||
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji
|
||||
<p id="mensagem-help" class="text-base-content/50 mt-2 text-center text-xs" role="note">
|
||||
💡 Enter para enviar • Shift+Enter para quebrar linha • 😊 Clique no emoji • Use @ para
|
||||
mencionar
|
||||
</p>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,26 @@
|
||||
<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 { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import {
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
Search,
|
||||
User,
|
||||
Users,
|
||||
UserX,
|
||||
Video,
|
||||
X,
|
||||
Search,
|
||||
ChevronRight,
|
||||
Plus,
|
||||
UserX
|
||||
X
|
||||
} from 'lucide-svelte';
|
||||
import { abrirConversa } from '$lib/stores/chatStore';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { onClose }: Props = $props();
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||
@@ -35,7 +35,7 @@
|
||||
let salaReuniaoName = $state('');
|
||||
let loading = $state(false);
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
let usuariosFiltrados = $derived(() => {
|
||||
if (!usuarios?.data) return [];
|
||||
|
||||
// Filtrar o próprio usuário
|
||||
|
||||
@@ -1,21 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { notificacoesCount } from "$lib/stores/chatStore";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { ptBR } from "date-fns/locale";
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Bell,
|
||||
Mail,
|
||||
AtSign,
|
||||
Users,
|
||||
Calendar,
|
||||
Clock,
|
||||
BellOff,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-svelte";
|
||||
import { onMount } from '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 { notificacoesCount } from '$lib/stores/chatStore';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Bell, Mail, AtSign, Users, Calendar, Clock, BellOff, Trash2, X } from 'lucide-svelte';
|
||||
|
||||
// Queries e Client
|
||||
const client = useConvexClient();
|
||||
@@ -23,15 +14,16 @@
|
||||
const countQuery = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
||||
// Query para obter TODAS as notificações (para o popup)
|
||||
const todasNotificacoesQuery = useQuery(api.chat.obterNotificacoes, {
|
||||
apenasPendentes: false,
|
||||
apenasPendentes: false
|
||||
});
|
||||
// Usuário atual
|
||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let usuarioId = $derived((currentUser?.data?._id as Id<'usuarios'> | undefined) ?? null);
|
||||
let notificacoesFerias = $state<
|
||||
Array<{
|
||||
_id: string;
|
||||
_id: Id<'notificacoesFerias'>;
|
||||
mensagem: string;
|
||||
tipo: string;
|
||||
_creationTime: number;
|
||||
@@ -39,7 +31,7 @@
|
||||
>([]);
|
||||
let notificacoesAusencias = $state<
|
||||
Array<{
|
||||
_id: string;
|
||||
_id: Id<'notificacoesAusencias'>;
|
||||
mensagem: string;
|
||||
tipo: string;
|
||||
_creationTime: number;
|
||||
@@ -48,93 +40,80 @@
|
||||
let limpandoNotificacoes = $state(false);
|
||||
|
||||
// Helpers para obter valores das queries
|
||||
const count = $derived(
|
||||
(typeof countQuery === "number" ? countQuery : countQuery?.data) ?? 0,
|
||||
);
|
||||
const todasNotificacoes = $derived(
|
||||
let count = $derived((typeof countQuery === 'number' ? countQuery : countQuery?.data) ?? 0);
|
||||
let todasNotificacoes = $derived(
|
||||
(Array.isArray(todasNotificacoesQuery)
|
||||
? todasNotificacoesQuery
|
||||
: todasNotificacoesQuery?.data) ?? [],
|
||||
: todasNotificacoesQuery?.data) ?? []
|
||||
);
|
||||
|
||||
// Separar notificações lidas e não lidas
|
||||
const notificacoesNaoLidas = $derived(
|
||||
todasNotificacoes.filter((n) => !n.lida),
|
||||
);
|
||||
const notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
|
||||
let notificacoesNaoLidas = $derived(todasNotificacoes.filter((n) => !n.lida));
|
||||
let notificacoesLidas = $derived(todasNotificacoes.filter((n) => n.lida));
|
||||
let totalCount = $derived(count + (notificacoesFerias?.length || 0));
|
||||
|
||||
// Atualizar contador no store
|
||||
$effect(() => {
|
||||
const totalNotificacoes =
|
||||
count +
|
||||
(notificacoesFerias?.length || 0) +
|
||||
(notificacoesAusencias?.length || 0);
|
||||
notificacoesCount.set(totalNotificacoes);
|
||||
count + (notificacoesFerias?.length || 0) + (notificacoesAusencias?.length || 0);
|
||||
$notificacoesCount = totalNotificacoes;
|
||||
});
|
||||
|
||||
// Buscar notificações de férias
|
||||
async function buscarNotificacoesFerias() {
|
||||
async function buscarNotificacoesFerias(id: Id<'usuarios'> | null) {
|
||||
try {
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
const notifsFerias = await client.query(
|
||||
api.ferias.obterNotificacoesNaoLidas,
|
||||
{
|
||||
usuarioId,
|
||||
},
|
||||
);
|
||||
if (!id) return;
|
||||
const notifsFerias = await client.query(api.ferias.obterNotificacoesNaoLidas, {
|
||||
usuarioId: id
|
||||
});
|
||||
notificacoesFerias = notifsFerias || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Erro ao buscar notificações de férias:", e);
|
||||
console.error('Erro ao buscar notificações de férias:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Buscar notificações de ausências
|
||||
async function buscarNotificacoesAusencias() {
|
||||
async function buscarNotificacoesAusencias(id: Id<'usuarios'> | null) {
|
||||
try {
|
||||
const usuarioId = currentUser?.data?._id;
|
||||
if (usuarioId) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const notifsAusencias = await client.query(
|
||||
api.ausencias.obterNotificacoesNaoLidas,
|
||||
{
|
||||
usuarioId,
|
||||
},
|
||||
);
|
||||
const notifsAusencias = await client.query(api.ausencias.obterNotificacoesNaoLidas, {
|
||||
usuarioId: id
|
||||
});
|
||||
notificacoesAusencias = notifsAusencias || [];
|
||||
} catch (queryError: unknown) {
|
||||
// Silenciar erro se a função não estiver disponível ainda (Convex não sincronizado)
|
||||
const errorMessage =
|
||||
queryError instanceof Error
|
||||
? queryError.message
|
||||
: String(queryError);
|
||||
if (!errorMessage.includes("Could not find public function")) {
|
||||
console.error(
|
||||
"Erro ao buscar notificações de ausências:",
|
||||
queryError,
|
||||
);
|
||||
// Silenciar erros de timeout e função não encontrada
|
||||
const errorMessage = queryError instanceof Error ? queryError.message : String(queryError);
|
||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
||||
|
||||
if (!isTimeout && !isFunctionNotFound) {
|
||||
console.error('Erro ao buscar notificações de ausências:', queryError);
|
||||
}
|
||||
notificacoesAusencias = [];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Erro geral - silenciar se for sobre função não encontrada
|
||||
// Erro geral - silenciar se for sobre função não encontrada ou timeout
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (!errorMessage.includes("Could not find public function")) {
|
||||
console.error("Erro ao buscar notificações de ausências:", e);
|
||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||
const isFunctionNotFound = errorMessage.includes('Could not find public function');
|
||||
|
||||
if (!isTimeout && !isFunctionNotFound) {
|
||||
console.error('Erro ao buscar notificações de ausências:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Atualizar notificações periodicamente
|
||||
$effect(() => {
|
||||
buscarNotificacoesFerias();
|
||||
buscarNotificacoesAusencias();
|
||||
onMount(() => {
|
||||
void buscarNotificacoesFerias(usuarioId);
|
||||
void buscarNotificacoesAusencias(usuarioId);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
buscarNotificacoesFerias();
|
||||
buscarNotificacoesAusencias();
|
||||
void buscarNotificacoesFerias(usuarioId);
|
||||
void buscarNotificacoesAusencias(usuarioId);
|
||||
}, 30000); // A cada 30s
|
||||
|
||||
return () => clearInterval(interval);
|
||||
});
|
||||
|
||||
@@ -142,39 +121,21 @@
|
||||
try {
|
||||
return formatDistanceToNow(new Date(timestamp), {
|
||||
addSuffix: true,
|
||||
locale: ptBR,
|
||||
locale: ptBR
|
||||
});
|
||||
} catch {
|
||||
return "agora";
|
||||
return 'agora';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarcarTodasLidas() {
|
||||
await client.mutation(api.chat.marcarTodasNotificacoesLidas, {});
|
||||
// Marcar todas as notificações de férias como lidas
|
||||
for (const notif of notificacoesFerias) {
|
||||
await client.mutation(api.ferias.marcarComoLida, {
|
||||
notificacaoId: notif._id,
|
||||
});
|
||||
}
|
||||
// Marcar todas as notificações de ausências como lidas
|
||||
for (const notif of notificacoesAusencias) {
|
||||
await client.mutation(api.ausencias.marcarComoLida, {
|
||||
notificacaoId: notif._id,
|
||||
});
|
||||
}
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
}
|
||||
|
||||
async function handleLimparTodasNotificacoes() {
|
||||
limpandoNotificacoes = true;
|
||||
try {
|
||||
await client.mutation(api.chat.limparTodasNotificacoes, {});
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
} catch (error) {
|
||||
console.error("Erro ao limpar notificações:", error);
|
||||
console.error('Erro ao limpar notificações:', error);
|
||||
} finally {
|
||||
limpandoNotificacoes = false;
|
||||
}
|
||||
@@ -184,10 +145,10 @@
|
||||
limpandoNotificacoes = true;
|
||||
try {
|
||||
await client.mutation(api.chat.limparNotificacoesNaoLidas, {});
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesAusencias();
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
} catch (error) {
|
||||
console.error("Erro ao limpar notificações não lidas:", error);
|
||||
console.error('Erro ao limpar notificações não lidas:', error);
|
||||
} finally {
|
||||
limpandoNotificacoes = false;
|
||||
}
|
||||
@@ -195,26 +156,26 @@
|
||||
|
||||
async function handleClickNotificacao(notificacaoId: string) {
|
||||
await client.mutation(api.chat.marcarNotificacaoLida, {
|
||||
notificacaoId: notificacaoId as any,
|
||||
notificacaoId: notificacaoId as Id<'notificacoes'>
|
||||
});
|
||||
}
|
||||
|
||||
async function handleClickNotificacaoFerias(notificacaoId: string) {
|
||||
async function handleClickNotificacaoFerias(notificacaoId: Id<'notificacoesFerias'>) {
|
||||
await client.mutation(api.ferias.marcarComoLida, {
|
||||
notificacaoId: notificacaoId,
|
||||
notificacaoId: notificacaoId
|
||||
});
|
||||
await buscarNotificacoesFerias();
|
||||
await buscarNotificacoesFerias(usuarioId);
|
||||
// Redirecionar para a página de férias
|
||||
window.location.href = "/recursos-humanos/ferias";
|
||||
window.location.href = '/recursos-humanos/ferias';
|
||||
}
|
||||
|
||||
async function handleClickNotificacaoAusencias(notificacaoId: string) {
|
||||
async function handleClickNotificacaoAusencias(notificacaoId: Id<'notificacoesAusencias'>) {
|
||||
await client.mutation(api.ausencias.marcarComoLida, {
|
||||
notificacaoId: notificacaoId,
|
||||
notificacaoId: notificacaoId
|
||||
});
|
||||
await buscarNotificacoesAusencias();
|
||||
await buscarNotificacoesAusencias(usuarioId);
|
||||
// Redirecionar para a página de perfil na aba de ausências
|
||||
window.location.href = "/perfil?aba=minhas-ausencias";
|
||||
window.location.href = '/perfil?aba=minhas-ausencias';
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
@@ -226,95 +187,65 @@
|
||||
}
|
||||
|
||||
// Fechar popup ao clicar fora ou pressionar Escape
|
||||
$effect(() => {
|
||||
if (!modalOpen) return;
|
||||
|
||||
onMount(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (!modalOpen) return;
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
!target.closest(".notification-popup") &&
|
||||
!target.closest(".notification-bell")
|
||||
) {
|
||||
modalOpen = false;
|
||||
if (!target.closest('.notification-popup') && !target.closest('.notification-bell')) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
modalOpen = false;
|
||||
if (!modalOpen) return;
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="notification-bell relative">
|
||||
<!-- Botão de Notificação ULTRA MODERNO (igual ao perfil) -->
|
||||
<!-- Botão de Notificação (padrão do tema) -->
|
||||
<div class="indicator">
|
||||
{#if totalCount > 0}
|
||||
<span class="indicator-item badge badge-error badge-sm">
|
||||
{totalCount > 9 ? '9+' : totalCount}
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="relative flex items-center justify-center w-14 h-14 rounded-2xl overflow-hidden group transition-all duration-300 hover:scale-105"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
||||
class="btn ring-base-200 hover:ring-primary/50 size-10 p-0 ring-2 ring-offset-2 transition-all"
|
||||
onclick={openModal}
|
||||
aria-label="Notificações"
|
||||
aria-expanded={modalOpen}
|
||||
>
|
||||
<!-- Efeito de brilho no hover -->
|
||||
<div
|
||||
class="absolute inset-0 bg-linear-to-br from-white/0 to-white/20 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
></div>
|
||||
|
||||
<!-- Anel de pulso sutil -->
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl"
|
||||
style="animation: pulse-ring-subtle 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
|
||||
></div>
|
||||
|
||||
<!-- Glow effect quando tem notificações -->
|
||||
{#if count && count > 0}
|
||||
<div
|
||||
class="absolute inset-0 rounded-2xl bg-error/30 blur-lg animate-pulse"
|
||||
></div>
|
||||
{/if}
|
||||
|
||||
<!-- Ícone do sino PREENCHIDO moderno -->
|
||||
<Bell
|
||||
class="w-7 h-7 text-white relative z-10 transition-all duration-300 group-hover:scale-110"
|
||||
style="filter: drop-shadow(0 2px 8px rgba(0,0,0,0.3)); animation: {count &&
|
||||
count > 0
|
||||
? 'bell-ring 2s ease-in-out infinite'
|
||||
: 'none'};"
|
||||
fill="currentColor"
|
||||
class="size-6 transition-colors {totalCount > 0 ? 'text-primary' : 'text-base-content/70'}"
|
||||
style="animation: {totalCount > 0 ? 'bell-ring 2s ease-in-out infinite' : 'none'};"
|
||||
/>
|
||||
|
||||
<!-- Badge premium MODERNO com gradiente -->
|
||||
{#if count + (notificacoesFerias?.length || 0) > 0}
|
||||
{@const totalCount = count + (notificacoesFerias?.length || 0)}
|
||||
<span
|
||||
class="absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full text-white text-[10px] font-black shadow-xl ring-2 ring-white z-20"
|
||||
style="background: linear-gradient(135deg, #ff416c, #ff4b2b); box-shadow: 0 8px 24px -4px rgba(255, 65, 108, 0.6), 0 4px 12px -2px rgba(255, 75, 43, 0.4); animation: badge-bounce 2s ease-in-out infinite;"
|
||||
>
|
||||
{totalCount > 9 ? "9+" : totalCount}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Popup Flutuante de Notificações -->
|
||||
{#if modalOpen}
|
||||
<div
|
||||
class="notification-popup fixed right-4 top-24 z-[100] w-[calc(100vw-2rem)] max-w-2xl max-h-[calc(100vh-7rem)] flex flex-col bg-base-100 rounded-2xl shadow-2xl border border-base-300 overflow-hidden backdrop-blur-sm"
|
||||
class="notification-popup bg-base-100 border-base-300 fixed top-24 right-4 z-100 flex max-h-[calc(100vh-7rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col overflow-hidden rounded-2xl border shadow-2xl backdrop-blur-sm"
|
||||
style="animation: slideDown 0.2s ease-out;"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-base-300 bg-linear-to-r from-primary/5 to-primary/10"
|
||||
class="border-base-300 from-primary/5 to-primary/10 flex items-center justify-between border-b bg-linear-to-r px-6 py-4"
|
||||
>
|
||||
<h3 class="text-2xl font-bold text-primary">Notificações</h3>
|
||||
<h3 class="text-primary text-2xl font-bold">Notificações</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if notificacoesNaoLidas.length > 0}
|
||||
<button
|
||||
@@ -323,7 +254,7 @@
|
||||
onclick={handleLimparNotificacoesNaoLidas}
|
||||
disabled={limpandoNotificacoes}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Limpar não lidas
|
||||
</button>
|
||||
{/if}
|
||||
@@ -334,16 +265,12 @@
|
||||
onclick={handleLimparTodasNotificacoes}
|
||||
disabled={limpandoNotificacoes}
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
<Trash2 class="h-4 w-4" />
|
||||
Limpar todas
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost"
|
||||
onclick={closeModal}
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
<button type="button" class="btn btn-sm btn-circle" onclick={closeModal}>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,68 +281,57 @@
|
||||
<!-- Notificações não lidas -->
|
||||
{#if notificacoesNaoLidas.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-semibold text-primary mb-2 px-2">
|
||||
Não lidas
|
||||
</h4>
|
||||
<h4 class="text-primary mb-2 px-2 text-sm font-semibold">Não lidas</h4>
|
||||
{#each notificacoesNaoLidas as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-primary"
|
||||
class="hover:bg-base-200 border-primary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||
onclick={() => handleClickNotificacao(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="shrink-0 mt-1">
|
||||
{#if notificacao.tipo === "nova_mensagem"}
|
||||
<Mail class="w-5 h-5 text-primary" strokeWidth={1.5} />
|
||||
{:else if notificacao.tipo === "mencao"}
|
||||
<AtSign
|
||||
class="w-5 h-5 text-warning"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div class="mt-1 shrink-0">
|
||||
{#if notificacao.tipo === 'nova_mensagem'}
|
||||
<Mail class="text-primary h-5 w-5" strokeWidth={1.5} />
|
||||
{:else if notificacao.tipo === 'mencao'}
|
||||
<AtSign class="text-warning h-5 w-5" strokeWidth={1.5} />
|
||||
{:else}
|
||||
<Users class="w-5 h-5 text-info" strokeWidth={1.5} />
|
||||
<Users class="text-info h-5 w-5" strokeWidth={1.5} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if notificacao.tipo === "nova_mensagem" && notificacao.remetente}
|
||||
<p class="text-sm font-semibold text-primary">
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if notificacao.tipo === 'nova_mensagem' && notificacao.remetente}
|
||||
<p class="text-primary text-sm font-semibold">
|
||||
{notificacao.remetente.nome}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-base-content/70 mt-1 line-clamp-2"
|
||||
>
|
||||
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
{:else if notificacao.tipo === "mencao" && notificacao.remetente}
|
||||
<p class="text-sm font-semibold text-warning">
|
||||
{:else if notificacao.tipo === 'mencao' && notificacao.remetente}
|
||||
<p class="text-warning text-sm font-semibold">
|
||||
{notificacao.remetente.nome} mencionou você
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-base-content/70 mt-1 line-clamp-2"
|
||||
>
|
||||
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm font-semibold text-base-content">
|
||||
<p class="text-base-content text-sm font-semibold">
|
||||
{notificacao.titulo}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-base-content/70 mt-1 line-clamp-2"
|
||||
>
|
||||
<p class="text-base-content/70 mt-1 line-clamp-2 text-xs">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{formatarTempo(notificacao.criadaEm)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Indicador de não lida -->
|
||||
<div class="shrink-0">
|
||||
<div class="w-2 h-2 rounded-full bg-primary"></div>
|
||||
<div class="bg-primary h-2 w-2 rounded-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -426,64 +342,50 @@
|
||||
<!-- Notificações lidas -->
|
||||
{#if notificacoesLidas.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-semibold text-base-content/60 mb-2 px-2">
|
||||
Lidas
|
||||
</h4>
|
||||
<h4 class="text-base-content/60 mb-2 px-2 text-sm font-semibold">Lidas</h4>
|
||||
{#each notificacoesLidas as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 opacity-75"
|
||||
class="hover:bg-base-200 mb-2 w-full rounded-lg px-4 py-3 text-left opacity-75 transition-colors"
|
||||
onclick={() => handleClickNotificacao(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="shrink-0 mt-1">
|
||||
{#if notificacao.tipo === "nova_mensagem"}
|
||||
<Mail
|
||||
class="w-5 h-5 text-primary/60"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
{:else if notificacao.tipo === "mencao"}
|
||||
<AtSign
|
||||
class="w-5 h-5 text-warning/60"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div class="mt-1 shrink-0">
|
||||
{#if notificacao.tipo === 'nova_mensagem'}
|
||||
<Mail class="text-primary/60 h-5 w-5" strokeWidth={1.5} />
|
||||
{:else if notificacao.tipo === 'mencao'}
|
||||
<AtSign class="text-warning/60 h-5 w-5" strokeWidth={1.5} />
|
||||
{:else}
|
||||
<Users class="w-5 h-5 text-info/60" strokeWidth={1.5} />
|
||||
<Users class="text-info/60 h-5 w-5" strokeWidth={1.5} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
{#if notificacao.tipo === "nova_mensagem" && notificacao.remetente}
|
||||
<p class="text-sm font-medium text-primary/70">
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if notificacao.tipo === 'nova_mensagem' && notificacao.remetente}
|
||||
<p class="text-primary/70 text-sm font-medium">
|
||||
{notificacao.remetente.nome}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-base-content/60 mt-1 line-clamp-2"
|
||||
>
|
||||
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
{:else if notificacao.tipo === "mencao" && notificacao.remetente}
|
||||
<p class="text-sm font-medium text-warning/70">
|
||||
{:else if notificacao.tipo === 'mencao' && notificacao.remetente}
|
||||
<p class="text-warning/70 text-sm font-medium">
|
||||
{notificacao.remetente.nome} mencionou você
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-base-content/60 mt-1 line-clamp-2"
|
||||
>
|
||||
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm font-medium text-base-content/70">
|
||||
<p class="text-base-content/70 text-sm font-medium">
|
||||
{notificacao.titulo}
|
||||
</p>
|
||||
<p
|
||||
class="text-xs text-base-content/60 mt-1 line-clamp-2"
|
||||
>
|
||||
<p class="text-base-content/60 mt-1 line-clamp-2 text-xs">
|
||||
{notificacao.descricao}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{formatarTempo(notificacao.criadaEm)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -496,37 +398,32 @@
|
||||
<!-- Notificações de Férias -->
|
||||
{#if notificacoesFerias.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-semibold text-purple-600 mb-2 px-2">
|
||||
Férias
|
||||
</h4>
|
||||
<h4 class="text-secondary mb-2 px-2 text-sm font-semibold">Férias</h4>
|
||||
{#each notificacoesFerias as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-purple-600"
|
||||
class="hover:bg-base-200 border-secondary mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||
onclick={() => handleClickNotificacaoFerias(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="shrink-0 mt-1">
|
||||
<Calendar
|
||||
class="w-5 h-5 text-purple-600"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
<div class="mt-1 shrink-0">
|
||||
<Calendar class="text-secondary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content text-sm font-medium">
|
||||
{notificacao.mensagem}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{formatarTempo(notificacao._creationTime)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Badge -->
|
||||
<div class="shrink-0">
|
||||
<div class="badge badge-primary badge-xs"></div>
|
||||
<div class="badge badge-secondary badge-xs"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -537,28 +434,25 @@
|
||||
<!-- Notificações de Ausências -->
|
||||
{#if notificacoesAusencias.length > 0}
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-semibold text-orange-600 mb-2 px-2">
|
||||
Ausências
|
||||
</h4>
|
||||
<h4 class="text-warning mb-2 px-2 text-sm font-semibold">Ausências</h4>
|
||||
{#each notificacoesAusencias as notificacao (notificacao._id)}
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-4 py-3 hover:bg-base-200 rounded-lg transition-colors mb-2 border-l-4 border-orange-600"
|
||||
onclick={() =>
|
||||
handleClickNotificacaoAusencias(notificacao._id)}
|
||||
class="hover:bg-base-200 border-warning mb-2 w-full rounded-lg border-l-4 px-4 py-3 text-left transition-colors"
|
||||
onclick={() => handleClickNotificacaoAusencias(notificacao._id)}
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Ícone -->
|
||||
<div class="shrink-0 mt-1">
|
||||
<Clock class="w-5 h-5 text-orange-600" strokeWidth={2} />
|
||||
<div class="mt-1 shrink-0">
|
||||
<Clock class="text-warning h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-base-content">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-base-content text-sm font-medium">
|
||||
{notificacao.mensagem}
|
||||
</p>
|
||||
<p class="text-xs text-base-content/50 mt-1">
|
||||
<p class="text-base-content/50 mt-1 text-xs">
|
||||
{formatarTempo(notificacao._creationTime)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -574,23 +468,18 @@
|
||||
{/if}
|
||||
{:else}
|
||||
<!-- Sem notificações -->
|
||||
<div class="px-4 py-12 text-center text-base-content/50">
|
||||
<BellOff
|
||||
class="w-16 h-16 mx-auto mb-4 opacity-50"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
<div class="text-base-content/50 px-4 py-12 text-center">
|
||||
<BellOff class="mx-auto mb-4 h-16 w-16 opacity-50" strokeWidth={1.5} />
|
||||
<p class="text-base font-medium">Nenhuma notificação</p>
|
||||
<p class="text-sm mt-1">Você está em dia!</p>
|
||||
<p class="mt-1 text-sm">Você está em dia!</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer com estatísticas -->
|
||||
{#if todasNotificacoes.length > 0 || notificacoesFerias.length > 0 || notificacoesAusencias.length > 0}
|
||||
<div class="px-6 py-4 border-t border-base-300 bg-base-200/50">
|
||||
<div
|
||||
class="flex items-center justify-between text-xs text-base-content/60"
|
||||
>
|
||||
<div class="border-base-300 bg-base-200/50 border-t px-6 py-4">
|
||||
<div class="text-base-content/60 flex items-center justify-between text-xs">
|
||||
<span>
|
||||
Total: {todasNotificacoes.length +
|
||||
notificacoesFerias.length +
|
||||
@@ -609,28 +498,6 @@
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes badge-bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-ring-subtle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bell-ring {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -1,18 +1,71 @@
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from "convex-svelte";
|
||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
||||
import { onMount } from "svelte";
|
||||
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, {});
|
||||
let usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined);
|
||||
|
||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||
|
||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let lastActivity = Date.now();
|
||||
let lastStatusUpdate = 0;
|
||||
let pendingStatusUpdate: ReturnType<typeof setTimeout> | null = null;
|
||||
const STATUS_UPDATE_THROTTLE = 5000; // 5 segundos entre atualizações
|
||||
|
||||
// Função auxiliar para atualizar status com throttle e tratamento de erro
|
||||
async function atualizarStatusPresencaSeguro(
|
||||
status: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao'
|
||||
) {
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
const now = Date.now();
|
||||
// Throttle: só atualizar se passou tempo suficiente desde a última atualização
|
||||
if (now - lastStatusUpdate < STATUS_UPDATE_THROTTLE) {
|
||||
// Cancelar atualização pendente se houver
|
||||
if (pendingStatusUpdate) {
|
||||
clearTimeout(pendingStatusUpdate);
|
||||
}
|
||||
// Agendar atualização para depois do throttle
|
||||
pendingStatusUpdate = setTimeout(
|
||||
() => {
|
||||
atualizarStatusPresencaSeguro(status);
|
||||
},
|
||||
STATUS_UPDATE_THROTTLE - (now - lastStatusUpdate)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Limpar atualização pendente se houver
|
||||
if (pendingStatusUpdate) {
|
||||
clearTimeout(pendingStatusUpdate);
|
||||
pendingStatusUpdate = null;
|
||||
}
|
||||
|
||||
lastStatusUpdate = now;
|
||||
|
||||
try {
|
||||
await client.mutation(api.chat.atualizarStatusPresenca, { status });
|
||||
} catch (error) {
|
||||
// Silenciar erros de timeout - não são críticos para a funcionalidade
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const isTimeout = errorMessage.includes('timed out') || errorMessage.includes('timeout');
|
||||
if (!isTimeout) {
|
||||
console.error('Erro ao atualizar status de presença:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detectar atividade do usuário
|
||||
function handleActivity() {
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
lastActivity = Date.now();
|
||||
|
||||
// Limpar timeout de inatividade anterior
|
||||
@@ -21,52 +74,80 @@
|
||||
}
|
||||
|
||||
// Configurar novo timeout (5 minutos)
|
||||
inactivityTimeout = setTimeout(() => {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
}, 5 * 60 * 1000);
|
||||
inactivityTimeout = setTimeout(
|
||||
() => {
|
||||
if (usuarioAutenticado) {
|
||||
atualizarStatusPresencaSeguro('ausente');
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
);
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Configurar como online ao montar
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
// Só configurar presença se usuário estiver autenticado
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
// Heartbeat a cada 30 segundos
|
||||
// Configurar como online ao montar (apenas se autenticado)
|
||||
atualizarStatusPresencaSeguro('online');
|
||||
|
||||
// Heartbeat a cada 30 segundos (apenas se autenticado)
|
||||
heartbeatInterval = setInterval(() => {
|
||||
if (!usuarioAutenticado) {
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
heartbeatInterval = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const timeSinceLastActivity = Date.now() - lastActivity;
|
||||
|
||||
// Se houve atividade nos últimos 5 minutos, manter online
|
||||
if (timeSinceLastActivity < 5 * 60 * 1000) {
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
atualizarStatusPresencaSeguro('online');
|
||||
}
|
||||
}, 30 * 1000);
|
||||
|
||||
// Listeners para detectar atividade
|
||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
||||
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||
events.forEach((event) => {
|
||||
window.addEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
// Configurar timeout inicial de inatividade
|
||||
if (usuarioAutenticado) {
|
||||
handleActivity();
|
||||
}
|
||||
|
||||
// Detectar quando a aba fica inativa/ativa
|
||||
function handleVisibilityChange() {
|
||||
if (!usuarioAutenticado) return;
|
||||
|
||||
if (document.hidden) {
|
||||
// Aba ficou inativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
||||
atualizarStatusPresencaSeguro('ausente');
|
||||
} else {
|
||||
// Aba ficou ativa
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
||||
atualizarStatusPresencaSeguro('online');
|
||||
handleActivity();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Marcar como offline ao desmontar
|
||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
|
||||
// Limpar atualização pendente
|
||||
if (pendingStatusUpdate) {
|
||||
clearTimeout(pendingStatusUpdate);
|
||||
pendingStatusUpdate = null;
|
||||
}
|
||||
|
||||
// Marcar como offline ao desmontar (apenas se autenticado)
|
||||
if (usuarioAutenticado) {
|
||||
atualizarStatusPresencaSeguro('offline');
|
||||
}
|
||||
|
||||
if (heartbeatInterval) {
|
||||
clearInterval(heartbeatInterval);
|
||||
@@ -80,10 +161,9 @@
|
||||
window.removeEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Componente invisível - apenas lógica -->
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<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 { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { ArrowDown, ArrowUp, Search, Trash2, UserPlus, Users, X } from 'lucide-svelte';
|
||||
import UserAvatar from './UserAvatar.svelte';
|
||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
@@ -12,7 +12,7 @@
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, isAdmin, onClose }: Props = $props();
|
||||
const { conversaId, isAdmin, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const conversas = useQuery(api.chat.listarConversas, {});
|
||||
@@ -23,16 +23,16 @@
|
||||
let loading = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const conversa = $derived(() => {
|
||||
let conversa = $derived(() => {
|
||||
if (!conversas?.data) return null;
|
||||
return conversas.data.find((c: any) => c._id === conversaId);
|
||||
});
|
||||
|
||||
const todosUsuarios = $derived(() => {
|
||||
let todosUsuarios = $derived(() => {
|
||||
return todosUsuariosQuery?.data || [];
|
||||
});
|
||||
|
||||
const participantes = $derived(() => {
|
||||
let participantes = $derived(() => {
|
||||
try {
|
||||
const conv = conversa();
|
||||
const usuarios = todosUsuarios();
|
||||
@@ -76,11 +76,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
const administradoresIds = $derived(() => {
|
||||
let administradoresIds = $derived(() => {
|
||||
return conversa()?.administradores || [];
|
||||
});
|
||||
|
||||
const usuariosDisponiveis = $derived(() => {
|
||||
let usuariosDisponiveis = $derived(() => {
|
||||
const usuarios = todosUsuarios();
|
||||
if (!usuarios || usuarios.length === 0) return [];
|
||||
const participantesIds = conversa()?.participantes || [];
|
||||
@@ -89,7 +89,7 @@
|
||||
);
|
||||
});
|
||||
|
||||
const usuariosFiltrados = $derived(() => {
|
||||
let usuariosFiltrados = $derived(() => {
|
||||
const disponiveis = usuariosDisponiveis();
|
||||
if (!searchQuery.trim()) return disponiveis;
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<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 { useConvexClient, useQuery } from 'convex-svelte';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Clock, X, Trash2 } from 'lucide-svelte';
|
||||
import { Clock, Trash2, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
conversaId: Id<'conversas'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { conversaId, onClose }: Props = $props();
|
||||
const { conversaId, onClose }: Props = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||
|
||||
@@ -1,41 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { getAvatarUrl as generateAvatarUrl } from "$lib/utils/avatarGenerator";
|
||||
import { User } from 'lucide-svelte';
|
||||
import { getCachedAvatar } from '$lib/utils/avatarCache';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
avatar?: string;
|
||||
fotoPerfilUrl?: string | null;
|
||||
nome: string;
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
userId?: string; // ID do usuário para cache
|
||||
}
|
||||
|
||||
let { avatar, fotoPerfilUrl, nome, size = "md" }: Props = $props();
|
||||
let { fotoPerfilUrl, nome, size = 'md', userId }: Props = $props();
|
||||
|
||||
let cachedAvatarUrl = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
if (fotoPerfilUrl) {
|
||||
loading = true;
|
||||
try {
|
||||
cachedAvatarUrl = await getCachedAvatar(fotoPerfilUrl, userId);
|
||||
} catch (error) {
|
||||
console.warn('Erro ao carregar avatar:', error);
|
||||
cachedAvatarUrl = null;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
} else {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Atualizar quando fotoPerfilUrl mudar
|
||||
$effect(() => {
|
||||
if (fotoPerfilUrl) {
|
||||
loading = true;
|
||||
getCachedAvatar(fotoPerfilUrl, userId)
|
||||
.then((url) => {
|
||||
cachedAvatarUrl = url;
|
||||
loading = false;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Erro ao carregar avatar:', error);
|
||||
cachedAvatarUrl = null;
|
||||
loading = false;
|
||||
});
|
||||
} else {
|
||||
cachedAvatarUrl = null;
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const sizeClasses = {
|
||||
xs: "w-8 h-8",
|
||||
sm: "w-10 h-10",
|
||||
md: "w-12 h-12",
|
||||
lg: "w-16 h-16",
|
||||
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'
|
||||
};
|
||||
|
||||
function getAvatarUrl(avatarId: string): string {
|
||||
// Usar gerador local ao invés da API externa
|
||||
return generateAvatarUrl(avatarId);
|
||||
}
|
||||
|
||||
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`}>
|
||||
<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 loading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else if cachedAvatarUrl}
|
||||
<img
|
||||
src={avatarUrlToShow()}
|
||||
alt={`Avatar de ${nome}`}
|
||||
class="w-full h-full object-cover"
|
||||
src={cachedAvatarUrl}
|
||||
alt={`Foto de perfil de ${nome}`}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
cachedAvatarUrl = null;
|
||||
}}
|
||||
/>
|
||||
{:else if fotoPerfilUrl}
|
||||
<!-- Fallback: usar URL original se cache falhar -->
|
||||
<img
|
||||
src={fotoPerfilUrl}
|
||||
alt={`Foto de perfil de ${nome}`}
|
||||
class="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
{:else}
|
||||
<User size={iconSizes[size]} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,75 +1,68 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, XCircle, AlertCircle, Plus, Video } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
status?: "online" | "offline" | "ausente" | "externo" | "em_reuniao";
|
||||
size?: "sm" | "md" | "lg";
|
||||
status?: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
let { status = "offline", size = "md" }: Props = $props();
|
||||
let { status = 'offline', size = 'md' }: Props = $props();
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "w-3 h-3",
|
||||
md: "w-4 h-4",
|
||||
lg: "w-5 h-5",
|
||||
sm: 'w-3 h-3',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
online: {
|
||||
color: "bg-success",
|
||||
borderColor: "border-success",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
||||
<circle cx="12" cy="12" r="10" fill="#10b981"/>
|
||||
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`,
|
||||
label: "🟢 Online",
|
||||
color: 'bg-success',
|
||||
borderColor: 'border-success',
|
||||
icon: CheckCircle2,
|
||||
label: '🟢 Online'
|
||||
},
|
||||
offline: {
|
||||
color: "bg-base-300",
|
||||
borderColor: "border-base-300",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
||||
<circle cx="12" cy="12" r="10" fill="#9ca3af"/>
|
||||
<path d="M8 8l8 8M16 8l-8 8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
label: "⚫ Offline",
|
||||
color: 'bg-base-300',
|
||||
borderColor: 'border-base-300',
|
||||
icon: XCircle,
|
||||
label: '⚫ Offline'
|
||||
},
|
||||
ausente: {
|
||||
color: "bg-warning",
|
||||
borderColor: "border-warning",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
||||
<circle cx="12" cy="12" r="10" fill="#f59e0b"/>
|
||||
<circle cx="12" cy="6" r="1.5" fill="white"/>
|
||||
<path d="M12 10v4" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
label: "🟡 Ausente",
|
||||
color: 'bg-warning',
|
||||
borderColor: 'border-warning',
|
||||
icon: AlertCircle,
|
||||
label: '🟡 Ausente'
|
||||
},
|
||||
externo: {
|
||||
color: "bg-info",
|
||||
borderColor: "border-info",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
||||
<circle cx="12" cy="12" r="10" fill="#3b82f6"/>
|
||||
<path d="M8 12h8M12 8v8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>`,
|
||||
label: "🔵 Externo",
|
||||
color: 'bg-info',
|
||||
borderColor: 'border-info',
|
||||
icon: Plus,
|
||||
label: '🔵 Externo'
|
||||
},
|
||||
em_reuniao: {
|
||||
color: "bg-error",
|
||||
borderColor: "border-error",
|
||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
||||
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
|
||||
<rect x="8" y="8" width="8" height="8" fill="white" rx="1"/>
|
||||
</svg>`,
|
||||
label: "🔴 Em Reunião",
|
||||
},
|
||||
color: 'bg-error',
|
||||
borderColor: 'border-error',
|
||||
icon: Video,
|
||||
label: '🔴 Em Reunião'
|
||||
}
|
||||
};
|
||||
|
||||
const config = $derived(statusConfig[status]);
|
||||
const IconComponent = $derived(config.icon);
|
||||
const iconSize = $derived(iconSizes[size]);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={`${sizeClasses[size]} rounded-full relative flex items-center justify-center`}
|
||||
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15); border: 2px solid white;"
|
||||
class={`${sizeClasses[size]} ${config.color} ${config.borderColor} relative flex items-center justify-center rounded-full border-2`}
|
||||
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15);"
|
||||
title={config.label}
|
||||
aria-label={config.label}
|
||||
>
|
||||
{@html config.icon}
|
||||
<IconComponent class="text-white" size={iconSize} strokeWidth={2.5} fill="currentColor" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import type { FunctionReference } from 'convex/server';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { LogIn, Settings, User, UserCog } from 'lucide-svelte';
|
||||
import { authClient } from '$lib/auth';
|
||||
import NotificationBell from '$lib/components/chat/NotificationBell.svelte';
|
||||
|
||||
let currentPath = $derived(page.url.pathname);
|
||||
|
||||
const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>, {});
|
||||
|
||||
// Função para obter a URL do avatar/foto do usuário
|
||||
let avatarUrlDoUsuario = $derived.by(() => {
|
||||
if (!currentUser.data) return null;
|
||||
|
||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
||||
if (currentUser.data.fotoPerfilUrl) {
|
||||
return currentUser.data.fotoPerfilUrl;
|
||||
}
|
||||
|
||||
if (currentUser.data.avatar) {
|
||||
return currentUser.data.avatar;
|
||||
}
|
||||
|
||||
// Fallback: retornar null para usar o ícone User do Lucide
|
||||
return null;
|
||||
});
|
||||
|
||||
function goToLogin(redirectTo?: string) {
|
||||
const target = redirectTo || currentPath || '/';
|
||||
goto(`${resolve('/login')}?redirect=${encodeURIComponent(target)}`);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
const result = await authClient.signOut();
|
||||
if (result.error) {
|
||||
console.error('Sign out error:', result.error);
|
||||
}
|
||||
goto(resolve('/home'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
{#if currentUser.data}
|
||||
<!-- Nome e Perfil -->
|
||||
<div class="hidden flex-col items-end lg:flex">
|
||||
<span class="text-base-content text-sm leading-tight font-semibold"
|
||||
>{currentUser.data.nome}</span
|
||||
>
|
||||
<span class="text-base-content/60 text-xs leading-tight">{currentUser.data.role?.nome}</span>
|
||||
</div>
|
||||
|
||||
<div class="dropdown dropdown-end">
|
||||
<!-- Botão de Perfil com Avatar -->
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="btn avatar ring-base-200 hover:ring-primary/50 h-10 w-10 p-0 ring-2 ring-offset-2 transition-all"
|
||||
aria-label="Menu do usuário"
|
||||
>
|
||||
<div class="h-full w-full overflow-hidden rounded-full">
|
||||
{#if avatarUrlDoUsuario}
|
||||
<img
|
||||
src={avatarUrlDoUsuario}
|
||||
alt={currentUser.data?.nome || 'Usuário'}
|
||||
class="h-full w-full object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<div class="bg-primary/10 text-primary flex h-full w-full items-center justify-center">
|
||||
<User class="h-5 w-5" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box ring-base-content/5 z-1 mt-3 w-56 p-2 shadow-xl ring-1"
|
||||
>
|
||||
<li class="menu-title border-base-200 mb-2 border-b px-4 py-2">
|
||||
<span class="text-base-content font-bold">{currentUser.data?.nome}</span>
|
||||
<span class="text-base-content/60 text-xs font-normal">{currentUser.data.email}</span>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/perfil')} class="active:bg-primary/10 active:text-primary"
|
||||
><UserCog class="mr-2 h-4 w-4" /> Meu Perfil</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a href={resolve('/alterar-senha')} class="active:bg-primary/10 active:text-primary"
|
||||
><Settings class="mr-2 h-4 w-4" /> Alterar Senha</a
|
||||
>
|
||||
</li>
|
||||
<div class="divider my-1"></div>
|
||||
<li>
|
||||
<button type="button" onclick={handleLogout} class="text-error hover:bg-error/10"
|
||||
><LogIn class="mr-2 h-4 w-4 rotate-180" /> Sair</button
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Sino de notificações -->
|
||||
<div class="relative">
|
||||
<NotificationBell />
|
||||
</div>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm rounded-full px-6"
|
||||
onclick={() => goToLogin()}
|
||||
>
|
||||
Entrar
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Calendar } from '@fullcalendar/core';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import multiMonthPlugin from '@fullcalendar/multimonth';
|
||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||
import { onMount } from 'svelte';
|
||||
import { SvelteDate } from 'svelte/reactivity';
|
||||
|
||||
interface Props {
|
||||
periodosExistentes?: Array<{ dataInicio: string; dataFim: string; dias: number }>;
|
||||
periodosExistentes?: Array<{
|
||||
dataInicio: string;
|
||||
dataFim: string;
|
||||
dias: number;
|
||||
}>;
|
||||
onPeriodoAdicionado?: (periodo: { dataInicio: string; dataFim: string; dias: number }) => void;
|
||||
onPeriodoRemovido?: (index: number) => void;
|
||||
maxPeriodos?: number;
|
||||
@@ -17,7 +21,7 @@
|
||||
readonly?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
const {
|
||||
periodosExistentes = [],
|
||||
onPeriodoAdicionado,
|
||||
onPeriodoRemovido,
|
||||
@@ -37,7 +41,7 @@
|
||||
{ bg: '#4facfe', border: '#00c6ff', text: '#ffffff' } // Azul
|
||||
];
|
||||
|
||||
const eventos = $derived.by(() =>
|
||||
let eventos = $derived.by(() =>
|
||||
periodosExistentes.map((periodo, index) => ({
|
||||
id: `periodo-${index}`,
|
||||
title: `Período ${index + 1} (${periodo.dias} dias)`,
|
||||
@@ -99,7 +103,10 @@
|
||||
selectable: !readonly,
|
||||
selectMirror: true,
|
||||
unselectAuto: false,
|
||||
events: eventos.map((evento) => ({ ...evento, extendedProps: { ...evento.extendedProps } })),
|
||||
events: eventos.map((evento) => ({
|
||||
...evento,
|
||||
extendedProps: { ...evento.extendedProps }
|
||||
})),
|
||||
|
||||
// Estilo customizado
|
||||
buttonText: {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
<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';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
}
|
||||
|
||||
let { funcionarioId }: Props = $props();
|
||||
const { funcionarioId }: Props = $props();
|
||||
|
||||
// Queries
|
||||
const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId });
|
||||
@@ -15,20 +15,18 @@
|
||||
funcionarioId
|
||||
});
|
||||
|
||||
const saldos = $derived(saldosQuery.data || []);
|
||||
const solicitacoes = $derived(solicitacoesQuery.data || []);
|
||||
let saldos = $derived(saldosQuery.data || []);
|
||||
let solicitacoes = $derived(solicitacoesQuery.data || []);
|
||||
|
||||
// Estatísticas derivadas
|
||||
const saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
|
||||
const totalSolicitacoes = $derived(solicitacoes.length);
|
||||
const aprovadas = $derived(
|
||||
let saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
|
||||
let totalSolicitacoes = $derived(solicitacoes.length);
|
||||
let aprovadas = $derived(
|
||||
solicitacoes.filter((s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada')
|
||||
.length
|
||||
);
|
||||
const pendentes = $derived(
|
||||
solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length
|
||||
);
|
||||
const reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
|
||||
let pendentes = $derived(solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length);
|
||||
let reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
|
||||
|
||||
// Canvas para gráfico de pizza
|
||||
let canvasSaldo = $state<HTMLCanvasElement>();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { Check, Zap, Clock, Info, AlertTriangle, Calendar, X, Plus, ChevronLeft, ChevronRight, Trash2, CheckCircle } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
@@ -10,7 +9,7 @@
|
||||
onCancelar?: () => void;
|
||||
}
|
||||
|
||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
const { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||
|
||||
// Cliente Convex
|
||||
const client = useConvexClient();
|
||||
@@ -34,18 +33,20 @@
|
||||
let dataFimPeriodo = $state('');
|
||||
|
||||
// Queries
|
||||
const funcionarioQuery = useQuery(api.funcionarios.getById, { id: funcionarioId });
|
||||
const funcionario = $derived(funcionarioQuery?.data);
|
||||
const regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
|
||||
const funcionarioQuery = useQuery(api.funcionarios.getById, {
|
||||
id: funcionarioId
|
||||
});
|
||||
let funcionario = $derived(funcionarioQuery?.data);
|
||||
let regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
|
||||
|
||||
const saldoQuery = $derived(
|
||||
let saldoQuery = $derived(
|
||||
useQuery(api.saldoFerias.obterSaldo, {
|
||||
funcionarioId,
|
||||
anoReferencia: anoSelecionado
|
||||
})
|
||||
);
|
||||
|
||||
const validacaoQuery = $derived(
|
||||
let validacaoQuery = $derived(
|
||||
periodosFerias.length > 0
|
||||
? useQuery(api.saldoFerias.validarSolicitacao, {
|
||||
funcionarioId,
|
||||
@@ -59,18 +60,18 @@
|
||||
);
|
||||
|
||||
// Derivados
|
||||
const saldo = $derived(saldoQuery.data);
|
||||
const validacao = $derived(validacaoQuery.data);
|
||||
const totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0));
|
||||
let saldo = $derived(saldoQuery.data);
|
||||
let validacao = $derived(validacaoQuery.data);
|
||||
let totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0));
|
||||
|
||||
// Anos disponíveis (últimos 3 anos + próximo ano)
|
||||
const anosDisponiveis = $derived.by(() => {
|
||||
let anosDisponiveis = $derived.by(() => {
|
||||
const anoAtual = new Date().getFullYear();
|
||||
return [anoAtual - 1, anoAtual, anoAtual + 1];
|
||||
});
|
||||
|
||||
// Verificar se é regime estatutário PE ou Municipal
|
||||
const ehEstatutarioPEOuMunicipal = $derived(
|
||||
let ehEstatutarioPEOuMunicipal = $derived(
|
||||
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
|
||||
);
|
||||
|
||||
@@ -127,7 +128,9 @@
|
||||
// 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.`);
|
||||
toast.error(
|
||||
`O total não pode exceder 30 dias. Você já tem ${totalDiasSelecionados} dias, adicionando ${dias} dias totalizaria ${novoTotal} dias.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -135,7 +138,9 @@
|
||||
// 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})`);
|
||||
toast.error(
|
||||
`Total de dias (${novoTotal}) excede saldo disponível (${saldo.diasDisponiveis})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -221,18 +226,19 @@
|
||||
}
|
||||
|
||||
// Calcular dias do período atual
|
||||
const diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
|
||||
let 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}
|
||||
@@ -242,29 +248,25 @@
|
||||
style:box-shadow={passoAtual === i + 1 ? '0 0 20px rgba(102, 126, 234, 0.5)' : 'none'}
|
||||
>
|
||||
{#if passoAtual > i + 1}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<Check class="h-6 w-6" strokeWidth={3} />
|
||||
{:else}
|
||||
{i + 1}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Label do passo -->
|
||||
<p
|
||||
class="mt-3 text-center text-sm font-semibold"
|
||||
class:text-primary={passoAtual === i + 1}
|
||||
>
|
||||
{labels[i]}
|
||||
</p>
|
||||
|
||||
<!-- Linha conectora -->
|
||||
{#if i < totalPassos - 1}
|
||||
<div
|
||||
class="mx-2 h-1 flex-1 transition-all duration-300"
|
||||
class="absolute top-6 left-1/2 z-10 h-1 transition-all duration-300"
|
||||
style="width: calc(100% - 1.5rem); margin-left: calc(50% + 0.75rem);"
|
||||
class:bg-primary={passoAtual > i + 1}
|
||||
class:bg-base-300={passoAtual <= i + 1}
|
||||
></div>
|
||||
@@ -272,19 +274,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 -->
|
||||
@@ -309,7 +298,9 @@
|
||||
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}
|
||||
style:box-shadow={anoSelecionado === ano
|
||||
? '0 0 10px rgba(249, 115, 22, 0.3)'
|
||||
: undefined}
|
||||
onclick={() => (anoSelecionado = ano)}
|
||||
>
|
||||
{ano}
|
||||
@@ -332,19 +323,7 @@
|
||||
<div class="stats stats-vertical lg:stats-horizontal w-full shadow-lg">
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block h-8 w-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
></path>
|
||||
</svg>
|
||||
<Zap class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="stat-title">Total Direito</div>
|
||||
<div class="stat-value text-primary">{saldo.diasDireito}</div>
|
||||
@@ -353,19 +332,7 @@
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block h-8 w-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
></path>
|
||||
</svg>
|
||||
<Check class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="stat-title">Disponível</div>
|
||||
<div class="stat-value text-success">
|
||||
@@ -376,19 +343,7 @@
|
||||
|
||||
<div class="stat">
|
||||
<div class="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="inline-block h-8 w-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<Clock class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
|
||||
</div>
|
||||
<div class="stat-title">Usado</div>
|
||||
<div class="stat-value text-warning">{saldo.diasUsados}</div>
|
||||
@@ -398,19 +353,7 @@
|
||||
|
||||
<!-- Informações do Regime -->
|
||||
<div class="alert alert-info mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<Info class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
|
||||
<p class="text-sm">
|
||||
@@ -419,7 +362,8 @@
|
||||
</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.
|
||||
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode
|
||||
exceder 30 dias.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -427,19 +371,7 @@
|
||||
|
||||
{#if saldo.diasDisponiveis === 0}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>Você não tem saldo disponível para este ano.</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -447,19 +379,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>Nenhum saldo encontrado para este ano.</span>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -477,19 +397,7 @@
|
||||
|
||||
<!-- Resumo rápido -->
|
||||
<div class="alert bg-base-200 mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
class="stroke-info h-6 w-6 shrink-0"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<Info class="stroke-info h-6 w-6 shrink-0" />
|
||||
<div>
|
||||
<p>
|
||||
<strong>Saldo disponível:</strong>
|
||||
@@ -500,14 +408,15 @@
|
||||
</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.
|
||||
⚠️ Regras: Períodos de 15 ou 30 dias. Máximo 2 períodos. Total não pode exceder 30
|
||||
dias.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulário para adicionar período -->
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card bg-base-100 mb-6 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Adicionar Período</h3>
|
||||
|
||||
@@ -516,11 +425,7 @@
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Data Início</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
class="input input-bordered"
|
||||
bind:value={dataInicioPeriodo}
|
||||
/>
|
||||
<input type="date" class="input input-bordered" bind:value={dataInicioPeriodo} />
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
@@ -540,7 +445,7 @@
|
||||
<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="text-primary font-bold">{diasPeriodoAtual}</span>
|
||||
<span class="ml-2 text-sm opacity-70">dias</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -553,20 +458,7 @@
|
||||
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>
|
||||
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||
Adicionar Período
|
||||
</button>
|
||||
</div>
|
||||
@@ -575,7 +467,7 @@
|
||||
|
||||
<!-- Lista de períodos adicionados -->
|
||||
{#if periodosFerias.length > 0}
|
||||
<div class="card bg-base-100 shadow-lg mb-6">
|
||||
<div class="card bg-base-100 mb-6 shadow-lg">
|
||||
<div class="card-body">
|
||||
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
|
||||
<div class="space-y-3">
|
||||
@@ -601,20 +493,7 @@
|
||||
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>
|
||||
<Trash2 class="h-4 w-4" strokeWidth={2} />
|
||||
Remover
|
||||
</button>
|
||||
</div>
|
||||
@@ -629,36 +508,12 @@
|
||||
<div class="mt-6">
|
||||
{#if validacao.valido}
|
||||
<div class="alert alert-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<span>✅ Períodos válidos! Total: {validacao.totalDias} dias</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<X class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<p class="font-bold">Erros encontrados:</p>
|
||||
<ul class="list-inside list-disc">
|
||||
@@ -672,19 +527,7 @@
|
||||
|
||||
{#if validacao.avisos.length > 0}
|
||||
<div class="alert alert-warning mt-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6 shrink-0 stroke-current"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||
<div>
|
||||
<p class="font-bold">Avisos:</p>
|
||||
<ul class="list-inside list-disc">
|
||||
@@ -774,20 +617,7 @@
|
||||
<div>
|
||||
{#if passoAtual > 1}
|
||||
<button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronLeft class="h-5 w-5" strokeWidth={2} />
|
||||
Voltar
|
||||
</button>
|
||||
{:else if onCancelar}
|
||||
@@ -804,20 +634,7 @@
|
||||
disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)}
|
||||
>
|
||||
Próximo
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
<ChevronRight class="h-5 w-5" strokeWidth={2} />
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
@@ -830,20 +647,7 @@
|
||||
<span class="loading loading-spinner"></span>
|
||||
Enviando...
|
||||
{:else}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<Check class="h-5 w-5" strokeWidth={2} />
|
||||
Enviar Solicitação
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
29
apps/web/src/lib/components/layout/Breadcrumbs.svelte
Normal file
29
apps/web/src/lib/components/layout/Breadcrumbs.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
export type BreadcrumbItem = {
|
||||
label: string;
|
||||
href?: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
items: BreadcrumbItem[];
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { items, class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['breadcrumbs mb-4 text-sm', className].filter(Boolean)}>
|
||||
<ul>
|
||||
{#each items as item (item.label)}
|
||||
<li>
|
||||
{#if item.href}
|
||||
<a href={item.href} class="text-primary hover:underline">{item.label}</a>
|
||||
{:else}
|
||||
{item.label}
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
50
apps/web/src/lib/components/layout/PageHeader.svelte
Normal file
50
apps/web/src/lib/components/layout/PageHeader.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
|
||||
icon?: Snippet;
|
||||
actions?: Snippet;
|
||||
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
actions,
|
||||
class: className = ''
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class={['mb-6', className].filter(Boolean)}>
|
||||
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
<div class="flex items-center gap-4">
|
||||
{#if icon}
|
||||
<div class="bg-primary/10 rounded-xl p-3">
|
||||
<div class="text-primary [&_svg]:h-8 [&_svg]:w-8">
|
||||
{@render icon()}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
<h1 class="text-primary text-3xl font-bold">{title}</h1>
|
||||
{#if subtitle}
|
||||
<p class="text-base-content/70">{subtitle}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if actions}
|
||||
<div class="flex items-center gap-2">
|
||||
{@render actions()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
16
apps/web/src/lib/components/layout/PageShell.svelte
Normal file
16
apps/web/src/lib/components/layout/PageShell.svelte
Normal file
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
children?: Snippet;
|
||||
}
|
||||
|
||||
let { class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<main class={['container mx-auto flex max-w-7xl flex-col px-4 py-4', className].filter(Boolean)}>
|
||||
{@render children?.()}
|
||||
</main>
|
||||
|
||||
|
||||
62
apps/web/src/lib/components/login/LoginInput.svelte
Normal file
62
apps/web/src/lib/components/login/LoginInput.svelte
Normal file
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { Field } from '@ark-ui/svelte/field';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLInputAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
label: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
autocomplete?: HTMLInputAttributes['autocomplete'];
|
||||
disabled?: boolean;
|
||||
required?: boolean;
|
||||
error?: string | null;
|
||||
right?: Snippet;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
id,
|
||||
label,
|
||||
type = 'text',
|
||||
placeholder = '',
|
||||
autocomplete,
|
||||
disabled = false,
|
||||
required = false,
|
||||
error = null,
|
||||
right,
|
||||
value = $bindable('')
|
||||
}: Props = $props();
|
||||
|
||||
const invalid = $derived(!!error);
|
||||
</script>
|
||||
|
||||
<Field.Root {invalid} {required} class="space-y-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<Field.Label
|
||||
for={id}
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wider uppercase"
|
||||
>
|
||||
{label}
|
||||
</Field.Label>
|
||||
{@render right?.()}
|
||||
</div>
|
||||
|
||||
<div class="group relative">
|
||||
<Field.Input
|
||||
{id}
|
||||
{type}
|
||||
{placeholder}
|
||||
{disabled}
|
||||
{autocomplete}
|
||||
{required}
|
||||
bind:value
|
||||
class="border-base-content/10 bg-base-200/25 text-base-content placeholder-base-content/40 focus:border-primary/50 focus:bg-base-200/35 focus:ring-primary/20 w-full rounded-xl border px-4 py-3 transition-all duration-300 focus:ring-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<Field.ErrorText class="text-error text-sm font-medium">{error}</Field.ErrorText>
|
||||
{/if}
|
||||
</Field.Root>
|
||||
1253
apps/web/src/lib/components/ponto/BancoHorasMensal.svelte
Normal file
1253
apps/web/src/lib/components/ponto/BancoHorasMensal.svelte
Normal file
File diff suppressed because it is too large
Load Diff
684
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
684
apps/web/src/lib/components/ponto/ComprovantePonto.svelte
Normal file
@@ -0,0 +1,684 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import jsPDF from 'jspdf';
|
||||
import autoTable from 'jspdf-autotable';
|
||||
import { Calendar, CheckCircle2, Clock, MapPin, Printer, User, X, XCircle } from 'lucide-svelte';
|
||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||
|
||||
interface Props {
|
||||
registroId: Id<'registrosPonto'>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const { registroId, onClose }: Props = $props();
|
||||
|
||||
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||
|
||||
let gerando = $state(false);
|
||||
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
||||
|
||||
// Função para calcular a posição baseada no card de registro de ponto
|
||||
function calcularPosicaoModal() {
|
||||
// Procurar pelo elemento do card de registro de ponto
|
||||
const cardRef = document.getElementById('card-registro-ponto-ref');
|
||||
|
||||
if (cardRef) {
|
||||
const rect = cardRef.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
|
||||
const top = rect.top;
|
||||
|
||||
// Garantir que o modal não saia da viewport
|
||||
// Considerar uma altura mínima do modal (aproximadamente 300px)
|
||||
const minTop = 20;
|
||||
const maxTop = viewportHeight - 350; // Deixar espaço para o modal
|
||||
const finalTop = Math.max(minTop, Math.min(top, maxTop));
|
||||
|
||||
// Centralizar horizontalmente
|
||||
return {
|
||||
top: finalTop,
|
||||
left: window.innerWidth / 2
|
||||
};
|
||||
}
|
||||
|
||||
// Se não encontrar, usar posição padrão (centro da tela)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Atualizar posição quando o modal for aberto (quando registroQuery tiver dados)
|
||||
$effect(() => {
|
||||
if (registroQuery?.data) {
|
||||
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
|
||||
const updatePosition = () => {
|
||||
requestAnimationFrame(() => {
|
||||
const pos = calcularPosicaoModal();
|
||||
if (pos) {
|
||||
modalPosition = pos;
|
||||
} else {
|
||||
// Fallback para centralização
|
||||
modalPosition = {
|
||||
top: window.innerHeight / 2,
|
||||
left: window.innerWidth / 2
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Aguardar um pouco para garantir que o DOM está atualizado
|
||||
setTimeout(updatePosition, 50);
|
||||
|
||||
// Adicionar listener de scroll para atualizar posição
|
||||
const handleScroll = () => {
|
||||
updatePosition();
|
||||
};
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleScroll);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleScroll);
|
||||
};
|
||||
} else {
|
||||
// Limpar posição quando o modal for fechado
|
||||
modalPosition = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Função para obter estilo do modal baseado na posição calculada
|
||||
function getModalStyle() {
|
||||
if (modalPosition) {
|
||||
// Posicionar na altura do card, centralizado horizontalmente
|
||||
// position: fixed já é relativo à viewport, então podemos usar diretamente
|
||||
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
|
||||
}
|
||||
// Se não houver posição calculada, centralizar na tela
|
||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
|
||||
}
|
||||
|
||||
async function gerarPDF() {
|
||||
if (!registroQuery?.data) return;
|
||||
|
||||
gerando = true;
|
||||
|
||||
try {
|
||||
const registro = registroQuery.data;
|
||||
const doc = new jsPDF();
|
||||
|
||||
// Adicionar logo no canto superior esquerdo
|
||||
let yPosition = 20;
|
||||
try {
|
||||
const logoImg = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = (err) => reject(err);
|
||||
setTimeout(() => reject(new Error('Timeout loading logo')), 3000);
|
||||
img.src = logoGovPE;
|
||||
});
|
||||
|
||||
const logoWidth = 25;
|
||||
const aspectRatio = logoImg.height / logoImg.width;
|
||||
const logoHeight = logoWidth * aspectRatio;
|
||||
|
||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||
yPosition = 10 + logoHeight + 10;
|
||||
} catch (err) {
|
||||
console.warn('Erro ao carregar logo:', err);
|
||||
yPosition = 20;
|
||||
}
|
||||
|
||||
// Cabeçalho padrão do sistema (centralizado)
|
||||
doc.setFontSize(14);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text('GOVERNO DO ESTADO DE PERNAMBUCO', 105, Math.max(yPosition - 10, 20), {
|
||||
align: 'center'
|
||||
});
|
||||
doc.setFontSize(12);
|
||||
doc.text('SECRETARIA DE ESPORTES', 105, Math.max(yPosition - 2, 28), {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
yPosition = Math.max(yPosition, 40);
|
||||
yPosition += 10;
|
||||
|
||||
// Título do comprovante
|
||||
doc.setFontSize(16);
|
||||
doc.setTextColor(102, 126, 234); // Cor primária padrão do sistema
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
|
||||
yPosition += 15;
|
||||
|
||||
// Informações do Funcionário em tabela
|
||||
const funcionarioData: string[][] = [];
|
||||
|
||||
if (registro.funcionario) {
|
||||
if (registro.funcionario.matricula) {
|
||||
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
|
||||
}
|
||||
funcionarioData.push(['Nome', registro.funcionario.nome || '-']);
|
||||
if (registro.funcionario.descricaoCargo) {
|
||||
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
|
||||
}
|
||||
if (registro.funcionario.simbolo) {
|
||||
const simboloTipo =
|
||||
registro.funcionario.simbolo.tipo === 'cargo_comissionado'
|
||||
? 'Cargo Comissionado'
|
||||
: 'Função Gratificada';
|
||||
funcionarioData.push([
|
||||
'Símbolo',
|
||||
`${registro.funcionario.simbolo.nome} (${simboloTipo})`
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (funcionarioData.length > 0) {
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
||||
yPosition += 8;
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['Campo', 'Informação']],
|
||||
body: funcionarioData,
|
||||
theme: 'striped',
|
||||
headStyles: { fillColor: [102, 126, 234] },
|
||||
styles: { fontSize: 10 },
|
||||
margin: { left: 15, right: 15 }
|
||||
});
|
||||
|
||||
type JsPDFWithAutoTable = jsPDF & {
|
||||
lastAutoTable?: { finalY: number };
|
||||
};
|
||||
const finalY = (doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPosition + 10;
|
||||
yPosition = finalY + 10;
|
||||
}
|
||||
|
||||
// Informações do Registro em tabela
|
||||
const config = configQuery?.data;
|
||||
const tipoLabel = config
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: config.nomeEntrada,
|
||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||
nomeSaida: config.nomeSaida
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo);
|
||||
|
||||
const dataHora = formatarDataHoraCompleta(
|
||||
registro.data,
|
||||
registro.hora,
|
||||
registro.minuto,
|
||||
registro.segundo
|
||||
);
|
||||
|
||||
const registroData: string[][] = [
|
||||
['Tipo', tipoLabel],
|
||||
['Data e Hora', dataHora],
|
||||
['Status', registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'],
|
||||
['Tolerância', `${registro.toleranciaMinutos} minutos`],
|
||||
['Sincronizado', registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)']
|
||||
];
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
||||
yPosition += 8;
|
||||
|
||||
autoTable(doc, {
|
||||
startY: yPosition,
|
||||
head: [['Campo', 'Informação']],
|
||||
body: registroData,
|
||||
theme: 'striped',
|
||||
headStyles: { fillColor: [102, 126, 234] },
|
||||
styles: { fontSize: 10 },
|
||||
margin: { left: 15, right: 15 }
|
||||
});
|
||||
|
||||
type JsPDFWithAutoTable2 = jsPDF & {
|
||||
lastAutoTable?: { finalY: number };
|
||||
};
|
||||
const finalY2 = (doc as JsPDFWithAutoTable2).lastAutoTable?.finalY ?? yPosition + 10;
|
||||
yPosition = finalY2 + 10;
|
||||
|
||||
// Imagem capturada (se disponível)
|
||||
if (registro.imagemUrl) {
|
||||
yPosition += 10;
|
||||
// Verificar se precisa de nova página
|
||||
if (yPosition > 200) {
|
||||
doc.addPage();
|
||||
yPosition = 20;
|
||||
}
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.setFont('helvetica', 'bold');
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.text('FOTO CAPTURADA', 15, yPosition);
|
||||
doc.setFont('helvetica', 'normal');
|
||||
yPosition += 10;
|
||||
|
||||
try {
|
||||
// Carregar imagem usando fetch para evitar problemas de CORS
|
||||
const response = await fetch(registro.imagemUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error('Erro ao carregar imagem');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const reader = new FileReader();
|
||||
|
||||
// Converter blob para base64
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
if (typeof reader.result === 'string') {
|
||||
resolve(reader.result);
|
||||
} else {
|
||||
reject(new Error('Erro ao converter imagem'));
|
||||
}
|
||||
};
|
||||
reader.onerror = () => reject(new Error('Erro ao ler imagem'));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
|
||||
// Criar elemento de imagem para obter dimensões
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve();
|
||||
img.onerror = () => reject(new Error('Erro ao processar imagem'));
|
||||
img.src = base64;
|
||||
setTimeout(() => reject(new Error('Timeout ao processar imagem')), 10000);
|
||||
});
|
||||
|
||||
// Calcular dimensões para caber na página (largura máxima 80mm, manter proporção)
|
||||
const maxWidth = 80;
|
||||
const maxHeight = 60;
|
||||
let imgWidth = img.width;
|
||||
let imgHeight = img.height;
|
||||
const aspectRatio = imgWidth / imgHeight;
|
||||
|
||||
if (imgWidth > maxWidth || imgHeight > maxHeight) {
|
||||
if (aspectRatio > 1) {
|
||||
// Imagem horizontal
|
||||
imgWidth = maxWidth;
|
||||
imgHeight = maxWidth / aspectRatio;
|
||||
} else {
|
||||
// Imagem vertical
|
||||
imgHeight = maxHeight;
|
||||
imgWidth = maxHeight * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
// Centralizar imagem
|
||||
const xPosition = (doc.internal.pageSize.getWidth() - imgWidth) / 2;
|
||||
|
||||
// Verificar se cabe na página atual
|
||||
if (yPosition + imgHeight > doc.internal.pageSize.getHeight() - 20) {
|
||||
doc.addPage();
|
||||
yPosition = 20;
|
||||
}
|
||||
|
||||
// Adicionar imagem ao PDF usando base64
|
||||
doc.addImage(base64, 'JPEG', xPosition, yPosition, imgWidth, imgHeight);
|
||||
yPosition += imgHeight + 10;
|
||||
} catch (error) {
|
||||
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
||||
doc.setFontSize(10);
|
||||
doc.text('Foto não disponível para impressão', 105, yPosition, {
|
||||
align: 'center'
|
||||
});
|
||||
yPosition += 6;
|
||||
}
|
||||
}
|
||||
|
||||
// Rodapé
|
||||
const pageCount = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= pageCount; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(128, 128, 128);
|
||||
doc.text(
|
||||
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
|
||||
doc.internal.pageSize.getWidth() / 2,
|
||||
doc.internal.pageSize.getHeight() - 10,
|
||||
{ align: 'center' }
|
||||
);
|
||||
}
|
||||
|
||||
// Salvar
|
||||
const nomeArquivo = `comprovante-ponto-${registro.data}-${registro.hora}${registro.minuto.toString().padStart(2, '0')}.pdf`;
|
||||
doc.save(nomeArquivo);
|
||||
} catch (error) {
|
||||
console.error('Erro ao gerar PDF:', error);
|
||||
alert('Erro ao gerar comprovante PDF. Tente novamente.');
|
||||
} finally {
|
||||
gerando = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="pointer-events-none fixed inset-0 z-50"
|
||||
style="animation: fadeIn 0.2s ease-out;"
|
||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-comprovante-title"
|
||||
>
|
||||
<!-- Backdrop leve -->
|
||||
<div
|
||||
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
|
||||
onclick={onClose}
|
||||
></div>
|
||||
|
||||
<!-- Modal Box -->
|
||||
<div
|
||||
class="from-base-100 via-base-100 to-primary/5 border-primary/20 pointer-events-auto absolute z-10 flex max-h-[90vh] w-full max-w-2xl transform flex-col overflow-hidden rounded-2xl border-2 bg-gradient-to-br shadow-2xl transition-all duration-300"
|
||||
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Header Premium com gradiente -->
|
||||
<div
|
||||
class="from-primary/10 via-primary/5 border-primary/20 flex flex-shrink-0 items-center justify-between border-b-2 bg-gradient-to-r to-transparent px-6 py-5"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="bg-primary/20 rounded-xl p-2.5 shadow-lg">
|
||||
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 id="modal-comprovante-title" class="text-base-content text-xl font-bold">
|
||||
Comprovante de Registro de Ponto
|
||||
</h3>
|
||||
<p class="text-base-content/70 mt-0.5 text-sm">Detalhes do registro realizado</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all"
|
||||
onclick={onClose}
|
||||
>
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conteúdo com rolagem -->
|
||||
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||
{#if registroQuery === undefined}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if !registroQuery?.data}
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<XCircle class="h-5 w-5" />
|
||||
<span class="font-semibold">Erro ao carregar registro</span>
|
||||
</div>
|
||||
{:else}
|
||||
{@const registro = registroQuery.data}
|
||||
<div class="space-y-6">
|
||||
<!-- Informações do Funcionário -->
|
||||
<div
|
||||
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<User class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="text-base-content text-lg font-bold">Dados do Funcionário</h4>
|
||||
</div>
|
||||
{#if registro.funcionario}
|
||||
<div class="space-y-3">
|
||||
{#if registro.funcionario.matricula}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Matrícula</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-base font-semibold">
|
||||
{registro.funcionario.matricula}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Nome</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-base font-semibold">
|
||||
{registro.funcionario.nome}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if registro.funcionario.descricaoCargo}
|
||||
<div
|
||||
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<span
|
||||
class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Cargo/Função</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-base font-semibold">
|
||||
{registro.funcionario.descricaoCargo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Informações do Registro -->
|
||||
<div
|
||||
class="card from-primary/5 to-primary/10 border-primary/20 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="bg-primary/20 rounded-lg p-2">
|
||||
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
<h4 class="text-base-content text-lg font-bold">Dados do Registro</h4>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Tipo -->
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Tipo</span
|
||||
>
|
||||
<p class="text-primary mt-1 text-lg font-bold">
|
||||
{configQuery?.data
|
||||
? getTipoRegistroLabel(registro.tipo, {
|
||||
nomeEntrada: configQuery.data.nomeEntrada,
|
||||
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
|
||||
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
|
||||
nomeSaida: configQuery.data.nomeSaida
|
||||
})
|
||||
: getTipoRegistroLabel(registro.tipo)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Data e Hora -->
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Data e Hora</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-lg font-bold">
|
||||
{formatarDataHoraCompleta(
|
||||
registro.data,
|
||||
registro.hora,
|
||||
registro.minuto,
|
||||
registro.segundo
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Status</span
|
||||
>
|
||||
<div class="mt-2">
|
||||
<span
|
||||
class="badge badge-lg gap-2 {registro.dentroDoPrazo
|
||||
? 'badge-success'
|
||||
: 'badge-error'}"
|
||||
>
|
||||
{#if registro.dentroDoPrazo}
|
||||
<CheckCircle2 class="h-4 w-4" />
|
||||
{:else}
|
||||
<XCircle class="h-4 w-4" />
|
||||
{/if}
|
||||
{registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tolerância -->
|
||||
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||
>Tolerância</span
|
||||
>
|
||||
<p class="text-base-content mt-1 text-lg font-bold">
|
||||
{registro.toleranciaMinutos} minutos
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imagem Capturada -->
|
||||
{#if registro.imagemUrl}
|
||||
<div
|
||||
class="card from-base-100 to-base-200 border-primary/10 border-2 bg-gradient-to-br shadow-lg transition-all hover:shadow-xl"
|
||||
>
|
||||
<div class="card-body p-6">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-primary h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-base-content text-lg font-bold">Foto Capturada</h4>
|
||||
</div>
|
||||
<div
|
||||
class="bg-base-100 border-primary/20 flex justify-center rounded-xl border-2 p-4"
|
||||
>
|
||||
<img
|
||||
src={registro.imagemUrl}
|
||||
alt="Foto do registro de ponto"
|
||||
class="max-h-[300px] max-w-full rounded-lg object-contain shadow-md"
|
||||
onerror={(e) => {
|
||||
console.error('Erro ao carregar imagem:', e);
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer fixo com botões -->
|
||||
<div
|
||||
class="border-primary/20 bg-base-100/50 flex flex-shrink-0 justify-end gap-3 border-t-2 px-6 py-4 backdrop-blur-sm"
|
||||
>
|
||||
<button class="btn btn-outline gap-2" onclick={onClose}>
|
||||
<X class="h-4 w-4" />
|
||||
Fechar
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||
onclick={gerarPDF}
|
||||
disabled={gerando}
|
||||
>
|
||||
{#if gerando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Gerando...
|
||||
{:else}
|
||||
<Printer class="h-5 w-5" />
|
||||
Imprimir Comprovante
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Scrollbar customizada para os modais */
|
||||
:global(.modal-scroll) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--bc) / 0.3) transparent;
|
||||
scroll-behavior: smooth;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-thumb) {
|
||||
background-color: hsl(var(--bc) / 0.3);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
:global(.modal-scroll::-webkit-scrollbar-thumb:hover) {
|
||||
background-color: hsl(var(--bc) / 0.5);
|
||||
}
|
||||
</style>
|
||||
24
apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte
Normal file
24
apps/web/src/lib/components/ponto/LocalizacaoIcon.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, HelpCircle, MapPin } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
dentroRaioPermitido: boolean | null | undefined;
|
||||
showTooltip?: boolean;
|
||||
}
|
||||
|
||||
const { dentroRaioPermitido, showTooltip = true }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if dentroRaioPermitido === true}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
|
||||
<MapPin class="text-success h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
{:else if dentroRaioPermitido === false}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
|
||||
<AlertCircle class="text-error h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
|
||||
<HelpCircle class="text-base-content/40 h-5 w-5" strokeWidth={2.5} />
|
||||
</div>
|
||||
{/if}
|
||||
193
apps/web/src/lib/components/ponto/PrintPontoModal.svelte
Normal file
193
apps/web/src/lib/components/ponto/PrintPontoModal.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import { CheckCircle2, Printer, X } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
funcionarioId: Id<'funcionarios'>;
|
||||
onClose: () => void;
|
||||
onGenerate: (sections: {
|
||||
dadosFuncionario: boolean;
|
||||
registrosPonto: boolean;
|
||||
saldoDiario: boolean;
|
||||
bancoHoras: boolean;
|
||||
alteracoesGestor: boolean;
|
||||
dispensasRegistro: boolean;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const { funcionarioId, onClose, onGenerate }: Props = $props();
|
||||
|
||||
let modalRef: HTMLDialogElement;
|
||||
|
||||
// Seções selecionáveis
|
||||
let sections = $state({
|
||||
dadosFuncionario: true,
|
||||
registrosPonto: true,
|
||||
saldoDiario: true,
|
||||
bancoHoras: true,
|
||||
alteracoesGestor: true,
|
||||
dispensasRegistro: true
|
||||
});
|
||||
|
||||
function selectAll() {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = true;
|
||||
});
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
Object.keys(sections).forEach((key) => {
|
||||
sections[key as keyof typeof sections] = false;
|
||||
});
|
||||
}
|
||||
|
||||
function handleGenerate() {
|
||||
onGenerate(sections);
|
||||
// Não chamar onClose() aqui - o modal será fechado pelo callback onSuccess
|
||||
// após a geração do PDF ser concluída com sucesso
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (modalRef) {
|
||||
modalRef.close();
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (modalRef) {
|
||||
modalRef.showModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<dialog bind:this={modalRef} class="modal modal-open">
|
||||
<div class="modal-box max-w-4xl">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h3 class="text-2xl font-bold">Selecionar Campos para Impressão</h3>
|
||||
<button class="btn btn-sm btn-circle btn-ghost" onclick={handleClose} aria-label="Fechar">
|
||||
<X class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<!-- Seção 1: Dados do Funcionário -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Dados do Funcionário</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.dadosFuncionario}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Nome, matrícula, cargo e informações básicas
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 2: Registros de Ponto -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Registros de Ponto</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.registrosPonto}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Data, tipo, horário e status de cada registro
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 3: Saldo Diário -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Saldo Diário</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.saldoDiario}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Saldo em horas e minutos de cada dia (positivo/negativo)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 4: Banco de Horas -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Banco de Horas</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.bancoHoras}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-base-content/70 mt-2 text-sm">Saldo acumulado do banco de horas</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 5: Alterações pelo Gestor -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Alterações pelo Gestor</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.alteracoesGestor}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Edições e ajustes realizados pelo gestor (se houver)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seção 6: Dispensas de Registro -->
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body p-4">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">Dispensas de Registro</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
bind:checked={sections.dispensasRegistro}
|
||||
/>
|
||||
</label>
|
||||
<p class="text-base-content/70 mt-2 text-sm">
|
||||
Períodos onde o funcionário esteve dispensado de registrar ponto
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-outline" onclick={selectAll}> Selecionar Todos </button>
|
||||
<button class="btn btn-sm btn-outline" onclick={deselectAll}> Desmarcar Todos </button>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-ghost" onclick={handleClose}> Cancelar </button>
|
||||
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
|
||||
<Printer class="h-4 w-4" />
|
||||
Gerar PDF
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop" onsubmit={handleClose}>
|
||||
<button type="submit">fechar</button>
|
||||
</form>
|
||||
</dialog>
|
||||
1983
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
1983
apps/web/src/lib/components/ponto/RegistroPonto.svelte
Normal file
File diff suppressed because it is too large
Load Diff
157
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
157
apps/web/src/lib/components/ponto/RelogioSincronizado.svelte
Normal file
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { AlertCircle, CheckCircle2, Clock } from 'lucide-svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { obterTempoPC, obterTempoServidor } from '$lib/utils/sincronizacaoTempo';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
let tempoAtual = $state<Date>(new Date());
|
||||
let sincronizado = $state(false);
|
||||
let usandoServidorExterno = $state(false);
|
||||
let offsetSegundos = $state(0);
|
||||
let erro = $state<string | null>(null);
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
async function atualizarTempo() {
|
||||
try {
|
||||
const config = await client.query(api.configuracaoRelogio.obterConfiguracao, {});
|
||||
// Usar gmtOffset da configuração, sem valor padrão, pois 0 é um valor válido
|
||||
// Se não estiver configurado, usar null e tratar como 0
|
||||
const gmtOffset = config.gmtOffset ?? 0;
|
||||
|
||||
let timestampBase: number;
|
||||
|
||||
if (config.usarServidorExterno) {
|
||||
try {
|
||||
const resultado = await client.action(api.configuracaoRelogio.sincronizarTempo, {});
|
||||
if (resultado.sucesso && resultado.timestamp) {
|
||||
timestampBase = resultado.timestamp;
|
||||
sincronizado = true;
|
||||
usandoServidorExterno = resultado.usandoServidorExterno || false;
|
||||
offsetSegundos = resultado.offsetSegundos || 0;
|
||||
erro = null;
|
||||
} else {
|
||||
throw new Error('Falha ao sincronizar');
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Erro ao sincronizar:', error);
|
||||
if (config.fallbackParaPC) {
|
||||
timestampBase = obterTempoPC();
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC (falha na sincronização)';
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Usar relógio do PC (sem sincronização com servidor)
|
||||
timestampBase = obterTempoPC();
|
||||
sincronizado = false;
|
||||
usandoServidorExterno = false;
|
||||
erro = 'Usando relógio do PC';
|
||||
}
|
||||
|
||||
// Aplicar GMT offset ao timestamp UTC
|
||||
// O offset é aplicado manualmente, então usamos UTC como base para evitar conversão dupla
|
||||
let timestampAjustado: number;
|
||||
if (gmtOffset !== 0) {
|
||||
// Aplicar offset configurado ao timestamp UTC
|
||||
timestampAjustado = timestampBase + gmtOffset * 60 * 60 * 1000;
|
||||
} else {
|
||||
// Quando GMT = 0, manter timestamp UTC puro
|
||||
timestampAjustado = timestampBase;
|
||||
}
|
||||
// Armazenar o timestamp ajustado (não o Date, para evitar problemas de timezone)
|
||||
tempoAtual = new Date(timestampAjustado);
|
||||
} catch (error) {
|
||||
console.error('Erro ao obter tempo:', error);
|
||||
tempoAtual = new Date(obterTempoPC());
|
||||
sincronizado = false;
|
||||
erro = 'Erro ao obter tempo do servidor';
|
||||
}
|
||||
}
|
||||
|
||||
function atualizarRelogio() {
|
||||
// Atualizar segundo a segundo
|
||||
const agora = new Date(tempoAtual.getTime() + 1000);
|
||||
tempoAtual = agora;
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await atualizarTempo();
|
||||
// Sincronizar a cada 30 segundos
|
||||
setInterval(atualizarTempo, 30000);
|
||||
// Atualizar display a cada segundo
|
||||
intervalId = setInterval(atualizarRelogio, 1000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
|
||||
const horaFormatada = $derived.by(() => {
|
||||
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
|
||||
// Isso evita conversão dupla pelo navegador
|
||||
return tempoAtual.toLocaleTimeString('pt-BR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
|
||||
});
|
||||
});
|
||||
|
||||
const dataFormatada = $derived.by(() => {
|
||||
// Usar UTC como base pois já aplicamos o offset manualmente no timestamp
|
||||
// Isso evita conversão dupla pelo navegador
|
||||
return tempoAtual.toLocaleDateString('pt-BR', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC' // Usar UTC como base pois já aplicamos o offset manualmente
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
<!-- Hora -->
|
||||
<div class="text-primary font-mono text-5xl font-black tracking-tight drop-shadow-sm">
|
||||
{horaFormatada}
|
||||
</div>
|
||||
|
||||
<!-- Data -->
|
||||
<div class="text-base-content/80 text-base font-semibold capitalize">
|
||||
{dataFormatada}
|
||||
</div>
|
||||
|
||||
<!-- Status de Sincronização -->
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-full px-4 py-2 {sincronizado
|
||||
? 'bg-success/20 text-success border-success/30 border'
|
||||
: erro
|
||||
? 'bg-warning/20 text-warning border-warning/30 border'
|
||||
: 'bg-base-300/50 text-base-content/60 border-base-300 border'}"
|
||||
>
|
||||
{#if sincronizado}
|
||||
<CheckCircle2 class="h-4 w-4" strokeWidth={2.5} />
|
||||
<span class="text-sm font-semibold">
|
||||
{#if usandoServidorExterno}
|
||||
Sincronizado com servidor NTP
|
||||
{:else}
|
||||
Sincronizado com servidor
|
||||
{/if}
|
||||
</span>
|
||||
{:else if erro}
|
||||
<AlertCircle class="h-4 w-4" strokeWidth={2.5} />
|
||||
<span class="text-sm font-semibold">{erro}</span>
|
||||
{:else}
|
||||
<Clock class="h-4 w-4" strokeWidth={2.5} />
|
||||
<span class="text-sm font-semibold">Usando relógio do PC</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
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';
|
||||
}
|
||||
|
||||
const { saldo, size = 'md' }: Props = $props();
|
||||
|
||||
function formatarSaldo(saldo: NonNullable<Props['saldo']>): string {
|
||||
const sinal = saldo.positivo ? '+' : '-';
|
||||
return `${sinal}${saldo.horas}h ${saldo.minutos}min`;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'badge-sm',
|
||||
md: 'badge-lg',
|
||||
lg: 'badge-xl'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if saldo}
|
||||
<span
|
||||
class="badge font-semibold shadow-sm {sizeClasses[size]} {saldo.positivo
|
||||
? 'badge-success'
|
||||
: 'badge-error'}"
|
||||
>
|
||||
{formatarSaldo(saldo)}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="badge badge-ghost {sizeClasses[size]}">-</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
saldo?: {
|
||||
trabalhadoMinutos: number;
|
||||
esperadoMinutos: number;
|
||||
diferencaMinutos: number;
|
||||
} | null;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const { saldo, size = 'md' }: Props = $props();
|
||||
|
||||
function formatarMinutos(minutos: number): { horas: number; minutos: number } {
|
||||
const horas = Math.floor(Math.abs(minutos) / 60);
|
||||
const mins = Math.abs(minutos) % 60;
|
||||
return { horas, minutos: mins };
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'text-xs px-2 py-1',
|
||||
md: 'text-sm px-3 py-1.5',
|
||||
lg: 'text-base px-4 py-2'
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if saldo}
|
||||
{@const trabalhado = formatarMinutos(saldo.trabalhadoMinutos)}
|
||||
{@const diferenca = formatarMinutos(saldo.diferencaMinutos)}
|
||||
{@const sinalDiferenca = saldo.diferencaMinutos >= 0 ? '+' : '-'}
|
||||
{@const isNegativo = saldo.diferencaMinutos < 0}
|
||||
|
||||
<div
|
||||
class="inline-flex items-center gap-1.5 {sizeClasses[
|
||||
size
|
||||
]} rounded-lg border font-semibold shadow-sm {isNegativo
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
: 'border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/20 dark:text-green-400'}"
|
||||
>
|
||||
<span class="font-bold text-green-600 dark:text-green-400"
|
||||
>+{trabalhado.horas}h {trabalhado.minutos}min</span
|
||||
>
|
||||
<span class="text-base-content/50">/</span>
|
||||
<span
|
||||
class={isNegativo ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}
|
||||
>
|
||||
{sinalDiferenca}{diferenca.horas}h {diferenca.minutos}min
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="badge badge-ghost {sizeClasses[size]}">-</span>
|
||||
{/if}
|
||||
163
apps/web/src/lib/components/ponto/TimePicker.svelte
Normal file
163
apps/web/src/lib/components/ponto/TimePicker.svelte
Normal file
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import { ChevronUp, ChevronDown } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
hours: number;
|
||||
minutes: number;
|
||||
onChange: (hours: number, minutes: number) => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { hours, minutes, onChange, label, disabled = false }: Props = $props();
|
||||
|
||||
function incrementHours() {
|
||||
if (disabled) return;
|
||||
const newHours = hours + 1;
|
||||
onChange(newHours, minutes);
|
||||
}
|
||||
|
||||
function decrementHours() {
|
||||
if (disabled) return;
|
||||
const newHours = Math.max(0, hours - 1);
|
||||
onChange(newHours, minutes);
|
||||
}
|
||||
|
||||
function incrementMinutes() {
|
||||
if (disabled) return;
|
||||
const newMinutes = minutes + 15;
|
||||
if (newMinutes >= 60) {
|
||||
const extraHours = Math.floor(newMinutes / 60);
|
||||
const remainingMinutes = newMinutes % 60;
|
||||
onChange(hours + extraHours, remainingMinutes);
|
||||
} else {
|
||||
onChange(hours, newMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
function decrementMinutes() {
|
||||
if (disabled) return;
|
||||
const newMinutes = minutes - 15;
|
||||
if (newMinutes < 0) {
|
||||
if (hours > 0) {
|
||||
onChange(hours - 1, 60 + newMinutes);
|
||||
} else {
|
||||
onChange(0, 0);
|
||||
}
|
||||
} else {
|
||||
onChange(hours, newMinutes);
|
||||
}
|
||||
}
|
||||
|
||||
function handleHoursInput(e: Event) {
|
||||
if (disabled) return;
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = parseInt(target.value) || 0;
|
||||
onChange(Math.max(0, value), minutes);
|
||||
}
|
||||
|
||||
function handleMinutesInput(e: Event) {
|
||||
if (disabled) return;
|
||||
const target = e.target as HTMLInputElement;
|
||||
const value = parseInt(target.value) || 0;
|
||||
const clampedValue = Math.max(0, Math.min(59, value));
|
||||
onChange(hours, clampedValue);
|
||||
}
|
||||
|
||||
const totalMinutes = $derived(hours * 60 + minutes);
|
||||
const displayText = $derived.by(() => {
|
||||
if (totalMinutes === 0) return '0h 0min';
|
||||
const h = Math.floor(totalMinutes / 60);
|
||||
const m = totalMinutes % 60;
|
||||
return `${h}h ${m}min`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="time-picker">
|
||||
{#if label}
|
||||
<div class="mb-2 block text-sm font-medium text-gray-700">{label}</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Horas -->
|
||||
<div class="flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={incrementHours}
|
||||
disabled={disabled}
|
||||
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronUp class="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={hours}
|
||||
oninput={handleHoursInput}
|
||||
disabled={disabled}
|
||||
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={decrementHours}
|
||||
disabled={disabled || hours === 0}
|
||||
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
<span class="mt-1 text-xs text-gray-500">horas</span>
|
||||
</div>
|
||||
|
||||
<!-- Separador -->
|
||||
<div class="text-2xl font-bold text-gray-400">:</div>
|
||||
|
||||
<!-- Minutos -->
|
||||
<div class="flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
onclick={incrementMinutes}
|
||||
disabled={disabled}
|
||||
class="flex h-10 w-12 items-center justify-center rounded-t-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronUp class="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
value={minutes}
|
||||
oninput={handleMinutesInput}
|
||||
disabled={disabled}
|
||||
class="h-14 w-12 border-x border-gray-300 bg-white text-center text-xl font-bold text-gray-900 focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20 disabled:cursor-not-allowed disabled:bg-gray-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onclick={decrementMinutes}
|
||||
disabled={disabled || (hours === 0 && minutes === 0)}
|
||||
class="flex h-10 w-12 items-center justify-center rounded-b-lg border border-gray-300 bg-gray-50 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<ChevronDown class="h-4 w-4 text-gray-600" />
|
||||
</button>
|
||||
<span class="mt-1 text-xs text-gray-500">min</span>
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="ml-4 flex flex-col items-center justify-center rounded-lg bg-primary/10 px-4 py-2">
|
||||
<span class="text-xs text-gray-600">Total</span>
|
||||
<span class="text-lg font-bold text-primary">{displayText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.time-picker input[type='number']::-webkit-inner-spin-button,
|
||||
.time-picker input[type='number']::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.time-picker input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
|
||||
722
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
722
apps/web/src/lib/components/ponto/WebcamCapture.svelte
Normal file
@@ -0,0 +1,722 @@
|
||||
<script lang="ts">
|
||||
import { AlertCircle, Camera, Check, X } from 'lucide-svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { capturarWebcamComPreview, validarWebcamDisponivel } from '$lib/utils/webcam';
|
||||
|
||||
interface Props {
|
||||
onCapture: (blob: Blob | null) => void;
|
||||
onCancel: () => void;
|
||||
onError?: () => void;
|
||||
autoCapture?: boolean;
|
||||
fotoObrigatoria?: boolean; // Se true, não permite continuar sem foto
|
||||
}
|
||||
|
||||
let {
|
||||
onCapture,
|
||||
onCancel,
|
||||
onError,
|
||||
autoCapture = false,
|
||||
fotoObrigatoria = false
|
||||
}: Props = $props();
|
||||
|
||||
let videoElement: HTMLVideoElement | null = $state(null);
|
||||
let canvasElement: HTMLCanvasElement | null = $state(null);
|
||||
let stream: MediaStream | null = $state(null);
|
||||
let webcamDisponivel = $state(false);
|
||||
let capturando = $state(false);
|
||||
let erro = $state<string | null>(null);
|
||||
let previewUrl = $state<string | null>(null);
|
||||
let videoReady = $state(false);
|
||||
|
||||
// Flag para evitar múltiplas chamadas de play() simultâneas
|
||||
let playEmAndamento = $state(false);
|
||||
|
||||
// Efeito para garantir que o vídeo seja exibido quando o stream for atribuído
|
||||
$effect(() => {
|
||||
if (stream && videoElement && !playEmAndamento) {
|
||||
// Sempre atualizar srcObject quando o stream mudar
|
||||
if (videoElement.srcObject !== stream) {
|
||||
videoElement.srcObject = stream;
|
||||
}
|
||||
|
||||
// Tentar reproduzir se ainda não estiver pronto e não houver outra chamada em andamento
|
||||
if (!videoReady && videoElement.readyState < 2) {
|
||||
// Verificar se já não está reproduzindo
|
||||
if (!videoElement.paused && videoElement.readyState >= 2) {
|
||||
videoReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
playEmAndamento = true;
|
||||
videoElement
|
||||
.play()
|
||||
.then(() => {
|
||||
playEmAndamento = false;
|
||||
// Aguardar um pouco para garantir que o vídeo esteja realmente reproduzindo
|
||||
setTimeout(() => {
|
||||
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||
videoReady = true;
|
||||
}
|
||||
}, 300);
|
||||
})
|
||||
.catch((err) => {
|
||||
playEmAndamento = false;
|
||||
// Ignorar AbortError - é esperado quando há uma nova requisição de load
|
||||
if (err.name !== 'AbortError') {
|
||||
console.warn('Erro ao reproduzir vídeo no effect:', err);
|
||||
}
|
||||
});
|
||||
} else if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||
videoReady = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
// Aguardar mais tempo para garantir que os elementos estejam no DOM
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// Verificar suporte
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
// Tentar método alternativo (navegadores antigos)
|
||||
const getUserMedia =
|
||||
navigator.getUserMedia ||
|
||||
(navigator as any).webkitGetUserMedia ||
|
||||
(navigator as any).mozGetUserMedia ||
|
||||
(navigator as any).msGetUserMedia;
|
||||
|
||||
if (!getUserMedia) {
|
||||
erro = 'Webcam não suportada';
|
||||
if (autoCapture && onError) {
|
||||
onError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Primeiro, tentar acessar a webcam antes de verificar o elemento
|
||||
// Isso garante que temos permissão antes de tentar renderizar o vídeo
|
||||
try {
|
||||
// Tentar diferentes configurações de webcam
|
||||
const constraints = [
|
||||
{
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user'
|
||||
}
|
||||
},
|
||||
{
|
||||
video: {
|
||||
width: { ideal: 640 },
|
||||
height: { ideal: 480 },
|
||||
facingMode: 'user'
|
||||
}
|
||||
},
|
||||
{
|
||||
video: {
|
||||
facingMode: 'user'
|
||||
}
|
||||
},
|
||||
{
|
||||
video: true
|
||||
}
|
||||
];
|
||||
|
||||
let ultimoErro: Error | null = null;
|
||||
let streamObtido = false;
|
||||
|
||||
for (const constraint of constraints) {
|
||||
try {
|
||||
console.log('Tentando acessar webcam com constraint:', constraint);
|
||||
const tempStream = await navigator.mediaDevices.getUserMedia(constraint);
|
||||
|
||||
// Verificar se o stream tem tracks de vídeo
|
||||
if (tempStream.getVideoTracks().length === 0) {
|
||||
tempStream.getTracks().forEach((track) => track.stop());
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log('Webcam acessada com sucesso');
|
||||
stream = tempStream;
|
||||
webcamDisponivel = true;
|
||||
streamObtido = true;
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn('Falha ao acessar webcam com constraint:', constraint, err);
|
||||
ultimoErro = err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (!streamObtido) {
|
||||
throw ultimoErro || new Error('Não foi possível acessar a webcam');
|
||||
}
|
||||
|
||||
// Agora que temos o stream, aguardar o elemento de vídeo estar disponível
|
||||
let tentativas = 0;
|
||||
while (!videoElement && tentativas < 30) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
tentativas++;
|
||||
}
|
||||
|
||||
if (!videoElement) {
|
||||
erro = 'Elemento de vídeo não encontrado';
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
webcamDisponivel = false;
|
||||
if (fotoObrigatoria) {
|
||||
return;
|
||||
}
|
||||
if (autoCapture && onError) {
|
||||
onError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Atribuir stream ao elemento de vídeo
|
||||
if (videoElement && stream) {
|
||||
videoElement.srcObject = stream;
|
||||
|
||||
// Aguardar o vídeo estar pronto com timeout maior
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
// Se o vídeo tem dimensões, considerar pronto mesmo sem eventos
|
||||
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||
videoReady = true;
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error('Timeout ao carregar vídeo'));
|
||||
}
|
||||
}, 15000); // Aumentar timeout para 15 segundos
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
// Aguardar um pouco mais para garantir que o vídeo esteja realmente visível
|
||||
setTimeout(() => {
|
||||
videoReady = true;
|
||||
resolve();
|
||||
}, 200);
|
||||
};
|
||||
|
||||
const onLoadedData = () => {
|
||||
if (videoElement && videoElement.videoWidth > 0 && videoElement.videoHeight > 0) {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
videoReady = true;
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
videoReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onError = () => {
|
||||
clearTimeout(timeout);
|
||||
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement?.removeEventListener('playing', onPlaying);
|
||||
videoElement?.removeEventListener('loadeddata', onLoadedData);
|
||||
videoElement?.removeEventListener('error', onError);
|
||||
reject(new Error('Erro ao carregar vídeo'));
|
||||
};
|
||||
|
||||
videoElement.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
videoElement.addEventListener('loadeddata', onLoadedData);
|
||||
videoElement.addEventListener('playing', onPlaying);
|
||||
videoElement.addEventListener('error', onError);
|
||||
|
||||
// Tentar reproduzir apenas se não estiver já reproduzindo
|
||||
if (videoElement.paused) {
|
||||
playEmAndamento = true;
|
||||
videoElement
|
||||
.play()
|
||||
.then(() => {
|
||||
playEmAndamento = false;
|
||||
console.log('Vídeo iniciado, readyState:', videoElement?.readyState);
|
||||
// Se já tiver metadata e dimensões, resolver imediatamente
|
||||
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||
setTimeout(() => {
|
||||
onLoadedMetadata();
|
||||
}, 300);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
playEmAndamento = false;
|
||||
// Ignorar AbortError - é esperado quando há uma nova requisição de load
|
||||
if (err.name !== 'AbortError') {
|
||||
console.warn('Erro ao reproduzir vídeo:', err);
|
||||
}
|
||||
// Continuar mesmo assim se já tiver metadata e dimensões
|
||||
if (videoElement && videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||
setTimeout(() => {
|
||||
onLoadedMetadata();
|
||||
}, 300);
|
||||
} else {
|
||||
// Aguardar um pouco mais antes de dar erro
|
||||
setTimeout(() => {
|
||||
if (videoElement && videoElement.videoWidth > 0) {
|
||||
onLoadedMetadata();
|
||||
} else {
|
||||
onError();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Já está reproduzindo, apenas verificar se está pronto
|
||||
if (videoElement.readyState >= 2 && videoElement.videoWidth > 0) {
|
||||
setTimeout(() => {
|
||||
onLoadedMetadata();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
'Vídeo pronto, dimensões:',
|
||||
videoElement.videoWidth,
|
||||
'x',
|
||||
videoElement.videoHeight
|
||||
);
|
||||
}
|
||||
|
||||
// Se for captura automática, aguardar um pouco e capturar
|
||||
if (autoCapture) {
|
||||
// Aguardar 1.5 segundos para o vídeo estabilizar
|
||||
setTimeout(() => {
|
||||
if (videoElement && canvasElement && !capturando && !previewUrl && webcamDisponivel) {
|
||||
capturar();
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
// Sucesso, sair do try
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('Erro ao acessar webcam:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||
erro = fotoObrigatoria
|
||||
? 'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.'
|
||||
: 'Permissão de webcam negada. Continuando sem foto.';
|
||||
} else if (
|
||||
errorMessage.includes('NotFoundError') ||
|
||||
errorMessage.includes('DevicesNotFoundError')
|
||||
) {
|
||||
erro = fotoObrigatoria
|
||||
? 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.'
|
||||
: 'Nenhuma webcam encontrada. Continuando sem foto.';
|
||||
} else {
|
||||
erro = fotoObrigatoria
|
||||
? 'Erro ao acessar webcam. Verifique as permissões e tente novamente.'
|
||||
: 'Erro ao acessar webcam. Continuando sem foto.';
|
||||
}
|
||||
|
||||
webcamDisponivel = false;
|
||||
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
|
||||
if (fotoObrigatoria) {
|
||||
// Apenas mostrar o erro e aguardar o usuário fechar ou tentar novamente
|
||||
return;
|
||||
}
|
||||
// Se for captura automática e houver erro, chamar onError para continuar sem foto
|
||||
if (autoCapture && onError) {
|
||||
setTimeout(() => {
|
||||
onError();
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
});
|
||||
|
||||
async function capturar() {
|
||||
if (!videoElement || !canvasElement) {
|
||||
console.error('Elementos de vídeo ou canvas não disponíveis');
|
||||
if (autoCapture && onError) {
|
||||
onError();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se o vídeo está pronto e tem dimensões válidas
|
||||
if (
|
||||
videoElement.readyState < 2 ||
|
||||
videoElement.videoWidth === 0 ||
|
||||
videoElement.videoHeight === 0
|
||||
) {
|
||||
console.warn('Vídeo ainda não está pronto, aguardando...');
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let tentativas = 0;
|
||||
const maxTentativas = 50; // 5 segundos
|
||||
const checkReady = () => {
|
||||
tentativas++;
|
||||
if (
|
||||
videoElement &&
|
||||
videoElement.readyState >= 2 &&
|
||||
videoElement.videoWidth > 0 &&
|
||||
videoElement.videoHeight > 0
|
||||
) {
|
||||
resolve();
|
||||
} else if (tentativas >= maxTentativas) {
|
||||
reject(new Error('Timeout aguardando vídeo ficar pronto'));
|
||||
} else {
|
||||
setTimeout(checkReady, 100);
|
||||
}
|
||||
};
|
||||
checkReady();
|
||||
}).catch((error) => {
|
||||
console.error('Erro ao aguardar vídeo:', error);
|
||||
erro = 'Vídeo não está pronto. Aguarde um momento e tente novamente.';
|
||||
capturando = false;
|
||||
return; // Retornar aqui para não continuar
|
||||
});
|
||||
|
||||
// Se chegou aqui, o vídeo está pronto, continuar com a captura
|
||||
}
|
||||
|
||||
capturando = true;
|
||||
erro = null;
|
||||
|
||||
try {
|
||||
// Verificar dimensões do vídeo novamente antes de capturar
|
||||
if (!videoElement.videoWidth || !videoElement.videoHeight) {
|
||||
throw new Error(
|
||||
'Dimensões do vídeo não disponíveis. Aguarde a câmera carregar completamente.'
|
||||
);
|
||||
}
|
||||
|
||||
// Configurar canvas com as dimensões do vídeo
|
||||
canvasElement.width = videoElement.videoWidth;
|
||||
canvasElement.height = videoElement.videoHeight;
|
||||
|
||||
// Obter contexto do canvas
|
||||
const ctx = canvasElement.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Não foi possível obter contexto do canvas');
|
||||
}
|
||||
|
||||
// Limpar canvas antes de desenhar
|
||||
ctx.clearRect(0, 0, canvasElement.width, canvasElement.height);
|
||||
|
||||
// Desenhar frame atual do vídeo no canvas
|
||||
// O vídeo está espelhado no CSS para visualização, mas capturamos normalmente
|
||||
ctx.drawImage(videoElement, 0, 0, canvasElement.width, canvasElement.height);
|
||||
|
||||
// Converter para blob
|
||||
const blob = await new Promise<Blob | null>((resolve, reject) => {
|
||||
canvasElement.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob);
|
||||
} else {
|
||||
reject(new Error('Falha ao converter canvas para blob'));
|
||||
}
|
||||
},
|
||||
'image/jpeg',
|
||||
0.92 // Qualidade ligeiramente reduzida para melhor compatibilidade
|
||||
);
|
||||
});
|
||||
|
||||
if (blob && blob.size > 0) {
|
||||
previewUrl = URL.createObjectURL(blob);
|
||||
console.log('Imagem capturada com sucesso, tamanho:', blob.size, 'bytes');
|
||||
|
||||
// Parar stream para mostrar preview
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
|
||||
// Se for captura automática, confirmar automaticamente após um pequeno delay
|
||||
if (autoCapture) {
|
||||
setTimeout(() => {
|
||||
confirmar();
|
||||
}, 500);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Blob vazio ou inválido');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao capturar:', error);
|
||||
erro = fotoObrigatoria
|
||||
? 'Erro ao capturar imagem. Tente novamente.'
|
||||
: 'Erro ao capturar imagem. Continuando sem foto.';
|
||||
// Se foto é obrigatória, não chamar onError para permitir continuar sem foto
|
||||
if (fotoObrigatoria) {
|
||||
// Apenas mostrar o erro e permitir que o usuário tente novamente
|
||||
capturando = false;
|
||||
return;
|
||||
}
|
||||
// Se for captura automática e houver erro, continuar sem foto
|
||||
if (autoCapture && onError) {
|
||||
setTimeout(() => {
|
||||
onError();
|
||||
}, 500);
|
||||
}
|
||||
} finally {
|
||||
capturando = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmar() {
|
||||
if (previewUrl) {
|
||||
// Converter preview URL de volta para blob
|
||||
fetch(previewUrl)
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
onCapture(blob);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Erro ao converter preview:', error);
|
||||
erro = 'Erro ao processar imagem';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cancelar() {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
}
|
||||
onCancel();
|
||||
}
|
||||
|
||||
async function recapturar() {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
previewUrl = null;
|
||||
}
|
||||
// Reiniciar webcam
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
facingMode: 'user'
|
||||
}
|
||||
});
|
||||
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao reiniciar webcam:', error);
|
||||
erro = 'Erro ao reiniciar webcam';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex w-full flex-col items-center gap-4 p-4">
|
||||
{#if !webcamDisponivel && !erro}
|
||||
<div class="text-warning flex items-center gap-2">
|
||||
<Camera class="h-5 w-5" />
|
||||
<span>Verificando webcam...</span>
|
||||
</div>
|
||||
{#if !autoCapture && !fotoObrigatoria}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
</div>
|
||||
{:else if fotoObrigatoria}
|
||||
<div class="alert alert-info max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>A captura de foto é obrigatória para registrar o ponto.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if erro && !webcamDisponivel}
|
||||
<div class="alert alert-error max-w-md">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{#if fotoObrigatoria}
|
||||
<div class="alert alert-warning max-w-md">
|
||||
<span
|
||||
>Não é possível registrar o ponto sem capturar uma foto. Verifique as permissões da webcam
|
||||
e tente novamente.</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick={async () => {
|
||||
erro = null;
|
||||
webcamDisponivel = false;
|
||||
videoReady = false;
|
||||
// Limpar stream anterior se existir
|
||||
if (stream) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
}
|
||||
// Tentar reiniciar a webcam
|
||||
try {
|
||||
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia && videoElement) {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
if (stream.getVideoTracks().length > 0) {
|
||||
webcamDisponivel = true;
|
||||
if (videoElement) {
|
||||
videoElement.srcObject = stream;
|
||||
await videoElement.play();
|
||||
}
|
||||
} else {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
stream = null;
|
||||
erro =
|
||||
'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
}
|
||||
} else {
|
||||
erro = 'Webcam não disponível. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao tentar novamente:', e);
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
if (
|
||||
errorMessage.includes('Permission denied') ||
|
||||
errorMessage.includes('NotAllowedError')
|
||||
) {
|
||||
erro =
|
||||
'Permissão de webcam negada. É necessário autorizar o acesso à webcam para registrar o ponto.';
|
||||
} else if (
|
||||
errorMessage.includes('NotFoundError') ||
|
||||
errorMessage.includes('DevicesNotFoundError')
|
||||
) {
|
||||
erro = 'Nenhuma webcam encontrada. É necessário uma webcam para registrar o ponto.';
|
||||
} else {
|
||||
erro = 'Erro ao acessar webcam. Verifique as permissões e tente novamente.';
|
||||
}
|
||||
}
|
||||
}}>Tentar Novamente</button
|
||||
>
|
||||
<button class="btn btn-error" onclick={cancelar}>Fechar</button>
|
||||
</div>
|
||||
{:else if autoCapture}
|
||||
<div class="text-base-content/70 text-center text-sm">O registro será feito sem foto.</div>
|
||||
{:else}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary" onclick={cancelar}>Continuar sem foto</button>
|
||||
</div>
|
||||
{/if}
|
||||
{:else if previewUrl}
|
||||
<!-- Preview da imagem capturada -->
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
{#if autoCapture}
|
||||
<!-- Modo automático: mostrar apenas preview sem botões -->
|
||||
<div class="text-base-content/70 mb-2 text-center text-sm">
|
||||
Foto capturada automaticamente...
|
||||
</div>
|
||||
{/if}
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 object-contain"
|
||||
/>
|
||||
{#if !autoCapture}
|
||||
<!-- Botões apenas se não for automático -->
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button class="btn btn-success" onclick={confirmar}>
|
||||
<Check class="h-5 w-5" />
|
||||
Confirmar
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={recapturar}>
|
||||
<Camera class="h-5 w-5" />
|
||||
Recapturar
|
||||
</button>
|
||||
<button class="btn btn-error" onclick={cancelar}>
|
||||
<X class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Webcam ativa -->
|
||||
<div class="flex w-full flex-col items-center gap-4">
|
||||
{#if autoCapture}
|
||||
<div class="text-base-content/70 mb-2 text-center text-sm">
|
||||
Capturando foto automaticamente...
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-base-content/70 mb-2 text-center text-sm">
|
||||
Posicione-se na frente da câmera e clique em "Capturar Foto"
|
||||
</div>
|
||||
{/if}
|
||||
<div class="relative flex w-full justify-center">
|
||||
<video
|
||||
bind:this={videoElement}
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
class="border-primary max-h-[60vh] max-w-full rounded-lg border-2 bg-black object-contain {!videoReady
|
||||
? 'opacity-50'
|
||||
: ''}"
|
||||
style="min-width: 320px; min-height: 240px; transform: scaleX(-1);"
|
||||
></video>
|
||||
<canvas bind:this={canvasElement} class="hidden"></canvas>
|
||||
{#if !videoReady && webcamDisponivel}
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-lg bg-black/70"
|
||||
>
|
||||
<span class="loading loading-spinner loading-lg text-white"></span>
|
||||
<span class="text-sm text-white">Carregando câmera...</span>
|
||||
</div>
|
||||
{:else if videoReady && webcamDisponivel}
|
||||
<div class="absolute bottom-2 left-1/2 -translate-x-1/2 transform">
|
||||
<div class="badge badge-success gap-2">
|
||||
<Camera class="h-4 w-4" />
|
||||
Câmera ativa
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if erro}
|
||||
<div class="alert alert-error max-w-md">
|
||||
<span>{erro}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !autoCapture}
|
||||
<!-- Botões sempre visíveis quando não for automático -->
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<button
|
||||
class="btn btn-primary btn-lg"
|
||||
onclick={capturar}
|
||||
disabled={capturando || !videoReady || !webcamDisponivel}
|
||||
>
|
||||
{#if capturando}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Capturando...
|
||||
{:else}
|
||||
<Camera class="h-5 w-5" />
|
||||
Capturar Foto
|
||||
{/if}
|
||||
</button>
|
||||
<button class="btn btn-outline" onclick={cancelar}>
|
||||
<X class="h-5 w-5" />
|
||||
Cancelar
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
139
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
139
apps/web/src/lib/components/ponto/WidgetGestaoPontos.svelte
Normal file
@@ -0,0 +1,139 @@
|
||||
<script lang="ts">
|
||||
import { CheckCircle2, Clock, XCircle } from 'lucide-svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl transition-all duration-300 hover:shadow-2xl">
|
||||
<div class="card-body">
|
||||
<!-- Cabeçalho da Categoria -->
|
||||
<div class="mb-6 flex items-start gap-6">
|
||||
<div class="rounded-2xl bg-blue-500/20 p-4">
|
||||
<div class="text-blue-600">
|
||||
<Clock class="h-12 w-12" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="card-title mb-2 text-2xl text-blue-600">Gestão de Pontos</h2>
|
||||
<p class="text-base-content/70">Registros de ponto do dia</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid de Opções -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/registro-pontos')}
|
||||
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-blue-500/10 to-blue-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div
|
||||
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
|
||||
>
|
||||
<div class="text-blue-600 group-hover:text-white">
|
||||
<Clock class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
|
||||
>
|
||||
Gestão de Pontos
|
||||
</h3>
|
||||
<p class="text-base-content/70 flex-1 text-sm">
|
||||
Visualizar e gerenciar registros de ponto
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/homologacao')}
|
||||
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-green-500/10 to-green-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div
|
||||
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
|
||||
>
|
||||
<div class="text-green-600 group-hover:text-white">
|
||||
<CheckCircle2 class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
|
||||
>
|
||||
Homologação de Registro
|
||||
</h3>
|
||||
<p class="text-base-content/70 flex-1 text-sm">
|
||||
Edite registros de ponto e ajuste banco de horas
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<a
|
||||
href={resolve('/(dashboard)/recursos-humanos/controle-ponto/dispensa')}
|
||||
class="group border-base-300 hover:border-primary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br from-orange-500/10 to-orange-600/20 p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||
>
|
||||
<div class="flex h-full flex-col">
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<div
|
||||
class="bg-base-100 group-hover:bg-primary rounded-lg p-3 transition-colors duration-300 group-hover:text-white"
|
||||
>
|
||||
<div class="text-orange-600 group-hover:text-white">
|
||||
<XCircle class="h-5 w-5" strokeWidth={2} />
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="text-base-content/30 group-hover:text-primary h-5 w-5 transition-colors duration-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3
|
||||
class="text-base-content group-hover:text-primary mb-2 text-lg font-bold transition-colors duration-300"
|
||||
>
|
||||
Dispensa de Registro
|
||||
</h3>
|
||||
<p class="text-base-content/70 flex-1 text-sm">
|
||||
Gerencie períodos de dispensa de registro de ponto
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import { BarChart3, CheckCircle2, XCircle, Users } from 'lucide-svelte';
|
||||
|
||||
interface Estatisticas {
|
||||
totalRegistros: number;
|
||||
dentroDoPrazo: number;
|
||||
foraDoPrazo: number;
|
||||
totalFuncionarios: number;
|
||||
funcionariosDentroPrazo: number;
|
||||
funcionariosForaPrazo: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
estatisticas?: Estatisticas;
|
||||
}
|
||||
|
||||
let { estatisticas = undefined }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if estatisticas}
|
||||
<div class="mb-8 grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
<!-- Total de Registros -->
|
||||
<div
|
||||
class="card transform border border-blue-500/20 bg-gradient-to-br from-blue-500/10 to-blue-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/70 mb-1 text-sm font-semibold">Total de Registros</p>
|
||||
<p class="text-base-content text-3xl font-bold">{estatisticas.totalRegistros}</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-blue-500/20 p-3">
|
||||
<BarChart3 class="h-8 w-8 text-blue-600" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dentro do Prazo -->
|
||||
<div
|
||||
class="card transform border border-green-500/20 bg-gradient-to-br from-green-500/10 to-green-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/70 mb-1 text-sm font-semibold">Dentro do Prazo</p>
|
||||
<p class="text-3xl font-bold text-green-600">{estatisticas.dentroDoPrazo}</p>
|
||||
<p class="text-base-content/60 mt-1 text-xs">
|
||||
{estatisticas.totalRegistros > 0
|
||||
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||
: 0}% do total
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-green-500/20 p-3">
|
||||
<CheckCircle2 class="h-8 w-8 text-green-600" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fora do Prazo -->
|
||||
<div
|
||||
class="card transform border border-red-500/20 bg-gradient-to-br from-red-500/10 to-red-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/70 mb-1 text-sm font-semibold">Fora do Prazo</p>
|
||||
<p class="text-3xl font-bold text-red-600">{estatisticas.foraDoPrazo}</p>
|
||||
<p class="text-base-content/60 mt-1 text-xs">
|
||||
{estatisticas.totalRegistros > 0
|
||||
? ((estatisticas.foraDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||
: 0}% do total
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-red-500/20 p-3">
|
||||
<XCircle class="h-8 w-8 text-red-600" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Funcionários -->
|
||||
<div
|
||||
class="card transform border border-purple-500/20 bg-gradient-to-br from-purple-500/10 to-purple-600/20 shadow-xl transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<p class="text-base-content/70 mb-1 text-sm font-semibold">Funcionários</p>
|
||||
<p class="text-3xl font-bold text-purple-600">{estatisticas.totalFuncionarios}</p>
|
||||
<p class="text-base-content/60 mt-1 text-xs">
|
||||
{estatisticas.funcionariosDentroPrazo} dentro, {estatisticas.funcionariosForaPrazo} fora
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-xl bg-purple-500/20 p-3">
|
||||
<Users class="h-8 w-8 text-purple-600" strokeWidth={2.5} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import { BarChart3, XCircle, FileText } from 'lucide-svelte';
|
||||
|
||||
interface Estatisticas {
|
||||
totalRegistros: number;
|
||||
dentroDoPrazo: number;
|
||||
foraDoPrazo: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
estatisticas?: Estatisticas;
|
||||
chartData?: {
|
||||
labels: string[];
|
||||
datasets: Array<{
|
||||
label: string;
|
||||
data: number[];
|
||||
backgroundColor: string;
|
||||
borderColor: string;
|
||||
borderWidth: number;
|
||||
}>;
|
||||
} | null;
|
||||
isLoading?: boolean;
|
||||
error?: Error | null;
|
||||
}
|
||||
|
||||
let { estatisticas = undefined, chartData = null, isLoading = false, error = null }: Props = $props();
|
||||
|
||||
let chartCanvas: HTMLCanvasElement;
|
||||
let chartInstance: Chart | null = null;
|
||||
|
||||
onMount(() => {
|
||||
Chart.register(...registerables);
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (chartCanvas && estatisticas && chartData && !chartInstance) {
|
||||
try {
|
||||
criarGrafico();
|
||||
} catch (err) {
|
||||
console.error('Erro ao criar gráfico no onMount:', err);
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
function criarGrafico() {
|
||||
if (!chartCanvas || !estatisticas || !chartData) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
try {
|
||||
chartInstance = new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
labels: {
|
||||
color: 'hsl(var(--bc))',
|
||||
font: {
|
||||
size: 12,
|
||||
family: "'Inter', sans-serif"
|
||||
},
|
||||
usePointStyle: true,
|
||||
padding: 15
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.85)',
|
||||
titleColor: '#fff',
|
||||
bodyColor: '#fff',
|
||||
borderColor: 'hsl(var(--p))',
|
||||
borderWidth: 1,
|
||||
padding: 12,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.dataset.label || '';
|
||||
const value = context.parsed.y;
|
||||
const total = estatisticas!.totalRegistros;
|
||||
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||
return `${label}: ${value} (${percentage}%)`;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
stacked: true,
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
color: 'hsl(var(--bc))',
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
}
|
||||
},
|
||||
y: {
|
||||
stacked: true,
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.05)'
|
||||
},
|
||||
ticks: {
|
||||
color: 'hsl(var(--bc))',
|
||||
font: {
|
||||
size: 11
|
||||
},
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
duration: 1000,
|
||||
easing: 'easeInOutQuart'
|
||||
},
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erro ao criar gráfico:', error);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (chartCanvas && estatisticas && chartData) {
|
||||
if (chartInstance) {
|
||||
chartInstance.destroy();
|
||||
chartInstance = null;
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
try {
|
||||
criarGrafico();
|
||||
} catch (err) {
|
||||
console.error('Erro ao criar gráfico no effect:', err);
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100/90 border-base-300 mb-8 border shadow-xl backdrop-blur-sm">
|
||||
<div class="card-body">
|
||||
<div class="mb-6 flex items-center justify-between">
|
||||
<h2 class="card-title text-2xl">
|
||||
<div class="bg-primary/10 rounded-lg p-2">
|
||||
<BarChart3 class="text-primary h-6 w-6" strokeWidth={2.5} />
|
||||
</div>
|
||||
<span>Visão Geral das Estatísticas</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="bg-base-200/50 border-base-300 relative h-80 w-full rounded-xl border p-4">
|
||||
{#if isLoading}
|
||||
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
<span class="text-base-content/70 font-medium">Carregando estatísticas...</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
|
||||
<div class="alert alert-error shadow-lg">
|
||||
<XCircle class="h-6 w-6" />
|
||||
<div>
|
||||
<h3 class="font-bold">Erro ao carregar estatísticas</h3>
|
||||
<div class="mt-1 text-sm">
|
||||
{error?.message || String(error) || 'Erro desconhecido'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if !estatisticas || !chartData}
|
||||
<div class="bg-base-200/30 absolute inset-0 flex items-center justify-center rounded-xl">
|
||||
<div class="text-center">
|
||||
<FileText class="text-base-content/30 mx-auto mb-2 h-12 w-12" />
|
||||
<p class="text-base-content/70">Nenhuma estatística disponível</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<canvas bind:this={chartCanvas} class="h-full w-full"></canvas>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import { Clock } from 'lucide-svelte';
|
||||
|
||||
interface Estatisticas {
|
||||
totalRegistros: number;
|
||||
totalFuncionarios: number;
|
||||
dentroDoPrazo: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
estatisticas?: Estatisticas;
|
||||
}
|
||||
|
||||
let { estatisticas = undefined }: Props = $props();
|
||||
</script>
|
||||
|
||||
<section
|
||||
class="border-base-300 from-primary/10 via-base-100 to-secondary/10 relative mb-8 overflow-hidden rounded-2xl border bg-gradient-to-br p-8 shadow-lg"
|
||||
>
|
||||
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
|
||||
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
|
||||
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="bg-primary/20 border-primary/30 rounded-2xl border p-4 shadow-lg backdrop-blur-sm"
|
||||
>
|
||||
<Clock class="text-primary h-10 w-10" strokeWidth={2.5} />
|
||||
</div>
|
||||
<div class="max-w-3xl space-y-2">
|
||||
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
|
||||
Registro de Pontos
|
||||
</h1>
|
||||
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
|
||||
Gerencie e visualize os registros de ponto dos funcionários com informações detalhadas e
|
||||
relatórios
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if estatisticas}
|
||||
<div
|
||||
class="border-base-200/60 bg-base-100/70 grid grid-cols-2 gap-4 rounded-2xl border p-6 shadow-lg backdrop-blur sm:max-w-sm"
|
||||
>
|
||||
<div>
|
||||
<p class="text-base-content/60 text-sm font-semibold">Total de Registros</p>
|
||||
<p class="text-base-content mt-2 text-2xl font-bold">{estatisticas.totalRegistros}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-base-content/60 text-sm font-semibold">Funcionários</p>
|
||||
<p class="text-base-content mt-2 text-xl font-bold">{estatisticas.totalFuncionarios}</p>
|
||||
</div>
|
||||
<div
|
||||
class="via-base-300 col-span-2 h-px bg-gradient-to-r from-transparent to-transparent"
|
||||
></div>
|
||||
<div class="text-base-content/70 col-span-2 flex items-center justify-between text-sm">
|
||||
<span>
|
||||
{estatisticas.totalRegistros > 0
|
||||
? ((estatisticas.dentroDoPrazo / estatisticas.totalRegistros) * 100).toFixed(1)
|
||||
: 0}% dentro do prazo
|
||||
</span>
|
||||
<span class="badge badge-primary badge-sm">Ativo</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<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 type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
import AlertModal from '$lib/components/AlertModal.svelte';
|
||||
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
|
||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props();
|
||||
const { onClose }: { onClose: () => void } = $props();
|
||||
|
||||
const client = useConvexClient();
|
||||
const alertasQuery = useQuery(api.monitoramento.listarAlertas, {});
|
||||
|
||||
// Derivação - o useQuery retorna um objeto com propriedade data quando a query retorna um array
|
||||
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 ?? [];
|
||||
if (alertasQuery === undefined) return [];
|
||||
// Verificar se é um objeto com propriedade data
|
||||
if (alertasQuery && typeof alertasQuery === 'object' && 'data' in alertasQuery) {
|
||||
return Array.isArray(alertasQuery.data) ? alertasQuery.data : [];
|
||||
}
|
||||
// Fallback: se for diretamente um array
|
||||
return Array.isArray(alertasQuery) ? alertasQuery : [];
|
||||
});
|
||||
|
||||
// Estado para novo alerta
|
||||
@@ -25,6 +32,13 @@
|
||||
let saving = $state(false);
|
||||
let showForm = $state(false);
|
||||
|
||||
// Estado para modais
|
||||
let showAlertModal = $state(false);
|
||||
let alertMessage = $state('');
|
||||
let showConfirmModal = $state(false);
|
||||
let confirmMessage = $state('');
|
||||
let confirmCallback = $state<(() => void) | null>(null);
|
||||
|
||||
const metricOptions = [
|
||||
{ value: 'cpuUsage', label: 'Uso de CPU (%)' },
|
||||
{ value: 'memoryUsage', label: 'Uso de Memória (%)' },
|
||||
@@ -55,7 +69,7 @@
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
function editAlert(alert: any) {
|
||||
function editAlert(alert: Doc<'alertConfigurations'>) {
|
||||
editingAlertId = alert._id;
|
||||
metricName = alert.metricName;
|
||||
threshold = alert.threshold;
|
||||
@@ -67,9 +81,15 @@
|
||||
}
|
||||
|
||||
async function saveAlert() {
|
||||
if (!notifyByChat && !notifyByEmail) {
|
||||
alertMessage = 'Selecione pelo menos um método de notificação (Chat ou Email)';
|
||||
showAlertModal = true;
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await client.mutation(api.monitoramento.configurarAlerta, {
|
||||
const result = await client.mutation(api.monitoramento.configurarAlerta, {
|
||||
alertId: editingAlertId || undefined,
|
||||
metricName,
|
||||
threshold,
|
||||
@@ -79,23 +99,38 @@
|
||||
notifyByChat
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// Aguardar um pouco para garantir que a query seja atualizada pelo Convex Svelte
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
resetForm();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao salvar alerta:', error);
|
||||
alert('Erro ao salvar alerta. Tente novamente.');
|
||||
alertMessage = 'Erro ao salvar alerta. Tente novamente.';
|
||||
showAlertModal = true;
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
|
||||
if (!confirm('Tem certeza que deseja deletar este alerta?')) return;
|
||||
function handleDeleteClick(alertId: Id<'alertConfigurations'>) {
|
||||
confirmMessage = 'Tem certeza que deseja deletar este alerta? Esta ação não pode ser desfeita.';
|
||||
confirmCallback = () => deleteAlert(alertId);
|
||||
showConfirmModal = true;
|
||||
console.log('Modal de confirmação aberto para deletar alerta:', alertId);
|
||||
}
|
||||
|
||||
async function deleteAlert(alertId: Id<'alertConfigurations'>) {
|
||||
try {
|
||||
console.log('Deletando alerta:', alertId);
|
||||
await client.mutation(api.monitoramento.deletarAlerta, { alertId });
|
||||
console.log('Alerta deletado com sucesso');
|
||||
// Aguardar um pouco para garantir que a query seja atualizada
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
} catch (error) {
|
||||
console.error('Erro ao deletar alerta:', error);
|
||||
alert('Erro ao deletar alerta. Tente novamente.');
|
||||
alertMessage = 'Erro ao deletar alerta. Tente novamente.';
|
||||
showAlertModal = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,7 +144,9 @@
|
||||
</script>
|
||||
|
||||
<dialog class="modal modal-open">
|
||||
<div class="modal-box from-base-100 to-base-200 max-w-4xl bg-linear-to-br">
|
||||
<div
|
||||
class="modal-box from-base-100 to-base-200 max-h-[90vh] max-w-4xl overflow-y-auto bg-linear-to-br"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
|
||||
@@ -163,7 +200,7 @@
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={metricName}
|
||||
>
|
||||
{#each metricOptions as option}
|
||||
{#each metricOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -179,7 +216,7 @@
|
||||
class="select select-bordered select-primary"
|
||||
bind:value={operator}
|
||||
>
|
||||
{#each operatorOptions as option}
|
||||
{#each operatorOptions as option (option.value)}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
@@ -211,6 +248,32 @@
|
||||
|
||||
<!-- Notificações -->
|
||||
<div class="divider">Método de Notificação</div>
|
||||
|
||||
<!-- Aviso sobre destinatários -->
|
||||
<div class="alert alert-warning mb-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">⚠️ Destinatários das Notificações</h4>
|
||||
<p class="text-sm">
|
||||
As notificações de alerta serão enviadas <strong
|
||||
>apenas para usuários com perfil TI_MASTER</strong
|
||||
>. Certifique-se de que os responsáveis pelo monitoramento possuem este perfil.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<label class="label cursor-pointer gap-3">
|
||||
<input
|
||||
@@ -328,7 +391,11 @@
|
||||
<!-- Lista de Alertas -->
|
||||
<div class="divider">Alertas Configurados</div>
|
||||
|
||||
{#if alertas.length > 0}
|
||||
{#if alertasQuery === undefined}
|
||||
<div class="py-8 text-center">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if alertas.length > 0}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table-zebra table">
|
||||
<thead>
|
||||
@@ -341,7 +408,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each alertas as alerta}
|
||||
{#each alertas as alerta (alerta._id)}
|
||||
<tr class={!alerta.enabled ? 'opacity-50' : ''}>
|
||||
<td>
|
||||
<div class="font-semibold">
|
||||
@@ -405,7 +472,12 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="btn btn-xs" onclick={() => editAlert(alerta)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs"
|
||||
onclick={() => editAlert(alerta)}
|
||||
aria-label="Editar alerta"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
@@ -424,7 +496,8 @@
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-xs text-error"
|
||||
onclick={() => deleteAlert(alerta._id)}
|
||||
onclick={() => handleDeleteClick(alerta._id)}
|
||||
aria-label="Deletar alerta"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -477,3 +550,32 @@
|
||||
<button type="button">close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<!-- Modais padrão SGSE -->
|
||||
<AlertModal
|
||||
open={showAlertModal}
|
||||
title="Atenção"
|
||||
message={alertMessage}
|
||||
onClose={() => (showAlertModal = false)}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={showConfirmModal}
|
||||
title="Confirmar exclusão"
|
||||
message={confirmMessage}
|
||||
confirmText="Deletar"
|
||||
cancelText="Cancelar"
|
||||
onConfirm={async () => {
|
||||
if (confirmCallback) {
|
||||
await confirmCallback();
|
||||
confirmCallback = null;
|
||||
}
|
||||
showConfirmModal = false;
|
||||
// Aguardar um pouco para garantir que a query seja atualizada
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}}
|
||||
onCancel={() => {
|
||||
confirmCallback = null;
|
||||
showConfirmModal = false;
|
||||
}}
|
||||
/>
|
||||
|
||||
246
apps/web/src/lib/components/ti/AlertDiagnosticsCard.svelte
Normal file
246
apps/web/src/lib/components/ti/AlertDiagnosticsCard.svelte
Normal file
@@ -0,0 +1,246 @@
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
Mail,
|
||||
Settings,
|
||||
FileText
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// Criar uma chave reativa para forçar atualização da query
|
||||
let refreshKey = $state(0);
|
||||
|
||||
// Usar refreshKey nos argumentos para forçar recarregamento quando mudar
|
||||
// O backend ignora esse parâmetro, mas força o Convex Svelte a reexecutar a query
|
||||
const configQuery = useQuery(api.monitoramento.verificarConfiguracaoAlertas, {
|
||||
_refresh: refreshKey
|
||||
});
|
||||
|
||||
function refresh() {
|
||||
refreshKey++;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<div class="card-title flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Settings class="text-primary h-6 w-6" />
|
||||
<h2 class="text-2xl font-bold">Diagnóstico de Configuração de Alertas</h2>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" onclick={refresh}>
|
||||
<RefreshCw class="h-4 w-4" />
|
||||
Atualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if configQuery === undefined}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
{:else if configQuery === null}
|
||||
<div class="alert alert-error">
|
||||
<XCircle class="h-6 w-6" />
|
||||
<span>Erro ao carregar diagnóstico</span>
|
||||
</div>
|
||||
{:else}
|
||||
{@const config = configQuery || {
|
||||
templateExiste: false,
|
||||
templateInfo: null,
|
||||
roleTiMasterExiste: false,
|
||||
usuariosTiMaster: [],
|
||||
configSmtpAtiva: false,
|
||||
configSmtpInfo: null,
|
||||
emailsPendentes: 0,
|
||||
emailsFalha: 0,
|
||||
alertasAtivos: 0,
|
||||
alertasComEmail: 0
|
||||
}}
|
||||
{@const usuariosTiMaster = Array.isArray(config.usuariosTiMaster)
|
||||
? config.usuariosTiMaster
|
||||
: []}
|
||||
|
||||
<!-- Template -->
|
||||
<div class="divider">Template de Email</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if config.templateExiste}
|
||||
<CheckCircle class="text-success h-6 w-6" />
|
||||
<div class="flex-1">
|
||||
<p class="text-success font-semibold">Template encontrado</p>
|
||||
{#if config.templateInfo}
|
||||
<p class="text-base-content/70 text-sm">
|
||||
{config.templateInfo.nome} ({config.templateInfo.codigo})
|
||||
</p>
|
||||
{#if !config.templateInfo.htmlCorpo}
|
||||
<p class="text-warning mt-1 text-xs">⚠️ Template não possui HTML personalizado</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<XCircle class="text-error h-6 w-6" />
|
||||
<div class="flex-1">
|
||||
<p class="text-error font-semibold">Template não encontrado</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
O template "monitoramento_alerta_sistema" não foi encontrado no banco de dados.
|
||||
</p>
|
||||
{#if config.todosTemplatesCodigos && config.todosTemplatesCodigos.length > 0}
|
||||
<p class="text-base-content/60 mt-2 text-xs">
|
||||
<strong>Templates encontrados no banco:</strong>
|
||||
{config.todosTemplatesCodigos.join(', ')}
|
||||
</p>
|
||||
{/if}
|
||||
<p class="text-warning mt-1 text-xs">
|
||||
💡 Para criar os templates padrão, acesse a página de <a
|
||||
href="/ti/notificacoes"
|
||||
class="link link-primary">Notificações</a
|
||||
>
|
||||
e use o botão "Criar Templates Padrão", ou execute a mutation
|
||||
<code class="text-xs">criarTemplatesPadrao</code> via Convex Dashboard.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Role TI_MASTER -->
|
||||
<div class="divider">Perfil TI_MASTER</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if config.roleTiMasterExiste}
|
||||
<CheckCircle class="text-success h-6 w-6" />
|
||||
<div class="flex-1">
|
||||
<p class="text-success font-semibold">Perfil TI_MASTER encontrado</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
{usuariosTiMaster?.length || 0} usuário(s) com este perfil
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<XCircle class="text-error h-6 w-6" />
|
||||
<div class="flex-1">
|
||||
<p class="text-error font-semibold">Perfil TI_MASTER não encontrado</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
A role "ti_master" não existe no banco de dados.
|
||||
</p>
|
||||
<p class="text-warning mt-1 text-xs">
|
||||
💡 Execute o seed do banco de dados para criar as roles padrão.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Usuários TI_MASTER -->
|
||||
{#if usuariosTiMaster.length > 0}
|
||||
<div class="mt-2 ml-10 space-y-2">
|
||||
{#each usuariosTiMaster as usuario}
|
||||
<div class="flex items-center gap-2">
|
||||
{#if usuario?.temEmail}
|
||||
<CheckCircle class="text-success h-4 w-4" />
|
||||
{:else}
|
||||
<XCircle class="text-error h-4 w-4" />
|
||||
{/if}
|
||||
<span class="text-sm">
|
||||
{usuario?.nome || 'Usuário desconhecido'}
|
||||
{#if usuario?.email}
|
||||
<span class="text-base-content/60">({usuario.email})</span>
|
||||
{:else}
|
||||
<span class="text-error"> - Sem email cadastrado</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if config.roleTiMasterExiste}
|
||||
<div class="mt-2 ml-10">
|
||||
<div class="alert alert-warning">
|
||||
<AlertCircle class="h-5 w-5" />
|
||||
<span class="text-sm">Nenhum usuário com perfil TI_MASTER encontrado</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Configuração SMTP -->
|
||||
<div class="divider">Configuração SMTP</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{#if config.configSmtpAtiva}
|
||||
<CheckCircle class="text-success h-6 w-6" />
|
||||
<div class="flex-1">
|
||||
<p class="text-success font-semibold">Configuração SMTP ativa</p>
|
||||
{#if config.configSmtpInfo}
|
||||
<div class="text-base-content/70 space-y-1 text-sm">
|
||||
<p>Servidor: {config.configSmtpInfo.servidor}:{config.configSmtpInfo.porta}</p>
|
||||
<p>Remetente: {config.configSmtpInfo.emailRemetente}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<XCircle class="text-error h-6 w-6" />
|
||||
<div class="flex-1">
|
||||
<p class="text-error font-semibold">Configuração SMTP não encontrada ou inativa</p>
|
||||
<p class="text-base-content/70 text-sm">
|
||||
Nenhuma configuração SMTP ativa foi encontrada no banco de dados.
|
||||
</p>
|
||||
<p class="text-warning mt-1 text-xs">
|
||||
💡 Configure o SMTP em: <a href="/ti/configuracoes-email" class="link"
|
||||
>Configurações de Email</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Estatísticas -->
|
||||
<div class="divider">Estatísticas</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-figure text-primary">
|
||||
<FileText class="h-8 w-8" />
|
||||
</div>
|
||||
<div class="stat-title">Alertas Ativos</div>
|
||||
<div class="stat-value text-primary">{config.alertasAtivos}</div>
|
||||
<div class="stat-desc">com notificação por email: {config.alertasComEmail}</div>
|
||||
</div>
|
||||
|
||||
<div class="stat bg-base-200 rounded-lg">
|
||||
<div class="stat-figure text-warning">
|
||||
<Mail class="h-8 w-8" />
|
||||
</div>
|
||||
<div class="stat-title">Emails Pendentes</div>
|
||||
<div class="stat-value text-warning">{config.emailsPendentes}</div>
|
||||
<div class="stat-desc">em falha: {config.emailsFalha}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resumo -->
|
||||
<div class="divider">Resumo</div>
|
||||
<div
|
||||
class="alert {config.templateExiste &&
|
||||
config.roleTiMasterExiste &&
|
||||
usuariosTiMaster.some((u) => u?.temEmail) &&
|
||||
config.configSmtpAtiva
|
||||
? 'alert-success'
|
||||
: 'alert-warning'}"
|
||||
>
|
||||
{#if config.templateExiste && config.roleTiMasterExiste && usuariosTiMaster.some((u) => u?.temEmail) && config.configSmtpAtiva}
|
||||
<CheckCircle class="h-6 w-6" />
|
||||
<div>
|
||||
<p class="font-semibold">✅ Sistema configurado corretamente</p>
|
||||
<p class="text-sm">
|
||||
Todos os componentes necessários estão configurados. Os alertas devem funcionar
|
||||
corretamente.
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<AlertCircle class="h-6 w-6" />
|
||||
<div>
|
||||
<p class="font-semibold">⚠️ Configuração incompleta</p>
|
||||
<p class="text-sm">
|
||||
Alguns componentes necessários não estão configurados. Verifique os itens acima.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user