Compare commits
233 Commits
refinament
...
first-depl
| Author | SHA1 | Date | |
|---|---|---|---|
| 5121fcac8a | |||
| a94ec86349 | |||
|
|
177bb87af7 | ||
| ec6ba1d95f | |||
|
|
278a7de6aa | ||
| 417394ddbe | |||
| a4b8dd3f77 | |||
| 4551adf64f | |||
|
|
8eb6bfee10 | ||
|
|
42b62b7959 | ||
| 664d90c2e0 | |||
| fb22f82ce6 | |||
|
|
8a97d236a6 | ||
| b965514e53 | |||
| 3ee405a002 | |||
| c7a64eb116 | |||
| bdc0afccb8 | |||
| b248472d65 | |||
| e548c2c678 | |||
| c6a52155ee | |||
| 5369a2ecc9 | |||
| a731015c89 | |||
| 414ae85264 | |||
| a8a7469812 | |||
| e03b6d7a65 | |||
|
|
f6bf4ec918 | ||
|
|
88fac1fc2a | ||
| 743d165af3 | |||
| 7ccca5c233 | |||
| b1db926ab4 | |||
| e19c24b9ab | |||
| ec3b5dc7ea | |||
| ae4f8fc6b3 | |||
| ef9dbedb34 | |||
| 639f7c6467 | |||
| 06ab7369bd | |||
| e4ffc1ae2a | |||
| fdbecff4fa | |||
| f0884a19a7 | |||
| 500b7b362c | |||
| fc633c5708 | |||
| 8f0452bd87 | |||
| 8dd2674305 | |||
| d4c7488cab | |||
| 0c7412c764 | |||
| 0a4be24655 | |||
| 367cda7b95 | |||
| 011a867aac | |||
| 1eb454815f | |||
| d10eddca39 | |||
|
|
ef68f524ed | ||
| d3a4e5db8f | |||
| f008610b26 | |||
| 230be4db61 | |||
| 94373c6b94 | |||
| 69914170bf | |||
| 551a2fed00 | |||
| 9072619e26 | |||
| fbf00c824e | |||
| f90b27648f | |||
| 0cbae42df5 | |||
| fd2669aa4f | |||
| c7b4ea15bd | |||
| a5ad843b3e | |||
| 60b53dac74 | |||
| f3288b9639 | |||
|
|
6056ee4635 | ||
| c272ca05e8 | |||
| 4faf279c3e | |||
|
|
a951f61676 | ||
| d2c0636179 | |||
| 0404edd0ba | |||
| 91d41f6d98 | |||
| 9e45a43910 | |||
| c068715fc1 | |||
| 13ec7cc8e3 | |||
| 4f238022cf | |||
| b771322b24 | |||
|
|
98d12d40ef | ||
| 457e89e386 | |||
|
|
10454b38ea | ||
| b47a317c33 | |||
| ba39167b2b | |||
| 92a9605417 | |||
| 4eb49d3e63 | |||
| 6936a59c21 | |||
|
|
813d614648 | ||
| 196ef90643 | |||
|
|
1a56f2ab64 | ||
| 84dbe50fce | |||
| 3aa1e49ddb | |||
|
|
52e6805c09 | ||
| 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 | |||
|
|
d8361769e4 |
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
|
||||||
|
}
|
||||||
|
```
|
||||||
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 |
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
trigger: glob
|
trigger: always_on
|
||||||
globs: **/*.svelte.ts,**/*.svelte
|
|
||||||
---
|
---
|
||||||
|
|
||||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
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:
|
||||||
@@ -19,8 +18,8 @@ After calling the list-sections tool, you MUST analyze the returned documentatio
|
|||||||
|
|
||||||
### 3. svelte-autofixer
|
### 3. svelte-autofixer
|
||||||
|
|
||||||
Analyzes Svelte code and returns issues and suggestions.
|
Analyzes Svelte code and returns problems and suggestions.
|
||||||
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
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
|
### 4. playground-link
|
||||||
|
|
||||||
@@ -14,6 +14,13 @@
|
|||||||
"mcp",
|
"mcp",
|
||||||
"start"
|
"start"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"ark-ui": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": [
|
||||||
|
"-y",
|
||||||
|
"@ark-ui/mcp"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
36
.github/workflows/deploy.yml
vendored
Normal file
36
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
name: Build and Deploy Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['master']
|
||||||
|
pull_request:
|
||||||
|
branches: ['master']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-dockerfile-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
# Only login if we are actually going to push
|
||||||
|
if: github.event_name == 'push'
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./apps/web/Dockerfile
|
||||||
|
# Only push on 'push' event (merge to master), not on 'pull_request'
|
||||||
|
push: ${{ github.event_name == 'push' }}
|
||||||
|
tags: sgsedevs/sgse-app: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
|
tmp
|
||||||
temp
|
temp
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
out
|
||||||
63
.vscode/settings.json
vendored
63
.vscode/settings.json
vendored
@@ -1,29 +1,38 @@
|
|||||||
{
|
{
|
||||||
// "editor.formatOnSave": true,
|
// "editor.formatOnSave": true,
|
||||||
// "editor.defaultFormatter": "biomejs.biome",
|
// "editor.defaultFormatter": "biomejs.biome",
|
||||||
// "editor.codeActionsOnSave": {
|
// "editor.codeActionsOnSave": {
|
||||||
// "source.fixAll.biome": "always"
|
// "source.fixAll.biome": "always"
|
||||||
// },
|
// },
|
||||||
// "[typescript]": {
|
// "[typescript]": {
|
||||||
// "editor.defaultFormatter": "biomejs.biome"
|
// "editor.defaultFormatter": "biomejs.biome"
|
||||||
// },
|
// },
|
||||||
// "[svelte]": {
|
// "[svelte]": {
|
||||||
// "editor.defaultFormatter": "biomejs.biome"
|
// "editor.defaultFormatter": "biomejs.biome"
|
||||||
// },
|
// },
|
||||||
"eslint.useFlatConfig": true,
|
"eslint.useFlatConfig": true,
|
||||||
"eslint.workingDirectories": [
|
"eslint.workingDirectories": [
|
||||||
{ "pattern": "apps/*" },
|
{
|
||||||
{ "pattern": "packages/*" }
|
"pattern": "apps/*"
|
||||||
],
|
},
|
||||||
"eslint.validate": [
|
{
|
||||||
"javascript",
|
"pattern": "packages/*"
|
||||||
"javascriptreact",
|
}
|
||||||
"typescript",
|
],
|
||||||
"typescriptreact",
|
"eslint.validate": ["javascript", "typescript", "svelte"],
|
||||||
"svelte"
|
"eslint.options": {
|
||||||
],
|
"cache": true,
|
||||||
"eslint.options": {
|
"cacheLocation": ".eslintcache"
|
||||||
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,296 +0,0 @@
|
|||||||
# Correções Implementadas para Integração Jitsi
|
|
||||||
|
|
||||||
## Resumo das Alterações
|
|
||||||
|
|
||||||
Este documento descreve todas as correções implementadas para integrar o servidor Jitsi ao projeto SGSE e fazer as chamadas de áudio e vídeo funcionarem corretamente.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Configuração do JitsiConnection
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- A configuração do `serviceUrl` e `muc` estava incorreta para Docker Jitsi local
|
|
||||||
- O domínio incluía a porta, causando problemas na conexão
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
// Separar host e porta corretamente
|
|
||||||
const { host, porta } = obterHostEPorta(config.domain);
|
|
||||||
const protocol = config.useHttps ? 'https' : 'http';
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hosts: {
|
|
||||||
domain: host, // Apenas o host (sem porta)
|
|
||||||
muc: `conference.${host}` // MUC no mesmo domínio
|
|
||||||
},
|
|
||||||
serviceUrl: `${protocol}://${host}:${porta}/http-bind`, // BOSH com porta
|
|
||||||
bosh: `${protocol}://${host}:${porta}/http-bind`, // BOSH alternativo
|
|
||||||
clientNode: config.appId
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
**Arquivo criado/atualizado:**
|
|
||||||
- `apps/web/src/lib/utils/jitsi.ts` - Adicionada função `obterHostEPorta()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Criação de Tracks Locais
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Os tracks locais não estavam sendo criados após entrar na conferência
|
|
||||||
- Faltava o evento `CONFERENCE_JOINED` para criar tracks locais
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
conference.on(JitsiMeetJS.constants.events.conference.CONFERENCE_JOINED, async () => {
|
|
||||||
// Criar tracks locais com constraints apropriadas
|
|
||||||
const constraints = {
|
|
||||||
audio: estadoAtual.audioHabilitado ? {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true
|
|
||||||
} : false,
|
|
||||||
video: estadoAtual.videoHabilitado ? {
|
|
||||||
facingMode: 'user',
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 720 }
|
|
||||||
} : false
|
|
||||||
};
|
|
||||||
|
|
||||||
const tracks = await JitsiMeetJS.createLocalTracks(constraints, {
|
|
||||||
devices: [],
|
|
||||||
cameraDeviceId: estadoChamada.dispositivos.cameraId || undefined,
|
|
||||||
micDeviceId: estadoChamada.dispositivos.microphoneId || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adicionar tracks à conferência e anexar ao vídeo local
|
|
||||||
for (const track of tracks) {
|
|
||||||
await conference.addTrack(track);
|
|
||||||
if (track.getType() === 'video' && localVideo) {
|
|
||||||
track.attach(localVideo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Gerenciamento de Tracks
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Tracks locais não eram armazenados corretamente
|
|
||||||
- Falta de limpeza adequada ao finalizar chamada
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
- Adicionada variável de estado `localTracks: JitsiTrack[]` para rastrear todos os tracks locais
|
|
||||||
- Implementada limpeza adequada no método `finalizar()`:
|
|
||||||
- Desconectar tracks antes de liberar
|
|
||||||
- Dispor de todos os tracks locais
|
|
||||||
- Limpar referências
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Attach/Detach de Tracks Remotos
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Tracks remotos não eram anexados corretamente aos elementos de vídeo/áudio
|
|
||||||
- Não havia tratamento específico para áudio vs vídeo
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
function adicionarTrackRemoto(track: JitsiTrack): void {
|
|
||||||
const participantId = track.getParticipantId();
|
|
||||||
const trackType = track.getType();
|
|
||||||
|
|
||||||
if (trackType === 'audio') {
|
|
||||||
// Criar elemento de áudio invisível
|
|
||||||
const audioElement = document.createElement('audio');
|
|
||||||
audioElement.id = `remote-audio-${participantId}`;
|
|
||||||
audioElement.autoplay = true;
|
|
||||||
track.attach(audioElement);
|
|
||||||
videoContainer.appendChild(audioElement);
|
|
||||||
} else if (trackType === 'video') {
|
|
||||||
// Criar elemento de vídeo
|
|
||||||
const videoElement = document.createElement('video');
|
|
||||||
videoElement.id = `remote-video-${participantId}`;
|
|
||||||
videoElement.autoplay = true;
|
|
||||||
track.attach(videoElement);
|
|
||||||
videoContainer.appendChild(videoElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Controles de Áudio e Vídeo
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Os métodos `handleToggleAudio` e `handleToggleVideo` não criavam novos tracks quando necessário
|
|
||||||
- Não atualizavam corretamente o estado dos tracks locais
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
- Implementada lógica para criar tracks se não existirem
|
|
||||||
- Atualização correta do estado dos tracks (mute/unmute)
|
|
||||||
- Sincronização com o backend quando anfitrião
|
|
||||||
- Anexar/desanexar tracks ao vídeo local corretamente
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Tratamento de Erros
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Uso de `alert()` para erros (não amigável)
|
|
||||||
- Falta de mensagens de erro claras
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
- Implementado sistema de tratamento de erros com `ErrorModal`
|
|
||||||
- Integrado com `traduzirErro()` para mensagens amigáveis
|
|
||||||
- Adicionado estado de erro no componente:
|
|
||||||
```typescript
|
|
||||||
let showErrorModal = $state(false);
|
|
||||||
let errorTitle = $state('Erro na Chamada');
|
|
||||||
let errorMessage = $state('');
|
|
||||||
let errorDetails = $state<string | undefined>(undefined);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivos modificados:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
- Integração com `apps/web/src/lib/utils/erroHelpers.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Inicialização do Jitsi Meet JS
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Configuração básica do Jitsi pode estar incompleta
|
|
||||||
- Nível de log muito restritivo
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
JitsiMeetJS.init({
|
|
||||||
disableAudioLevels: false, // Habilitado para melhor qualidade
|
|
||||||
disableSimulcast: false,
|
|
||||||
enableWindowOnErrorHandler: true,
|
|
||||||
enableRemb: true, // REMB para controle de bitrate
|
|
||||||
enableTcc: true, // TCC para controle de congestionamento
|
|
||||||
disableThirdPartyRequests: false
|
|
||||||
});
|
|
||||||
|
|
||||||
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO); // Mais verboso para debug
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. UI/UX Melhorias
|
|
||||||
|
|
||||||
### Implementado
|
|
||||||
- Indicador de conexão durante estabelecimento da chamada
|
|
||||||
- Mensagem de "Conectando..." enquanto não há conexão estabelecida
|
|
||||||
- Tratamento visual adequado de estados de conexão
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Eventos da Conferência
|
|
||||||
|
|
||||||
### Adicionado
|
|
||||||
- `CONFERENCE_JOINED`: Criar tracks locais após entrar
|
|
||||||
- `CONFERENCE_LEFT`: Limpar tracks ao sair
|
|
||||||
- Melhor tratamento de `TRACK_ADDED` e `TRACK_REMOVED`
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Correção de Interfaces TypeScript
|
|
||||||
|
|
||||||
### Adicionado
|
|
||||||
- Método `addTrack()` na interface `JitsiConference`
|
|
||||||
- Melhor tipagem de `JitsiTrack` com propriedade `track: MediaStreamTrack`
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuração Necessária
|
|
||||||
|
|
||||||
### Variáveis de Ambiente (.env)
|
|
||||||
```env
|
|
||||||
# Jitsi Meet Configuration (Docker Local)
|
|
||||||
VITE_JITSI_DOMAIN=localhost:8443
|
|
||||||
VITE_JITSI_APP_ID=sgse-app
|
|
||||||
VITE_JITSI_ROOM_PREFIX=sgse
|
|
||||||
VITE_JITSI_USE_HTTPS=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nota:** Para Docker Jitsi local, geralmente usa-se HTTPS na porta 8443.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verificações Necessárias
|
|
||||||
|
|
||||||
### 1. Docker Jitsi Rodando
|
|
||||||
```bash
|
|
||||||
docker ps | grep jitsi
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Porta 8443 Acessível
|
|
||||||
```bash
|
|
||||||
curl -k https://localhost:8443
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Permissões do Navegador
|
|
||||||
- Microfone deve estar permitido
|
|
||||||
- Câmera deve estar permitida (para chamadas de vídeo)
|
|
||||||
|
|
||||||
### 4. Logs do Navegador
|
|
||||||
- Abrir DevTools (F12)
|
|
||||||
- Verificar Console para erros de conexão
|
|
||||||
- Verificar Network para erros de rede
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Próximos Passos (Se Necessário)
|
|
||||||
|
|
||||||
1. **Testar conectividade** - Verificar se o servidor Jitsi responde corretamente
|
|
||||||
2. **Ajustar configuração de rede** - Se houver problemas de firewall ou CORS
|
|
||||||
3. **Configurar STUN/TURN** - Para conexões através de NAT (se necessário)
|
|
||||||
4. **Otimizar qualidade** - Ajustar bitrates e resoluções conforme necessário
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
✅ **Todas as correções foram implementadas**
|
|
||||||
✅ **Código sem erros de lint**
|
|
||||||
✅ **Tratamento de erros adequado**
|
|
||||||
✅ **Interfaces TypeScript corretas**
|
|
||||||
✅ **Gerenciamento de recursos adequado**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Data:** $(date)
|
|
||||||
**Versão:** 1.0.0
|
|
||||||
|
|
||||||
@@ -1,701 +0,0 @@
|
|||||||
# Plano de Implementação - Chamadas de Áudio e Vídeo com Jitsi Meet
|
|
||||||
|
|
||||||
## Opção Escolhida: Docker Local (Desenvolvimento)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Etapas Fora do Código - Configuração Docker
|
|
||||||
|
|
||||||
### Etapa 1: Preparar Ambiente Docker
|
|
||||||
|
|
||||||
**Requisitos:**
|
|
||||||
|
|
||||||
- Docker Desktop instalado e rodando
|
|
||||||
- Mínimo 4GB RAM disponível
|
|
||||||
- Portas livres: 8000, 8443, 10000-20000/udp
|
|
||||||
|
|
||||||
**Passos:**
|
|
||||||
|
|
||||||
1. **Criar diretório para configuração Docker Jitsi:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/jitsi-docker
|
|
||||||
cd ~/jitsi-docker
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Clonar repositório oficial:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/jitsi/docker-jitsi-meet.git
|
|
||||||
cd docker-jitsi-meet
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configurar variáveis de ambiente:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Editar arquivo `.env` com as seguintes configurações:**
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Configuração básica para desenvolvimento local
|
|
||||||
CONFIG=~/.jitsi-meet-cfg
|
|
||||||
TZ=America/Recife
|
|
||||||
|
|
||||||
# Desabilitar Let's Encrypt (não necessário para localhost)
|
|
||||||
ENABLE_LETSENCRYPT=0
|
|
||||||
|
|
||||||
# Portas HTTP/HTTPS
|
|
||||||
HTTP_PORT=8000
|
|
||||||
HTTPS_PORT=8443
|
|
||||||
|
|
||||||
# Domínio local
|
|
||||||
PUBLIC_URL=http://localhost:8000
|
|
||||||
DOMAIN=localhost
|
|
||||||
|
|
||||||
# Desabilitar autenticação para facilitar testes
|
|
||||||
ENABLE_AUTH=0
|
|
||||||
ENABLE_GUESTS=1
|
|
||||||
|
|
||||||
# Desabilitar transcrissão (não necessário para desenvolvimento)
|
|
||||||
ENABLE_TRANSCRIPTION=0
|
|
||||||
|
|
||||||
# Desabilitar gravação no servidor (usaremos gravação local)
|
|
||||||
ENABLE_RECORDING=0
|
|
||||||
|
|
||||||
# Configurações de vídeo (ajustar conforme necessidade)
|
|
||||||
ENABLE_PREJOIN_PAGE=0
|
|
||||||
START_AUDIO_MUTED=0
|
|
||||||
START_VIDEO_MUTED=0
|
|
||||||
|
|
||||||
# Configurações de segurança
|
|
||||||
ENABLE_XMPP_WEBSOCKET=0
|
|
||||||
ENABLE_P2P=1
|
|
||||||
|
|
||||||
# Limites
|
|
||||||
MAX_NUMBER_OF_PARTICIPANTS=10
|
|
||||||
RESOLUTION_WIDTH=1280
|
|
||||||
RESOLUTION_HEIGHT=720
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Criar diretórios necessários:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.jitsi-meet-cfg/{web/letsencrypt,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb}
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Iniciar containers:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Verificar status:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
8. **Ver logs se necessário:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
9. **Testar acesso:**
|
|
||||||
|
|
||||||
- Acessar: http://localhost:8000
|
|
||||||
- Criar uma sala de teste e verificar se funciona
|
|
||||||
|
|
||||||
**Troubleshooting:**
|
|
||||||
|
|
||||||
- Se houver erro de permissão nos diretórios: `sudo chown -R $USER:$USER ~/.jitsi-meet-cfg`
|
|
||||||
- Se portas estiverem em uso, alterar HTTP_PORT e HTTPS_PORT no .env
|
|
||||||
- Para parar: `docker-compose down`
|
|
||||||
- Para reiniciar: `docker-compose restart`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Etapas no Código - Backend Convex
|
|
||||||
|
|
||||||
### Etapa 2: Atualizar Schema
|
|
||||||
|
|
||||||
**Arquivo:** `packages/backend/convex/schema.ts`
|
|
||||||
|
|
||||||
**Adicionar nova tabela `chamadas`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
chamadas: defineTable({
|
|
||||||
conversaId: v.id('conversas'),
|
|
||||||
tipo: v.union(v.literal('audio'), v.literal('video')),
|
|
||||||
roomName: v.string(), // Nome único da sala Jitsi
|
|
||||||
criadoPor: v.id('usuarios'), // Anfitrião/criador
|
|
||||||
participantes: v.array(v.id('usuarios')),
|
|
||||||
status: v.union(
|
|
||||||
v.literal('aguardando'),
|
|
||||||
v.literal('em_andamento'),
|
|
||||||
v.literal('finalizada'),
|
|
||||||
v.literal('cancelada')
|
|
||||||
),
|
|
||||||
iniciadaEm: v.optional(v.number()),
|
|
||||||
finalizadaEm: v.optional(v.number()),
|
|
||||||
duracaoSegundos: v.optional(v.number()),
|
|
||||||
gravando: v.boolean(),
|
|
||||||
gravacaoIniciadaPor: v.optional(v.id('usuarios')),
|
|
||||||
gravacaoIniciadaEm: v.optional(v.number()),
|
|
||||||
gravacaoFinalizadaEm: v.optional(v.number()),
|
|
||||||
configuracoes: v.optional(
|
|
||||||
v.object({
|
|
||||||
audioHabilitado: v.boolean(),
|
|
||||||
videoHabilitado: v.boolean(),
|
|
||||||
participantesConfig: v.optional(
|
|
||||||
v.array(
|
|
||||||
v.object({
|
|
||||||
usuarioId: v.id('usuarios'),
|
|
||||||
audioHabilitado: v.boolean(),
|
|
||||||
videoHabilitado: v.boolean(),
|
|
||||||
forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
),
|
|
||||||
criadoEm: v.number()
|
|
||||||
})
|
|
||||||
.index('by_conversa', ['conversaId', 'status'])
|
|
||||||
.index('by_conversa_ativa', ['conversaId', 'status'])
|
|
||||||
.index('by_criado_por', ['criadoPor'])
|
|
||||||
.index('by_status', ['status'])
|
|
||||||
.index('by_room_name', ['roomName']);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 3: Criar Backend de Chamadas
|
|
||||||
|
|
||||||
**Arquivo:** `packages/backend/convex/chamadas.ts`
|
|
||||||
|
|
||||||
**Funções a implementar:**
|
|
||||||
|
|
||||||
#### Mutations:
|
|
||||||
|
|
||||||
1. `criarChamada` - Criar nova chamada
|
|
||||||
2. `iniciarChamada` - Marcar como em andamento
|
|
||||||
3. `finalizarChamada` - Finalizar e calcular duração
|
|
||||||
4. `adicionarParticipante` - Adicionar participante
|
|
||||||
5. `removerParticipante` - Remover participante
|
|
||||||
6. `toggleAudioVideo` - Anfitrião controla áudio/vídeo de participante
|
|
||||||
7. `atualizarConfiguracaoParticipante` - Atualizar configuração individual
|
|
||||||
8. `iniciarGravacao` - Marcar início de gravação
|
|
||||||
9. `finalizarGravacao` - Marcar fim de gravação
|
|
||||||
|
|
||||||
#### Queries:
|
|
||||||
|
|
||||||
1. `obterChamadaAtiva` - Buscar chamada ativa de uma conversa
|
|
||||||
2. `listarChamadas` - Listar histórico
|
|
||||||
3. `verificarAnfitriao` - Verificar se usuário é anfitrião
|
|
||||||
4. `obterParticipantesChamada` - Listar participantes
|
|
||||||
|
|
||||||
**Tipos TypeScript (sem usar `any`):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Id } from './_generated/dataModel';
|
|
||||||
import type { QueryCtx, MutationCtx } from './_generated/server';
|
|
||||||
|
|
||||||
type ChamadaTipo = 'audio' | 'video';
|
|
||||||
type ChamadaStatus = 'aguardando' | 'em_andamento' | 'finalizada' | 'cancelada';
|
|
||||||
|
|
||||||
interface ParticipanteConfig {
|
|
||||||
usuarioId: Id<'usuarios'>;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
forcadoPeloAnfitriao?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfiguracoesChamada {
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
participantesConfig?: ParticipanteConfig[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Etapas no Código - Frontend Svelte
|
|
||||||
|
|
||||||
### Etapa 4: Instalar Dependências
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/package.json`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/web
|
|
||||||
bun add lib-jitsi-meet
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependências adicionais necessárias:**
|
|
||||||
|
|
||||||
- `lib-jitsi-meet` - Biblioteca oficial Jitsi
|
|
||||||
- (Possivelmente tipos) `@types/lib-jitsi-meet` se disponível
|
|
||||||
|
|
||||||
### Etapa 5: Configurar Variáveis de Ambiente
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Jitsi Meet Configuration (Docker Local)
|
|
||||||
VITE_JITSI_DOMAIN=localhost:8443
|
|
||||||
VITE_JITSI_APP_ID=sgse-app
|
|
||||||
VITE_JITSI_ROOM_PREFIX=sgse
|
|
||||||
VITE_JITSI_USE_HTTPS=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 6: Criar Utilitários Jitsi
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/utils/jitsi.ts`
|
|
||||||
|
|
||||||
**Funções:**
|
|
||||||
|
|
||||||
- `gerarRoomName(conversaId: string, tipo: "audio" | "video"): string` - Gerar nome único da sala
|
|
||||||
- `obterConfiguracaoJitsi()` - Retornar configuração do Jitsi baseada em .env
|
|
||||||
- `validarDispositivos()` - Validar disponibilidade de microfone/webcam
|
|
||||||
- `obterDispositivosDisponiveis()` - Listar dispositivos de mídia
|
|
||||||
|
|
||||||
**Tipos (sem `any`):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ConfiguracaoJitsi {
|
|
||||||
domain: string;
|
|
||||||
appId: string;
|
|
||||||
roomPrefix: string;
|
|
||||||
useHttps: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispositivoMedia {
|
|
||||||
deviceId: string;
|
|
||||||
label: string;
|
|
||||||
kind: 'audioinput' | 'audiooutput' | 'videoinput';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispositivosDisponiveis {
|
|
||||||
microphones: DispositivoMedia[];
|
|
||||||
speakers: DispositivoMedia[];
|
|
||||||
cameras: DispositivoMedia[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 7: Criar Store de Chamadas
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/stores/callStore.ts`
|
|
||||||
|
|
||||||
**Estado gerenciado:**
|
|
||||||
|
|
||||||
- Chamada ativa (se houver)
|
|
||||||
- Estado de mídia (áudio/vídeo ligado/desligado)
|
|
||||||
- Dispositivos selecionados
|
|
||||||
- Status de gravação
|
|
||||||
- Lista de participantes
|
|
||||||
- Duração da chamada
|
|
||||||
- É anfitrião ou não
|
|
||||||
|
|
||||||
**Tipos:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface EstadoChamada {
|
|
||||||
chamadaId: Id<'chamadas'> | null;
|
|
||||||
conversaId: Id<'conversas'> | null;
|
|
||||||
tipo: 'audio' | 'video' | null;
|
|
||||||
roomName: string | null;
|
|
||||||
estaConectado: boolean;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
gravando: boolean;
|
|
||||||
ehAnfitriao: boolean;
|
|
||||||
participantes: Array<{
|
|
||||||
usuarioId: Id<'usuarios'>;
|
|
||||||
nome: string;
|
|
||||||
avatar?: string;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
}>;
|
|
||||||
duracaoSegundos: number;
|
|
||||||
dispositivos: {
|
|
||||||
microphoneId: string | null;
|
|
||||||
cameraId: string | null;
|
|
||||||
speakerId: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventosChamada {
|
|
||||||
'participant-joined': (participant: ParticipanteJitsi) => void;
|
|
||||||
'participant-left': (participantId: string) => void;
|
|
||||||
'audio-mute-status-changed': (isMuted: boolean) => void;
|
|
||||||
'video-mute-status-changed': (isMuted: boolean) => void;
|
|
||||||
'connection-failed': (error: Error) => void;
|
|
||||||
'connection-disconnected': () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Métodos principais:**
|
|
||||||
|
|
||||||
- `iniciarChamada(conversaId, tipo)`
|
|
||||||
- `finalizarChamada()`
|
|
||||||
- `toggleAudio()`
|
|
||||||
- `toggleVideo()`
|
|
||||||
- `iniciarGravacao()`
|
|
||||||
- `finalizarGravacao()`
|
|
||||||
- `atualizarDispositivos()`
|
|
||||||
|
|
||||||
### Etapa 8: Criar Utilitários de Gravação
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/utils/mediaRecorder.ts`
|
|
||||||
|
|
||||||
**Funções:**
|
|
||||||
|
|
||||||
- `iniciarGravacaoAudio(stream: MediaStream): MediaRecorder` - Gravar apenas áudio
|
|
||||||
- `iniciarGravacaoVideo(stream: MediaStream): MediaRecorder` - Gravar áudio + vídeo
|
|
||||||
- `pararGravacao(recorder: MediaRecorder): Promise<Blob>` - Parar e retornar blob
|
|
||||||
- `salvarGravacao(blob: Blob, nomeArquivo: string): void` - Salvar localmente
|
|
||||||
- `obterDuracaoGravacao(recorder: MediaRecorder): number` - Obter duração
|
|
||||||
|
|
||||||
**Tipos:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface OpcoesGravacao {
|
|
||||||
audioBitsPerSecond?: number;
|
|
||||||
videoBitsPerSecond?: number;
|
|
||||||
mimeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResultadoGravacao {
|
|
||||||
blob: Blob;
|
|
||||||
duracaoSegundos: number;
|
|
||||||
nomeArquivo: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 9: Criar Componente CallWindow
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
|
|
||||||
- Janela flutuante redimensionável e arrastável
|
|
||||||
- Integração com lib-jitsi-meet
|
|
||||||
- Container para vídeo dos participantes
|
|
||||||
- Barra de controles
|
|
||||||
- Indicador de gravação
|
|
||||||
- Contador de duração
|
|
||||||
|
|
||||||
**Props (TypeScript estrito):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
chamadaId: Id<'chamadas'>;
|
|
||||||
conversaId: Id<'conversas'>;
|
|
||||||
tipo: 'audio' | 'video';
|
|
||||||
roomName: string;
|
|
||||||
ehAnfitriao: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Estrutura:**
|
|
||||||
|
|
||||||
- `<script lang="ts">` com tipos explícitos
|
|
||||||
- Uso de `$state`, `$derived`, `$effect` (Svelte 5)
|
|
||||||
- Integração com `callStore`
|
|
||||||
- Eventos do Jitsi tratados tipados
|
|
||||||
|
|
||||||
**Bibliotecas para janela flutuante:**
|
|
||||||
|
|
||||||
- Usar eventos nativos de mouse/touch para drag
|
|
||||||
- CSS para redimensionamento com handles
|
|
||||||
- localStorage para persistir posição/tamanho
|
|
||||||
|
|
||||||
### Etapa 10: Criar Componente CallControls
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/CallControls.svelte`
|
|
||||||
|
|
||||||
**Controles:**
|
|
||||||
|
|
||||||
- Botão toggle áudio
|
|
||||||
- Botão toggle vídeo
|
|
||||||
- Botão gravação (se anfitrião)
|
|
||||||
- Botão configurações
|
|
||||||
- Botão encerrar chamada
|
|
||||||
- Contador de duração (HH:MM:SS)
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
gravando: boolean;
|
|
||||||
ehAnfitriao: boolean;
|
|
||||||
duracaoSegundos: number;
|
|
||||||
onToggleAudio: () => void;
|
|
||||||
onToggleVideo: () => void;
|
|
||||||
onIniciarGravacao: () => void;
|
|
||||||
onPararGravacao: () => void;
|
|
||||||
onAbrirConfiguracoes: () => void;
|
|
||||||
onEncerrar: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 11: Criar Componente CallSettings
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/CallSettings.svelte`
|
|
||||||
|
|
||||||
**Funcionalidades:**
|
|
||||||
|
|
||||||
- Listar microfones disponíveis
|
|
||||||
- Listar webcams disponíveis
|
|
||||||
- Listar alto-falantes disponíveis
|
|
||||||
- Preview de vídeo antes de aplicar
|
|
||||||
- Teste de áudio
|
|
||||||
- Botões aplicar/cancelar
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
dispositivoAtual: {
|
|
||||||
microphoneId: string | null;
|
|
||||||
cameraId: string | null;
|
|
||||||
speakerId: string | null;
|
|
||||||
};
|
|
||||||
onClose: () => void;
|
|
||||||
onAplicar: (dispositivos: {
|
|
||||||
microphoneId: string | null;
|
|
||||||
cameraId: string | null;
|
|
||||||
speakerId: string | null;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 12: Criar Componente HostControls
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/HostControls.svelte`
|
|
||||||
|
|
||||||
**Funcionalidades (apenas para anfitrião):**
|
|
||||||
|
|
||||||
- Lista de participantes
|
|
||||||
- Toggle áudio por participante
|
|
||||||
- Toggle vídeo por participante
|
|
||||||
- Indicador visual de quem está gravando
|
|
||||||
- Status de cada participante
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
participantes: Array<{
|
|
||||||
usuarioId: Id<'usuarios'>;
|
|
||||||
nome: string;
|
|
||||||
avatar?: string;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
forcadoPeloAnfitriao?: boolean;
|
|
||||||
}>;
|
|
||||||
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
|
|
||||||
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 13: Criar Componente RecordingIndicator
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/RecordingIndicator.svelte`
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
|
|
||||||
- Banner visível no topo da janela
|
|
||||||
- Ícone animado de gravação
|
|
||||||
- Mensagem clara de que está gravando
|
|
||||||
- Informação de quem iniciou (se disponível)
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
gravando: boolean;
|
|
||||||
iniciadoPor?: string; // Nome do usuário que iniciou
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 14: Criar Utilitário de Janela Flutuante
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/utils/floatingWindow.ts`
|
|
||||||
|
|
||||||
**Funções:**
|
|
||||||
|
|
||||||
- `criarDragHandler(element: HTMLElement, handle: HTMLElement): () => void` - Criar handler de arrastar
|
|
||||||
- `criarResizeHandler(element: HTMLElement, handles: HTMLElement[]): () => void` - Criar handler de redimensionar
|
|
||||||
- `salvarPosicaoJanela(id: string, posicao: { x: number; y: number; width: number; height: number }): void` - Salvar no localStorage
|
|
||||||
- `restaurarPosicaoJanela(id: string): { x: number; y: number; width: number; height: number } | null` - Restaurar do localStorage
|
|
||||||
|
|
||||||
**Tipos:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PosicaoJanela {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LimitesJanela {
|
|
||||||
minWidth: number;
|
|
||||||
minHeight: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 15: Integrar com ChatWindow
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/chat/ChatWindow.svelte`
|
|
||||||
|
|
||||||
**Modificações:**
|
|
||||||
|
|
||||||
- Adicionar botão de chamada de áudio
|
|
||||||
- Adicionar botão de chamada de vídeo
|
|
||||||
- Mostrar indicador quando há chamada ativa
|
|
||||||
- Importar e usar CallWindow quando houver chamada
|
|
||||||
|
|
||||||
**Adicionar no topo (junto com outros botões):**
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-circle"
|
|
||||||
onclick={() => iniciarChamada('audio')}
|
|
||||||
title="Ligação de áudio"
|
|
||||||
>
|
|
||||||
<Phone class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-circle"
|
|
||||||
onclick={() => iniciarChamada('video')}
|
|
||||||
title="Ligação de vídeo"
|
|
||||||
>
|
|
||||||
<Video class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Ordem de Implementação Recomendada
|
|
||||||
|
|
||||||
1. ✅ **Etapa 1:** Configurar Docker Jitsi (fora do código)
|
|
||||||
2. ✅ **Etapa 2:** Atualizar schema com tabela chamadas
|
|
||||||
3. ✅ **Etapa 3:** Criar backend chamadas.ts com todas as funções
|
|
||||||
4. ✅ **Etapa 4:** Instalar dependências frontend
|
|
||||||
5. ✅ **Etapa 5:** Configurar variáveis de ambiente
|
|
||||||
6. ✅ **Etapa 6:** Criar utilitários Jitsi (jitsi.ts)
|
|
||||||
7. ✅ **Etapa 7:** Criar store de chamadas (callStore.ts)
|
|
||||||
8. ✅ **Etapa 8:** Criar utilitários de gravação (mediaRecorder.ts)
|
|
||||||
9. ✅ **Etapa 9:** Criar CallWindow básico (apenas estrutura)
|
|
||||||
10. ✅ **Etapa 10:** Integrar lib-jitsi-meet no CallWindow
|
|
||||||
11. ✅ **Etapa 11:** Criar CallControls e integrar
|
|
||||||
12. ✅ **Etapa 12:** Implementar contador de duração
|
|
||||||
13. ✅ **Etapa 13:** Implementar janela flutuante (drag & resize)
|
|
||||||
14. ✅ **Etapa 14:** Criar CallSettings e integração de dispositivos
|
|
||||||
15. ✅ **Etapa 15:** Criar HostControls e lógica de anfitrião
|
|
||||||
16. ✅ **Etapa 16:** Implementar gravação local
|
|
||||||
17. ✅ **Etapa 17:** Criar RecordingIndicator
|
|
||||||
18. ✅ **Etapa 18:** Integrar botões no ChatWindow
|
|
||||||
19. ✅ **Etapa 19:** Testes completos
|
|
||||||
20. ✅ **Etapa 20:** Ajustes finais e tratamento de erros
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Segurança e Boas Práticas
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- ❌ **NUNCA** usar `any`
|
|
||||||
- ✅ Usar tipos explícitos em todas as funções
|
|
||||||
- ✅ Usar tipos inferidos do Convex quando possível
|
|
||||||
- ✅ Criar interfaces para objetos complexos
|
|
||||||
|
|
||||||
### Svelte 5
|
|
||||||
|
|
||||||
- ✅ Usar `$props()` para props
|
|
||||||
- ✅ Usar `$state()` para estado reativo
|
|
||||||
- ✅ Usar `$derived()` para valores derivados
|
|
||||||
- ✅ Usar `$effect()` para side effects
|
|
||||||
|
|
||||||
### Validação
|
|
||||||
|
|
||||||
- ✅ Validar permissões no backend antes de mutações
|
|
||||||
- ✅ Validar entrada de dados
|
|
||||||
- ✅ Tratar erros adequadamente
|
|
||||||
- ✅ Logs de segurança (criação/finalização de chamadas)
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- ✅ Cleanup adequado de event listeners
|
|
||||||
- ✅ Desconectar Jitsi ao fechar janela
|
|
||||||
- ✅ Parar gravação ao finalizar chamada
|
|
||||||
- ✅ Liberar streams de mídia
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Notas Importantes
|
|
||||||
|
|
||||||
1. **Room Names:** Gerar room names únicos usando conversaId + timestamp + hash
|
|
||||||
2. **Persistência:** Salvar posição/tamanho da janela no localStorage
|
|
||||||
3. **Notificações:** Notificar participantes quando chamada é criada/finalizada
|
|
||||||
4. **Limpeza:** Sempre limpar recursos ao finalizar chamada
|
|
||||||
5. **Erros:** Tratar erros de conexão, permissões de mídia, etc.
|
|
||||||
6. **Acessibilidade:** Adicionar labels, ARIA attributes, suporte a teclado
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testes
|
|
||||||
|
|
||||||
### Testes Funcionais
|
|
||||||
|
|
||||||
- [ ] Criar chamada de áudio individual
|
|
||||||
- [ ] Criar chamada de vídeo individual
|
|
||||||
- [ ] Criar chamada em grupo
|
|
||||||
- [ ] Toggle áudio/vídeo
|
|
||||||
- [ ] Anfitrião controlar participantes
|
|
||||||
- [ ] Iniciar/parar gravação
|
|
||||||
- [ ] Contador de duração
|
|
||||||
- [ ] Configuração de dispositivos
|
|
||||||
- [ ] Janela flutuante drag/resize
|
|
||||||
|
|
||||||
### Testes de Segurança
|
|
||||||
|
|
||||||
- [ ] Não anfitrião não pode controlar outros
|
|
||||||
- [ ] Não anfitrião não pode iniciar gravação
|
|
||||||
- [ ] Validação de participantes
|
|
||||||
- [ ] Rate limiting de criação de chamadas
|
|
||||||
|
|
||||||
### Testes de Erros
|
|
||||||
|
|
||||||
- [ ] Conexão perdida
|
|
||||||
- [ ] Sem permissão de mídia
|
|
||||||
- [ ] Dispositivos não disponíveis
|
|
||||||
- [ ] Servidor Jitsi offline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Referências
|
|
||||||
|
|
||||||
- [Jitsi Meet Docker](https://github.com/jitsi/docker-jitsi-meet)
|
|
||||||
- [lib-jitsi-meet Documentation](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api)
|
|
||||||
- [Svelte 5 Documentation](https://svelte.dev/docs)
|
|
||||||
- [Convex Documentation](https://docs.convex.dev)
|
|
||||||
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
|
|
||||||
- [MediaRecorder API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Data de Criação:** 2025-01-XX
|
|
||||||
**Versão:** 1.0
|
|
||||||
**Opção:** Docker Local (Desenvolvimento)
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Relatório de Testes - Sistema de Temas Personalizados
|
|
||||||
|
|
||||||
## Data: 2025-01-27
|
|
||||||
|
|
||||||
## Resumo Executivo
|
|
||||||
Foram testados todos os 10 temas disponíveis no sistema SGSE através da aba "Aparência" na página de perfil. Cada tema foi selecionado e validado visualmente através de screenshots.
|
|
||||||
|
|
||||||
## Temas Testados
|
|
||||||
|
|
||||||
### 1. ✅ Tema Roxo (Purple)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema padrão com cores roxa e azul
|
|
||||||
- **Screenshot**: `tema-roxo.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe cores roxas/azuis
|
|
||||||
|
|
||||||
### 2. ✅ Tema Azul (Blue)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema azul clássico e profissional
|
|
||||||
- **Screenshot**: `tema-azul.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de azul
|
|
||||||
|
|
||||||
### 3. ✅ Tema Verde (Green)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema verde natural e harmonioso
|
|
||||||
- **Screenshot**: `tema-verde.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de verde
|
|
||||||
|
|
||||||
### 4. ✅ Tema Laranja (Orange)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema laranja vibrante e energético
|
|
||||||
- **Screenshot**: `tema-laranja.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de laranja
|
|
||||||
|
|
||||||
### 5. ✅ Tema Vermelho (Red)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema vermelho intenso e impactante
|
|
||||||
- **Screenshot**: `tema-vermelho.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de vermelho
|
|
||||||
|
|
||||||
### 6. ✅ Tema Rosa (Pink)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema rosa suave e elegante
|
|
||||||
- **Screenshot**: `tema-rosa.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de rosa
|
|
||||||
|
|
||||||
### 7. ✅ Tema Verde-água (Teal)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema verde-água refrescante
|
|
||||||
- **Screenshot**: `tema-verde-agua.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de verde-água
|
|
||||||
|
|
||||||
### 8. ✅ Tema Escuro (Dark)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema escuro para uso noturno
|
|
||||||
- **Screenshot**: `tema-escuro.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe fundo escuro
|
|
||||||
|
|
||||||
### 9. ✅ Tema Claro (Light)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema claro e minimalista
|
|
||||||
- **Screenshot**: `tema-claro.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe fundo claro
|
|
||||||
|
|
||||||
### 10. ✅ Tema Corporativo (Corporate)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema corporativo azul escuro
|
|
||||||
- **Screenshot**: `tema-corporativo.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons corporativos
|
|
||||||
|
|
||||||
## Funcionalidades Testadas
|
|
||||||
|
|
||||||
### ✅ Seleção de Temas
|
|
||||||
- Todos os 10 temas podem ser selecionados através dos botões na interface
|
|
||||||
- A seleção é visualmente indicada com "Tema Ativo"
|
|
||||||
- A mudança de tema é aplicada imediatamente na interface
|
|
||||||
|
|
||||||
### ✅ Interface de Seleção
|
|
||||||
- A aba "Aparência" está acessível na página de perfil
|
|
||||||
- Todos os 10 temas são exibidos em cards com preview visual
|
|
||||||
- Cada card mostra o nome, descrição e um gradiente de cores representativo
|
|
||||||
|
|
||||||
### ✅ Aplicação de Temas
|
|
||||||
- Os temas são aplicados dinamicamente ao elemento `<html>` via atributo `data-theme`
|
|
||||||
- As cores são alteradas em toda a interface (sidebar, header, botões, etc.)
|
|
||||||
- A mudança é instantânea, sem necessidade de recarregar a página
|
|
||||||
|
|
||||||
## Screenshots Capturados
|
|
||||||
|
|
||||||
Todos os screenshots foram salvos com os seguintes nomes:
|
|
||||||
- `tema-verde-agua-atual.png` - Estado inicial (tema verde-água)
|
|
||||||
- `tema-roxo.png`
|
|
||||||
- `tema-azul.png`
|
|
||||||
- `tema-verde.png`
|
|
||||||
- `tema-laranja.png`
|
|
||||||
- `tema-vermelho.png`
|
|
||||||
- `tema-rosa.png`
|
|
||||||
- `tema-verde-agua.png`
|
|
||||||
- `tema-escuro.png`
|
|
||||||
- `tema-claro.png`
|
|
||||||
- `tema-corporativo.png`
|
|
||||||
|
|
||||||
## Conclusão
|
|
||||||
|
|
||||||
✅ **Todos os 10 temas estão funcionando corretamente!**
|
|
||||||
|
|
||||||
- Cada tema altera a aparência da interface conforme esperado
|
|
||||||
- As cores são aplicadas consistentemente em todos os componentes
|
|
||||||
- A seleção de temas funciona de forma intuitiva e responsiva
|
|
||||||
- O sistema está pronto para uso em produção
|
|
||||||
|
|
||||||
## Próximos Passos Recomendados
|
|
||||||
|
|
||||||
1. Testar a persistência do tema salvo no banco de dados
|
|
||||||
2. Validar que o tema é aplicado automaticamente ao fazer login
|
|
||||||
3. Verificar que o tema padrão (roxo) é aplicado ao fazer logout
|
|
||||||
4. Testar com diferentes usuários para garantir isolamento de preferências
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Validação e Correções do Sistema de Temas
|
|
||||||
|
|
||||||
## Correções Implementadas
|
|
||||||
|
|
||||||
### 1. Temas Customizados Melhorados
|
|
||||||
- Adicionadas todas as variáveis CSS necessárias do DaisyUI para cada tema customizado
|
|
||||||
- Incluídas variáveis de arredondamento, animação e bordas
|
|
||||||
- Adicionado `color-scheme` para temas claros/escuros
|
|
||||||
|
|
||||||
### 2. Estrutura Padronizada
|
|
||||||
- Todos os temas customizados seguem o mesmo padrão de variáveis CSS
|
|
||||||
- Temas nativos do DaisyUI (purple/aqua, dark, light) mantidos
|
|
||||||
- Temas customizados (sgse-blue, sgse-green, etc.) com variáveis completas
|
|
||||||
|
|
||||||
### 3. Aplicação de Temas
|
|
||||||
- Função `aplicarTema()` atualizada para aplicar corretamente no elemento HTML
|
|
||||||
- Removido localStorage - tema salvo apenas no banco de dados
|
|
||||||
- Tema padrão aplicado ao fazer logout
|
|
||||||
|
|
||||||
## Como Testar Manualmente
|
|
||||||
|
|
||||||
1. **Fazer Login:**
|
|
||||||
- Email: `dfw@poli.br` / Senha: `Admin@2025`
|
|
||||||
- OU Email: `kilder@kilder.com.br` / Senha: `Mudar@123`
|
|
||||||
|
|
||||||
2. **Acessar Página de Perfil:**
|
|
||||||
- Clique no avatar do usuário no canto superior direito
|
|
||||||
- Selecione "Meu Perfil"
|
|
||||||
- OU acesse diretamente: `/perfil`
|
|
||||||
|
|
||||||
3. **Testar Cada Tema:**
|
|
||||||
- Clique na aba "Aparência"
|
|
||||||
- Teste cada um dos 10 temas:
|
|
||||||
- **Roxo** (purple/aqua) - Padrão
|
|
||||||
- **Azul** (sgse-blue)
|
|
||||||
- **Verde** (sgse-green)
|
|
||||||
- **Laranja** (sgse-orange)
|
|
||||||
- **Vermelho** (sgse-red)
|
|
||||||
- **Rosa** (sgse-pink)
|
|
||||||
- **Verde-água** (sgse-teal)
|
|
||||||
- **Escuro** (dark)
|
|
||||||
- **Claro** (light)
|
|
||||||
- **Corporativo** (sgse-corporate)
|
|
||||||
|
|
||||||
4. **Validar Mudanças:**
|
|
||||||
- Ao clicar em um tema, a interface deve mudar imediatamente
|
|
||||||
- Verificar cores em:
|
|
||||||
- Sidebar
|
|
||||||
- Botões
|
|
||||||
- Cards
|
|
||||||
- Badges
|
|
||||||
- Links
|
|
||||||
- Backgrounds
|
|
||||||
|
|
||||||
5. **Salvar Tema:**
|
|
||||||
- Clique em "Salvar Tema" após selecionar
|
|
||||||
- Faça logout e login novamente
|
|
||||||
- O tema salvo deve ser aplicado automaticamente
|
|
||||||
|
|
||||||
6. **Testar Logout:**
|
|
||||||
- Ao fazer logout, o tema deve voltar ao padrão (roxo)
|
|
||||||
|
|
||||||
## Problemas Identificados e Corrigidos
|
|
||||||
|
|
||||||
1. ✅ Variáveis CSS incompletas nos temas customizados
|
|
||||||
2. ✅ Falta de `color-scheme` nos temas
|
|
||||||
3. ✅ localStorage removido (tema apenas no banco)
|
|
||||||
4. ✅ Tema padrão aplicado ao logout
|
|
||||||
5. ✅ Estrutura padronizada de todos os temas
|
|
||||||
|
|
||||||
## Próximos Passos para Validação
|
|
||||||
|
|
||||||
Se algum tema não estiver funcionando:
|
|
||||||
|
|
||||||
1. Verificar no console do navegador (F12) se há erros
|
|
||||||
2. Verificar o atributo `data-theme` no elemento `<html>` (deve mudar ao selecionar tema)
|
|
||||||
3. Verificar se as variáveis CSS estão sendo aplicadas (DevTools > Elements > Computed)
|
|
||||||
4. Testar em modo anônimo para garantir que não há cache
|
|
||||||
|
|
||||||
## Arquivos Modificados
|
|
||||||
|
|
||||||
- `apps/web/src/app.css` - Temas customizados melhorados
|
|
||||||
- `apps/web/src/lib/utils/temas.ts` - Funções de aplicação de temas
|
|
||||||
- `apps/web/src/routes/+layout.svelte` - Aplicação automática do tema
|
|
||||||
- `apps/web/src/routes/(dashboard)/perfil/+page.svelte` - Interface de seleção
|
|
||||||
- `apps/web/src/lib/components/Sidebar.svelte` - Reset de tema no logout
|
|
||||||
- `packages/backend/convex/schema.ts` - Campo temaPreferido
|
|
||||||
- `packages/backend/convex/usuarios.ts` - Função atualizarTema
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# ⚙️ Configuração de Variáveis de Ambiente
|
|
||||||
|
|
||||||
## 📁 Arquivo .env
|
|
||||||
|
|
||||||
Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Google Maps API Key (opcional)
|
|
||||||
# Obtenha sua chave em: https://console.cloud.google.com/
|
|
||||||
# Ative a "Geocoding API" para buscar coordenadas por endereço
|
|
||||||
# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave)
|
|
||||||
VITE_GOOGLE_MAPS_API_KEY=
|
|
||||||
|
|
||||||
# VAPID Public Key para Push Notifications (opcional)
|
|
||||||
VITE_VAPID_PUBLIC_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📖 Documentação Completa
|
|
||||||
|
|
||||||
Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte:
|
|
||||||
|
|
||||||
📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)**
|
|
||||||
|
|
||||||
## ⚠️ Importante
|
|
||||||
|
|
||||||
- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore)
|
|
||||||
- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend
|
|
||||||
- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env`
|
|
||||||
|
|
||||||
68
apps/web/Dockerfile
Normal file
68
apps/web/Dockerfile
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# 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
|
||||||
|
|
||||||
|
# Copy root node_modules (contains hoisted dependencies)
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy built application and workspace files
|
||||||
|
COPY --from=builder /app/apps/web/build ./apps/web/build
|
||||||
|
COPY --from=builder /app/apps/web/package.json ./apps/web/package.json
|
||||||
|
# Copy workspace node_modules (contains symlinks to root node_modules)
|
||||||
|
COPY --from=builder /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
|
|
||||||
|
# Copy any additional files needed for runtime
|
||||||
|
COPY --from=builder /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"]
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
# 📍 Configuração do Google Maps API para Busca de Coordenadas
|
|
||||||
|
|
||||||
Este guia explica como configurar a API do Google Maps para obter coordenadas GPS de forma automática e precisa no sistema de Endereços de Marcação.
|
|
||||||
|
|
||||||
## 🎯 Por que usar Google Maps?
|
|
||||||
|
|
||||||
- ✅ **Maior Precisão**: Resultados mais exatos para endereços brasileiros
|
|
||||||
- ✅ **Melhor Cobertura**: Banco de dados mais completo e atualizado
|
|
||||||
- ✅ **Geocoding Avançado**: Entende melhor endereços incompletos ou parciais
|
|
||||||
|
|
||||||
> **Nota**: O sistema funciona perfeitamente sem a API key do Google Maps, usando OpenStreetMap (gratuito). A configuração do Google Maps é opcional.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Passo a Passo
|
|
||||||
|
|
||||||
### 1. Criar Projeto no Google Cloud Platform
|
|
||||||
|
|
||||||
1. Acesse [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
2. Clique em **"Criar Projeto"** ou selecione um projeto existente
|
|
||||||
3. Preencha o nome do projeto (ex: "SGSE-App")
|
|
||||||
4. Clique em **"Criar"**
|
|
||||||
|
|
||||||
### 2. Ativar a Geocoding API
|
|
||||||
|
|
||||||
1. No menu lateral, vá em **"APIs e Serviços"** > **"Biblioteca"**
|
|
||||||
2. Procure por **"Geocoding API"**
|
|
||||||
3. Clique no resultado e depois em **"Ativar"**
|
|
||||||
4. Aguarde alguns segundos para a ativação
|
|
||||||
|
|
||||||
### 3. Criar Chave de API
|
|
||||||
|
|
||||||
1. Ainda em **"APIs e Serviços"**, vá em **"Credenciais"**
|
|
||||||
2. Clique em **"Criar Credenciais"** > **"Chave de API"**
|
|
||||||
3. Copie a chave gerada (você precisará dela depois)
|
|
||||||
|
|
||||||
### 4. Configurar Restrições de Segurança (Recomendado)
|
|
||||||
|
|
||||||
Para proteger sua chave de API:
|
|
||||||
|
|
||||||
1. Clique na chave criada para editá-la
|
|
||||||
2. Em **"Restrições de API"**:
|
|
||||||
- Selecione **"Restringir chave"**
|
|
||||||
- Escolha **"Geocoding API"**
|
|
||||||
3. Em **"Restrições de aplicativo"**:
|
|
||||||
- Para desenvolvimento local: escolha **"Referenciadores de sites HTTP"**
|
|
||||||
- Adicione: `http://localhost:*` e `http://127.0.0.1:*`
|
|
||||||
- Para produção: adicione o domínio do seu site
|
|
||||||
4. Clique em **"Salvar"**
|
|
||||||
|
|
||||||
### 5. Configurar no Projeto
|
|
||||||
|
|
||||||
1. No diretório `apps/web/`, copie o arquivo de exemplo:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Abra o arquivo `.env` e adicione sua chave:
|
|
||||||
```env
|
|
||||||
VITE_GOOGLE_MAPS_API_KEY=sua_chave_aqui
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Reinicie o servidor de desenvolvimento:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Verificar se está funcionando
|
|
||||||
|
|
||||||
1. Acesse a página de **Endereços de Marcação** (`/ti/configuracoes-ponto/enderecos`)
|
|
||||||
2. Clique em **"Novo Endereço"**
|
|
||||||
3. Preencha um endereço e clique em **"Buscar GPS"**
|
|
||||||
4. Se configurado corretamente, verá a mensagem: *"Coordenadas encontradas via Google Maps!"*
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💰 Custos
|
|
||||||
|
|
||||||
### Google Maps Geocoding API
|
|
||||||
|
|
||||||
- **$5.00 por 1.000 requisições** (primeiros 40.000 são gratuitos por mês)
|
|
||||||
- **$0.005 por requisição** após os 40.000 gratuitos
|
|
||||||
|
|
||||||
> 💡 Para a maioria dos casos de uso, os 40.000 gratuitos são suficientes!
|
|
||||||
|
|
||||||
### OpenStreetMap (Fallback)
|
|
||||||
|
|
||||||
- **100% Gratuito** e ilimitado
|
|
||||||
- Sem necessidade de configuração
|
|
||||||
- Precisão levemente menor, mas ainda muito boa
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Como funciona o sistema
|
|
||||||
|
|
||||||
O sistema foi projetado para usar uma estratégia de **fallback inteligente**:
|
|
||||||
|
|
||||||
1. **Primeiro**: Tenta buscar via Google Maps (se API key configurada)
|
|
||||||
2. **Se falhar ou não tiver API key**: Usa automaticamente OpenStreetMap
|
|
||||||
3. **Feedback**: Informa qual serviço foi usado na mensagem de sucesso
|
|
||||||
|
|
||||||
Isso garante que o sistema sempre funcione, mesmo sem a API key do Google Maps.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 Segurança
|
|
||||||
|
|
||||||
### ⚠️ Importante
|
|
||||||
|
|
||||||
- **Nunca** commite o arquivo `.env` no Git (já está no .gitignore)
|
|
||||||
- **Nunca** compartilhe sua chave de API publicamente
|
|
||||||
- Configure **restrições de API** no Google Cloud Console
|
|
||||||
- Para produção, use variáveis de ambiente seguras no seu provedor de hospedagem
|
|
||||||
|
|
||||||
### Configuração em Produção
|
|
||||||
|
|
||||||
Para ambientes de produção (Vercel, Netlify, etc.):
|
|
||||||
|
|
||||||
1. Acesse as configurações do projeto no seu provedor
|
|
||||||
2. Vá em **"Environment Variables"** ou **"Variáveis de Ambiente"**
|
|
||||||
3. Adicione: `VITE_GOOGLE_MAPS_API_KEY` com o valor da sua chave
|
|
||||||
4. Faça o deploy novamente
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Solução de Problemas
|
|
||||||
|
|
||||||
### A busca não está usando Google Maps
|
|
||||||
|
|
||||||
- Verifique se a variável `VITE_GOOGLE_MAPS_API_KEY` está no arquivo `.env`
|
|
||||||
- Reinicie o servidor de desenvolvimento
|
|
||||||
- Verifique no console do navegador se há erros
|
|
||||||
|
|
||||||
### Erro: "This API project is not authorized to use this API"
|
|
||||||
|
|
||||||
- Verifique se a **Geocoding API** está ativada no projeto
|
|
||||||
- Aguarde alguns minutos após a ativação (pode levar até 5 minutos)
|
|
||||||
|
|
||||||
### Erro: "API key not valid"
|
|
||||||
|
|
||||||
- Verifique se copiou a chave corretamente
|
|
||||||
- Verifique se as restrições de API permitem o uso da Geocoding API
|
|
||||||
- Verifique se as restrições de aplicativo permitem seu domínio/endereço
|
|
||||||
|
|
||||||
### Mensagem: "Coordenadas encontradas via OpenStreetMap"
|
|
||||||
|
|
||||||
- Isso é normal se:
|
|
||||||
- Não há API key configurada
|
|
||||||
- A API key não é válida
|
|
||||||
- O Google Maps falhou na busca
|
|
||||||
- O sistema continua funcionando normalmente com OpenStreetMap
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Recursos Úteis
|
|
||||||
|
|
||||||
- [Google Cloud Console](https://console.cloud.google.com/)
|
|
||||||
- [Documentação Geocoding API](https://developers.google.com/maps/documentation/geocoding)
|
|
||||||
- [Preços Google Maps](https://developers.google.com/maps/billing-and-pricing/pricing)
|
|
||||||
- [OpenStreetMap Nominatim](https://nominatim.org/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Resumo
|
|
||||||
|
|
||||||
1. ✅ Crie projeto no Google Cloud
|
|
||||||
2. ✅ Ative Geocoding API
|
|
||||||
3. ✅ Crie chave de API
|
|
||||||
4. ✅ Configure restrições (recomendado)
|
|
||||||
5. ✅ Adicione `VITE_GOOGLE_MAPS_API_KEY` no `.env`
|
|
||||||
6. ✅ Reinicie o servidor
|
|
||||||
|
|
||||||
**Pronto!** O sistema agora usará Google Maps para busca de coordenadas com maior precisão.
|
|
||||||
|
|
||||||
15
apps/web/convex/_generated/api.d.ts
vendored
15
apps/web/convex/_generated/api.d.ts
vendored
@@ -8,11 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type {
|
import type { ApiFromModules, FilterApi, FunctionReference } from 'convex/server';
|
||||||
ApiFromModules,
|
|
||||||
FilterApi,
|
|
||||||
FunctionReference,
|
|
||||||
} from "convex/server";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
@@ -25,13 +21,10 @@ import type {
|
|||||||
declare const fullApi: ApiFromModules<{}>;
|
declare const fullApi: ApiFromModules<{}>;
|
||||||
declare const fullApiWithMounts: typeof fullApi;
|
declare const fullApiWithMounts: typeof fullApi;
|
||||||
|
|
||||||
export declare const api: FilterApi<
|
export declare const api: FilterApi<typeof fullApiWithMounts, FunctionReference<any, 'public'>>;
|
||||||
typeof fullApiWithMounts,
|
|
||||||
FunctionReference<any, "public">
|
|
||||||
>;
|
|
||||||
export declare const internal: FilterApi<
|
export declare const internal: FilterApi<
|
||||||
typeof fullApiWithMounts,
|
typeof fullApiWithMounts,
|
||||||
FunctionReference<any, "internal">
|
FunctionReference<any, 'internal'>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export declare const components: {};
|
export declare const components: {};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { anyApi, componentsGeneric } from "convex/server";
|
import { anyApi, componentsGeneric } from 'convex/server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A utility for referencing Convex functions in your app's API.
|
* A utility for referencing Convex functions in your app's API.
|
||||||
|
|||||||
7
apps/web/convex/_generated/dataModel.d.ts
vendored
7
apps/web/convex/_generated/dataModel.d.ts
vendored
@@ -8,8 +8,8 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { AnyDataModel } from "convex/server";
|
import { AnyDataModel } from 'convex/server';
|
||||||
import type { GenericId } from "convex/values";
|
import type { GenericId } from 'convex/values';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No `schema.ts` file found!
|
* No `schema.ts` file found!
|
||||||
@@ -43,8 +43,7 @@ export type Doc = any;
|
|||||||
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
* IDs are just strings at runtime, but this type can be used to distinguish them from other
|
||||||
* strings when type checking.
|
* strings when type checking.
|
||||||
*/
|
*/
|
||||||
export type Id<TableName extends TableNames = TableNames> =
|
export type Id<TableName extends TableNames = TableNames> = GenericId<TableName>;
|
||||||
GenericId<TableName>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type describing your Convex data model.
|
* A type describing your Convex data model.
|
||||||
|
|||||||
44
apps/web/convex/_generated/server.d.ts
vendored
44
apps/web/convex/_generated/server.d.ts
vendored
@@ -9,24 +9,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ActionBuilder,
|
ActionBuilder,
|
||||||
AnyComponents,
|
AnyComponents,
|
||||||
HttpActionBuilder,
|
HttpActionBuilder,
|
||||||
MutationBuilder,
|
MutationBuilder,
|
||||||
QueryBuilder,
|
QueryBuilder,
|
||||||
GenericActionCtx,
|
GenericActionCtx,
|
||||||
GenericMutationCtx,
|
GenericMutationCtx,
|
||||||
GenericQueryCtx,
|
GenericQueryCtx,
|
||||||
GenericDatabaseReader,
|
GenericDatabaseReader,
|
||||||
GenericDatabaseWriter,
|
GenericDatabaseWriter,
|
||||||
FunctionReference,
|
FunctionReference
|
||||||
} from "convex/server";
|
} from 'convex/server';
|
||||||
import type { DataModel } from "./dataModel.js";
|
import type { DataModel } from './dataModel.js';
|
||||||
|
|
||||||
type GenericCtx =
|
type GenericCtx =
|
||||||
| GenericActionCtx<DataModel>
|
| GenericActionCtx<DataModel>
|
||||||
| GenericMutationCtx<DataModel>
|
| GenericMutationCtx<DataModel>
|
||||||
| GenericQueryCtx<DataModel>;
|
| GenericQueryCtx<DataModel>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
@@ -36,7 +36,7 @@ type GenericCtx =
|
|||||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const query: QueryBuilder<DataModel, "public">;
|
export declare const query: QueryBuilder<DataModel, 'public'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query that is only accessible from other Convex functions (but not from the client).
|
* Define a query that is only accessible from other Convex functions (but not from the client).
|
||||||
@@ -46,7 +46,7 @@ export declare const query: QueryBuilder<DataModel, "public">;
|
|||||||
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
* @param func - The query function. It receives a {@link QueryCtx} as its first argument.
|
||||||
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped query. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
export declare const internalQuery: QueryBuilder<DataModel, 'internal'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a mutation in this Convex app's public API.
|
* Define a mutation in this Convex app's public API.
|
||||||
@@ -56,7 +56,7 @@ export declare const internalQuery: QueryBuilder<DataModel, "internal">;
|
|||||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const mutation: MutationBuilder<DataModel, "public">;
|
export declare const mutation: MutationBuilder<DataModel, 'public'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
* Define a mutation that is only accessible from other Convex functions (but not from the client).
|
||||||
@@ -66,7 +66,7 @@ export declare const mutation: MutationBuilder<DataModel, "public">;
|
|||||||
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
* @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
|
||||||
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
export declare const internalMutation: MutationBuilder<DataModel, 'internal'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define an action in this Convex app's public API.
|
* Define an action in this Convex app's public API.
|
||||||
@@ -79,7 +79,7 @@ export declare const internalMutation: MutationBuilder<DataModel, "internal">;
|
|||||||
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
* @param func - The action. It receives an {@link ActionCtx} as its first argument.
|
||||||
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped action. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const action: ActionBuilder<DataModel, "public">;
|
export declare const action: ActionBuilder<DataModel, 'public'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define an action that is only accessible from other Convex functions (but not from the client).
|
* Define an action that is only accessible from other Convex functions (but not from the client).
|
||||||
@@ -87,7 +87,7 @@ export declare const action: ActionBuilder<DataModel, "public">;
|
|||||||
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
* @param func - The function. It receives an {@link ActionCtx} as its first argument.
|
||||||
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
* @returns The wrapped function. Include this as an `export` to name it and make it accessible.
|
||||||
*/
|
*/
|
||||||
export declare const internalAction: ActionBuilder<DataModel, "internal">;
|
export declare const internalAction: ActionBuilder<DataModel, 'internal'>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define an HTTP action.
|
* Define an HTTP action.
|
||||||
|
|||||||
@@ -9,15 +9,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
actionGeneric,
|
actionGeneric,
|
||||||
httpActionGeneric,
|
httpActionGeneric,
|
||||||
queryGeneric,
|
queryGeneric,
|
||||||
mutationGeneric,
|
mutationGeneric,
|
||||||
internalActionGeneric,
|
internalActionGeneric,
|
||||||
internalMutationGeneric,
|
internalMutationGeneric,
|
||||||
internalQueryGeneric,
|
internalQueryGeneric,
|
||||||
componentsGeneric,
|
componentsGeneric
|
||||||
} from "convex/server";
|
} from 'convex/server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Define a query in this Convex app's public API.
|
* Define a query in this Convex app's public API.
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import { config as svelteConfigBase } from '@sgse-app/eslint-config/svelte';
|
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 ts from 'typescript-eslint';
|
||||||
import { defineConfig } from "eslint/config";
|
import svelteConfig from './svelte.config.js';
|
||||||
|
|
||||||
/** @type {import("eslint").Linter.Config} */
|
/** @type {import("eslint").Linter.Config} */
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
...svelteConfigBase,
|
...svelteConfigBase,
|
||||||
{
|
{
|
||||||
files: ['**/*.svelte'],
|
files: ['**/*.svelte'],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
parser: ts.parser,
|
parser: ts.parser,
|
||||||
extraFileExtensions: ['.svelte'],
|
extraFileExtensions: ['.svelte'],
|
||||||
svelteConfig
|
svelteConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: [
|
ignores: [
|
||||||
'**/node_modules/**',
|
'**/node_modules/**',
|
||||||
'**/.svelte-kit/**',
|
'**/.svelte-kit/**',
|
||||||
'**/build/**',
|
'**/build/**',
|
||||||
'**/dist/**',
|
'**/dist/**',
|
||||||
'**/.turbo/**'
|
'**/.turbo/**'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
])
|
]);
|
||||||
|
|||||||
@@ -1,59 +1,70 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "bunx --bun vite dev",
|
||||||
"build": "vite build",
|
"build": "bunx --bun vite build",
|
||||||
"preview": "vite preview",
|
"preview": "bunx --bun vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
},
|
"lint": "eslint .",
|
||||||
"devDependencies": {
|
"format": "prettier --write ."
|
||||||
"@sgse-app/eslint-config": "*",
|
},
|
||||||
"@sveltejs/adapter-auto": "^6.1.0",
|
"devDependencies": {
|
||||||
"@sveltejs/kit": "^2.31.1",
|
"@sgse-app/eslint-config": "*",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.1.2",
|
"@sveltejs/adapter-auto": "^6.1.0",
|
||||||
"@tailwindcss/vite": "^4.1.12",
|
"@sveltejs/kit": "^2.31.1",
|
||||||
"autoprefixer": "^10.4.21",
|
"@sveltejs/vite-plugin-svelte": "^6.1.2",
|
||||||
"daisyui": "^5.3.8",
|
"@tailwindcss/vite": "^4.1.12",
|
||||||
"esbuild": "^0.25.11",
|
"autoprefixer": "^10.4.21",
|
||||||
"postcss": "^8.5.6",
|
"daisyui": "^5.3.8",
|
||||||
"svelte": "^5.38.1",
|
"esbuild": "^0.25.11",
|
||||||
"svelte-check": "^4.3.1",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^4.1.12",
|
"svelte": "^5.38.1",
|
||||||
"typescript": "catalog:",
|
"svelte-adapter-bun": "^1.0.1",
|
||||||
"vite": "^7.1.2"
|
"svelte-check": "^4.3.1",
|
||||||
},
|
"svelte-dnd-action": "^0.9.67",
|
||||||
"dependencies": {
|
"tailwindcss": "^4.1.12",
|
||||||
"@convex-dev/better-auth": "^0.9.7",
|
"typescript": "catalog:",
|
||||||
"@dicebear/collection": "^9.2.4",
|
"vite": "^7.1.2"
|
||||||
"@dicebear/core": "^9.2.4",
|
},
|
||||||
"@fullcalendar/core": "^6.1.19",
|
"dependencies": {
|
||||||
"@fullcalendar/daygrid": "^6.1.19",
|
"@ark-ui/svelte": "^5.15.0",
|
||||||
"@fullcalendar/interaction": "^6.1.19",
|
"@convex-dev/better-auth": "^0.9.7",
|
||||||
"@fullcalendar/list": "^6.1.19",
|
"@dicebear/collection": "^9.2.4",
|
||||||
"@fullcalendar/multimonth": "^6.1.19",
|
"@dicebear/core": "^9.2.4",
|
||||||
"@internationalized/date": "^3.10.0",
|
"@fullcalendar/core": "^6.1.19",
|
||||||
"@mmailaender/convex-better-auth-svelte": "^0.2.0",
|
"@fullcalendar/daygrid": "^6.1.19",
|
||||||
"@sgse-app/backend": "*",
|
"@fullcalendar/interaction": "^6.1.19",
|
||||||
"@tanstack/svelte-form": "^1.19.2",
|
"@fullcalendar/list": "^6.1.19",
|
||||||
"@types/papaparse": "^5.3.14",
|
"@fullcalendar/multimonth": "^6.1.19",
|
||||||
"better-auth": "catalog:",
|
"@internationalized/date": "^3.10.0",
|
||||||
"convex": "catalog:",
|
"@mmailaender/convex-better-auth-svelte": "^0.2.0",
|
||||||
"convex-svelte": "^0.0.12",
|
"@sgse-app/backend": "*",
|
||||||
"date-fns": "^4.1.0",
|
"@tanstack/svelte-form": "^1.19.2",
|
||||||
"emoji-picker-element": "^1.27.0",
|
"@types/papaparse": "^5.3.14",
|
||||||
"eslint": "catalog:",
|
"better-auth": "catalog:",
|
||||||
"is-network-error": "^1.3.0",
|
"convex": "catalog:",
|
||||||
"jspdf": "^3.0.3",
|
"convex-svelte": "^0.0.12",
|
||||||
"jspdf-autotable": "^5.0.2",
|
"date-fns": "^4.1.0",
|
||||||
"lib-jitsi-meet": "^1.0.6",
|
"emoji-picker-element": "^1.27.0",
|
||||||
"lucide-svelte": "^0.552.0",
|
"eslint": "catalog:",
|
||||||
"papaparse": "^5.4.1",
|
"exceljs": "^4.4.0",
|
||||||
"svelte-sonner": "^1.0.5",
|
"html5-qrcode": "^2.3.8",
|
||||||
"zod": "^4.1.12"
|
"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,480 +1,368 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@plugin "daisyui";
|
@plugin 'daisyui';
|
||||||
|
|
||||||
/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */
|
/* FullCalendar CSS - v6 não exporta CSS separado, estilos são aplicados via JavaScript */
|
||||||
|
|
||||||
/* Estilo padrão dos botões - mesmo estilo do sidebar */
|
/* Estilo padrão dos botões - mesmo estilo do sidebar */
|
||||||
.btn-standard {
|
.btn-standard {
|
||||||
@apply 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 */
|
/* Sobrescrever estilos DaisyUI para seguir o padrão */
|
||||||
.btn-primary {
|
.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 {
|
.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 {
|
.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) {
|
/* Tema Aqua (padrão roxo/azul) - redefinido como custom para garantir compatibilidade */
|
||||||
position: relative;
|
@plugin 'daisyui/theme' {
|
||||||
overflow: hidden;
|
name: 'aqua';
|
||||||
transform: translateY(0);
|
default: true;
|
||||||
transition: transform 220ms ease, box-shadow 220ms ease;
|
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 {
|
/* Temas customizados para SGSE */
|
||||||
content: "";
|
|
||||||
position: absolute;
|
/* Azul */
|
||||||
inset: -2px;
|
@plugin 'daisyui/theme' {
|
||||||
border-radius: 1.15rem;
|
name: 'sgse-blue';
|
||||||
box-shadow:
|
color-scheme: light;
|
||||||
0 0 0 1px hsl(var(--bc) / 0.04),
|
--color-primary: hsl(217 91% 55%);
|
||||||
0 14px 32px -22px hsl(var(--bc) / 0.45),
|
--color-primary-content: hsl(0 0% 100%);
|
||||||
0 6px 18px -16px hsl(var(--p) / 0.35);
|
--color-secondary: hsl(217 91% 55%);
|
||||||
opacity: 0.55;
|
--color-secondary-content: hsl(0 0% 100%);
|
||||||
transition: opacity 220ms ease, transform 220ms ease;
|
--color-accent: hsl(217 91% 55%);
|
||||||
pointer-events: none;
|
--color-accent-content: hsl(0 0% 100%);
|
||||||
z-index: 0;
|
--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 {
|
/* Verde */
|
||||||
content: "";
|
@plugin 'daisyui/theme' {
|
||||||
position: absolute;
|
name: 'sgse-green';
|
||||||
inset: 0;
|
color-scheme: light;
|
||||||
border-radius: 1rem;
|
--color-primary: hsl(142 76% 36%);
|
||||||
background: linear-gradient(135deg, hsl(var(--p) / 0.12), hsl(var(--s) / 0.12));
|
--color-primary-content: hsl(0 0% 100%);
|
||||||
opacity: 0;
|
--color-secondary: hsl(142 76% 36%);
|
||||||
transform: scale(0.96);
|
--color-secondary-content: hsl(0 0% 100%);
|
||||||
transition: opacity 220ms ease, transform 220ms ease;
|
--color-accent: hsl(142 76% 36%);
|
||||||
pointer-events: none;
|
--color-accent-content: hsl(0 0% 100%);
|
||||||
z-index: 1;
|
--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 {
|
/* Laranja */
|
||||||
transform: translateY(-6px);
|
@plugin 'daisyui/theme' {
|
||||||
box-shadow: 0 20px 45px -20px hsl(var(--bc) / 0.35);
|
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 {
|
/* Vermelho */
|
||||||
opacity: 0.9;
|
@plugin 'daisyui/theme' {
|
||||||
transform: scale(1);
|
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 {
|
/* Rosa */
|
||||||
opacity: 1;
|
@plugin 'daisyui/theme' {
|
||||||
transform: scale(1);
|
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) > * {
|
/* Teal */
|
||||||
position: relative;
|
@plugin 'daisyui/theme' {
|
||||||
z-index: 2;
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tema Aqua (padrão roxo/azul) - customizado para garantir funcionamento */
|
/* Corporativo (Dark Blue) */
|
||||||
html[data-theme="aqua"],
|
@plugin 'daisyui/theme' {
|
||||||
html[data-theme="aqua"] body,
|
name: 'sgse-corporate';
|
||||||
[data-theme="aqua"] {
|
color-scheme: dark;
|
||||||
color-scheme: light;
|
--color-primary: hsl(217 91% 55%);
|
||||||
--p: 217 91% 60%;
|
--color-primary-content: hsl(0 0% 100%);
|
||||||
--pf: 217 91% 50%;
|
--color-secondary: hsl(217 91% 55%);
|
||||||
--pc: 0 0% 100%;
|
--color-secondary-content: hsl(0 0% 100%);
|
||||||
--s: 217 91% 60%;
|
--color-accent: hsl(217 91% 55%);
|
||||||
--sf: 217 91% 50%;
|
--color-accent-content: hsl(0 0% 100%);
|
||||||
--sc: 0 0% 100%;
|
--color-neutral: hsl(217 30% 15%);
|
||||||
--a: 217 91% 60%;
|
--color-neutral-content: hsl(0 0% 100%);
|
||||||
--af: 217 91% 50%;
|
/* Aproxima do fundo do login (Tailwind slate-900 = #0f172a) */
|
||||||
--ac: 0 0% 100%;
|
--color-base-100: hsl(222 47% 11%);
|
||||||
--n: 217 20% 17%;
|
/* Escala de contraste (slate-800 / slate-700 aproximados) */
|
||||||
--nf: 217 20% 10%;
|
--color-base-200: hsl(215 28% 17%);
|
||||||
--nc: 0 0% 100%;
|
--color-base-300: hsl(215 25% 23%);
|
||||||
--b1: 0 0% 100%;
|
--color-base-content: hsl(217 10% 90%);
|
||||||
--b2: 217 20% 95%;
|
--color-info: hsl(217 91% 60%);
|
||||||
--b3: 217 20% 90%;
|
--color-info-content: hsl(0 0% 100%);
|
||||||
--bc: 217 20% 17%;
|
--color-success: hsl(142 76% 36%);
|
||||||
--in: 217 91% 60%;
|
--color-success-content: hsl(0 0% 100%);
|
||||||
--inc: 0 0% 100%;
|
--color-warning: hsl(38 92% 50%);
|
||||||
--su: 142 76% 36%;
|
--color-warning-content: hsl(0 0% 100%);
|
||||||
--suc: 0 0% 100%;
|
--color-error: hsl(0 84% 60%);
|
||||||
--wa: 38 92% 50%;
|
--color-error-content: hsl(0 0% 100%);
|
||||||
--wac: 0 0% 100%;
|
--radius-selector: 0.5rem;
|
||||||
--er: 0 84% 60%;
|
--radius-field: 0.5rem;
|
||||||
--erc: 0 0% 100%;
|
--radius-box: 1rem;
|
||||||
--rounded-box: 1rem;
|
--size-selector: 0.25rem;
|
||||||
--rounded-btn: 0.5rem;
|
--size-field: 0.25rem;
|
||||||
--rounded-badge: 1.9rem;
|
--border: 1px;
|
||||||
--animation-btn: 0.25s;
|
--depth: 1;
|
||||||
--animation-input: 0.2s;
|
--noise: 0;
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Temas customizados para SGSE - Azul */
|
/* Light */
|
||||||
html[data-theme="sgse-blue"],
|
@plugin 'daisyui/theme' {
|
||||||
html[data-theme="sgse-blue"] body,
|
name: 'light';
|
||||||
[data-theme="sgse-blue"] {
|
color-scheme: light;
|
||||||
color-scheme: light;
|
--color-primary: hsl(217 91% 55%);
|
||||||
--p: 217 91% 60%;
|
--color-primary-content: hsl(0 0% 100%);
|
||||||
--pf: 217 91% 50%;
|
--color-secondary: hsl(217 91% 55%);
|
||||||
--pc: 0 0% 100%;
|
--color-secondary-content: hsl(0 0% 100%);
|
||||||
--s: 217 91% 60%;
|
--color-accent: hsl(217 91% 55%);
|
||||||
--sf: 217 91% 50%;
|
--color-accent-content: hsl(0 0% 100%);
|
||||||
--sc: 0 0% 100%;
|
--color-neutral: hsl(217 20% 17%);
|
||||||
--a: 217 91% 60%;
|
--color-neutral-content: hsl(0 0% 100%);
|
||||||
--af: 217 91% 50%;
|
--color-base-100: hsl(0 0% 100%);
|
||||||
--ac: 0 0% 100%;
|
--color-base-200: hsl(217 20% 95%);
|
||||||
--n: 217 20% 17%;
|
--color-base-300: hsl(217 20% 90%);
|
||||||
--nf: 217 20% 10%;
|
--color-base-content: hsl(217 20% 17%);
|
||||||
--nc: 0 0% 100%;
|
--color-info: hsl(217 91% 60%);
|
||||||
--b1: 0 0% 100%;
|
--color-info-content: hsl(0 0% 100%);
|
||||||
--b2: 217 20% 95%;
|
--color-success: hsl(142 76% 36%);
|
||||||
--b3: 217 20% 90%;
|
--color-success-content: hsl(0 0% 100%);
|
||||||
--bc: 217 20% 17%;
|
--color-warning: hsl(38 92% 50%);
|
||||||
--in: 217 91% 60%;
|
--color-warning-content: hsl(0 0% 100%);
|
||||||
--inc: 0 0% 100%;
|
--color-error: hsl(0 84% 60%);
|
||||||
--su: 142 76% 36%;
|
--color-error-content: hsl(0 0% 100%);
|
||||||
--suc: 0 0% 100%;
|
--radius-selector: 0.5rem;
|
||||||
--wa: 38 92% 50%;
|
--radius-field: 0.5rem;
|
||||||
--wac: 0 0% 100%;
|
--radius-box: 1rem;
|
||||||
--er: 0 84% 60%;
|
--size-selector: 0.25rem;
|
||||||
--erc: 0 0% 100%;
|
--size-field: 0.25rem;
|
||||||
--rounded-box: 1rem;
|
--border: 1px;
|
||||||
--rounded-btn: 0.5rem;
|
--depth: 1;
|
||||||
--rounded-badge: 1.9rem;
|
--noise: 0;
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Garantir que todas as variáveis CSS sejam aplicadas em todos os elementos */
|
/* Dark */
|
||||||
html[data-theme] {
|
@plugin 'daisyui/theme' {
|
||||||
color-scheme: var(--color-scheme, light);
|
name: 'dark';
|
||||||
}
|
color-scheme: dark;
|
||||||
|
--color-primary: hsl(217 91% 55%);
|
||||||
html[data-theme] * {
|
--color-primary-content: hsl(0 0% 100%);
|
||||||
color-scheme: inherit;
|
--color-secondary: hsl(217 91% 55%);
|
||||||
}
|
--color-secondary-content: hsl(0 0% 100%);
|
||||||
|
--color-accent: hsl(217 91% 55%);
|
||||||
html[data-theme="sgse-green"],
|
--color-accent-content: hsl(0 0% 100%);
|
||||||
html[data-theme="sgse-green"] body,
|
--color-neutral: hsl(217 30% 15%);
|
||||||
[data-theme="sgse-green"] {
|
--color-neutral-content: hsl(0 0% 100%);
|
||||||
color-scheme: light;
|
--color-base-100: hsl(217 30% 10%);
|
||||||
--p: 142 76% 36%;
|
--color-base-200: hsl(217 30% 15%);
|
||||||
--pf: 142 76% 26%;
|
--color-base-300: hsl(217 30% 20%);
|
||||||
--pc: 0 0% 100%;
|
--color-base-content: hsl(217 10% 90%);
|
||||||
--s: 142 76% 36%;
|
--color-info: hsl(217 91% 60%);
|
||||||
--sf: 142 76% 26%;
|
--color-info-content: hsl(0 0% 100%);
|
||||||
--sc: 0 0% 100%;
|
--color-success: hsl(142 76% 36%);
|
||||||
--a: 142 76% 36%;
|
--color-success-content: hsl(0 0% 100%);
|
||||||
--af: 142 76% 26%;
|
--color-warning: hsl(38 92% 50%);
|
||||||
--ac: 0 0% 100%;
|
--color-warning-content: hsl(0 0% 100%);
|
||||||
--n: 142 20% 17%;
|
--color-error: hsl(0 84% 60%);
|
||||||
--nf: 142 20% 10%;
|
--color-error-content: hsl(0 0% 100%);
|
||||||
--nc: 0 0% 100%;
|
--radius-selector: 0.5rem;
|
||||||
--b1: 0 0% 100%;
|
--radius-field: 0.5rem;
|
||||||
--b2: 142 20% 95%;
|
--radius-box: 1rem;
|
||||||
--b3: 142 20% 90%;
|
--size-selector: 0.25rem;
|
||||||
--bc: 142 20% 17%;
|
--size-field: 0.25rem;
|
||||||
--in: 142 76% 36%;
|
--border: 1px;
|
||||||
--inc: 0 0% 100%;
|
--depth: 1;
|
||||||
--su: 142 76% 36%;
|
--noise: 0;
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="sgse-orange"],
|
|
||||||
html[data-theme="sgse-orange"] body,
|
|
||||||
[data-theme="sgse-orange"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--p: 25 95% 53%;
|
|
||||||
--pf: 25 95% 43%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 25 95% 53%;
|
|
||||||
--sf: 25 95% 43%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 25 95% 53%;
|
|
||||||
--af: 25 95% 43%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 25 20% 17%;
|
|
||||||
--nf: 25 20% 10%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 0 0% 100%;
|
|
||||||
--b2: 25 20% 95%;
|
|
||||||
--b3: 25 20% 90%;
|
|
||||||
--bc: 25 20% 17%;
|
|
||||||
--in: 25 95% 53%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="sgse-red"],
|
|
||||||
html[data-theme="sgse-red"] body,
|
|
||||||
[data-theme="sgse-red"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--p: 0 84% 60%;
|
|
||||||
--pf: 0 84% 50%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 0 84% 60%;
|
|
||||||
--sf: 0 84% 50%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 0 84% 60%;
|
|
||||||
--af: 0 84% 50%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 0 20% 17%;
|
|
||||||
--nf: 0 20% 10%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 0 0% 100%;
|
|
||||||
--b2: 0 20% 95%;
|
|
||||||
--b3: 0 20% 90%;
|
|
||||||
--bc: 0 20% 17%;
|
|
||||||
--in: 0 84% 60%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="sgse-pink"],
|
|
||||||
html[data-theme="sgse-pink"] body,
|
|
||||||
[data-theme="sgse-pink"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--p: 330 81% 60%;
|
|
||||||
--pf: 330 81% 50%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 330 81% 60%;
|
|
||||||
--sf: 330 81% 50%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 330 81% 60%;
|
|
||||||
--af: 330 81% 50%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 330 20% 17%;
|
|
||||||
--nf: 330 20% 10%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 0 0% 100%;
|
|
||||||
--b2: 330 20% 95%;
|
|
||||||
--b3: 330 20% 90%;
|
|
||||||
--bc: 330 20% 17%;
|
|
||||||
--in: 330 81% 60%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="sgse-teal"],
|
|
||||||
html[data-theme="sgse-teal"] body,
|
|
||||||
[data-theme="sgse-teal"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--p: 173 80% 40%;
|
|
||||||
--pf: 173 80% 30%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 173 80% 40%;
|
|
||||||
--sf: 173 80% 30%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 173 80% 40%;
|
|
||||||
--af: 173 80% 30%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 173 20% 17%;
|
|
||||||
--nf: 173 20% 10%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 0 0% 100%;
|
|
||||||
--b2: 173 20% 95%;
|
|
||||||
--b3: 173 20% 90%;
|
|
||||||
--bc: 173 20% 17%;
|
|
||||||
--in: 173 80% 40%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="sgse-corporate"],
|
|
||||||
html[data-theme="sgse-corporate"] body,
|
|
||||||
[data-theme="sgse-corporate"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--p: 217 91% 60%;
|
|
||||||
--pf: 217 91% 50%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 217 91% 60%;
|
|
||||||
--sf: 217 91% 50%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 217 91% 60%;
|
|
||||||
--af: 217 91% 50%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 217 30% 15%;
|
|
||||||
--nf: 217 30% 8%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 217 30% 10%;
|
|
||||||
--b2: 217 30% 15%;
|
|
||||||
--b3: 217 30% 20%;
|
|
||||||
--bc: 217 10% 90%;
|
|
||||||
--in: 217 91% 60%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tema Light customizado para garantir funcionamento completo */
|
|
||||||
html[data-theme="light"],
|
|
||||||
html[data-theme="light"] body,
|
|
||||||
[data-theme="light"] {
|
|
||||||
color-scheme: light;
|
|
||||||
--p: 217 91% 60%;
|
|
||||||
--pf: 217 91% 50%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 217 91% 60%;
|
|
||||||
--sf: 217 91% 50%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 217 91% 60%;
|
|
||||||
--af: 217 91% 50%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 217 20% 17%;
|
|
||||||
--nf: 217 20% 10%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 0 0% 100%;
|
|
||||||
--b2: 217 20% 95%;
|
|
||||||
--b3: 217 20% 90%;
|
|
||||||
--bc: 217 20% 17%;
|
|
||||||
--in: 217 91% 60%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tema Dark customizado para garantir funcionamento completo */
|
|
||||||
html[data-theme="dark"],
|
|
||||||
html[data-theme="dark"] body,
|
|
||||||
[data-theme="dark"] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--p: 217 91% 60%;
|
|
||||||
--pf: 217 91% 50%;
|
|
||||||
--pc: 0 0% 100%;
|
|
||||||
--s: 217 91% 60%;
|
|
||||||
--sf: 217 91% 50%;
|
|
||||||
--sc: 0 0% 100%;
|
|
||||||
--a: 217 91% 60%;
|
|
||||||
--af: 217 91% 50%;
|
|
||||||
--ac: 0 0% 100%;
|
|
||||||
--n: 217 30% 15%;
|
|
||||||
--nf: 217 30% 8%;
|
|
||||||
--nc: 0 0% 100%;
|
|
||||||
--b1: 217 30% 10%;
|
|
||||||
--b2: 217 30% 15%;
|
|
||||||
--b3: 217 30% 20%;
|
|
||||||
--bc: 217 10% 90%;
|
|
||||||
--in: 217 91% 60%;
|
|
||||||
--inc: 0 0% 100%;
|
|
||||||
--su: 142 76% 36%;
|
|
||||||
--suc: 0 0% 100%;
|
|
||||||
--wa: 38 92% 50%;
|
|
||||||
--wac: 0 0% 100%;
|
|
||||||
--er: 0 84% 60%;
|
|
||||||
--erc: 0 0% 100%;
|
|
||||||
--rounded-box: 1rem;
|
|
||||||
--rounded-btn: 0.5rem;
|
|
||||||
--rounded-badge: 1.9rem;
|
|
||||||
--animation-btn: 0.25s;
|
|
||||||
--animation-input: 0.2s;
|
|
||||||
--btn-focus-scale: 0.95;
|
|
||||||
--border-btn: 1px;
|
|
||||||
--tab-border: 1px;
|
|
||||||
--tab-radius: 0.5rem;
|
|
||||||
}
|
}
|
||||||
10
apps/web/src/app.d.ts
vendored
10
apps/web/src/app.d.ts
vendored
@@ -1,9 +1,9 @@
|
|||||||
declare global {
|
declare global {
|
||||||
namespace App {
|
namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
token: string | undefined;
|
token: string | undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<script>
|
<script>
|
||||||
// Executar IMEDIATAMENTE, de forma síncrona e bloqueante
|
// Executar IMEDIATAMENTE, de forma síncrona e bloqueante
|
||||||
// Não usar IIFE assíncrona, executar direto no escopo global
|
// Não usar IIFE assíncrona, executar direto no escopo global
|
||||||
(function() {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
// Implementar BlobBuilder usando Blob moderno
|
// Implementar BlobBuilder usando Blob moderno
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
this.parts = [];
|
this.parts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
BlobBuilderPolyfill.prototype.append = function(data) {
|
BlobBuilderPolyfill.prototype.append = function (data) {
|
||||||
if (data instanceof Blob) {
|
if (data instanceof Blob) {
|
||||||
this.parts.push(data);
|
this.parts.push(data);
|
||||||
} else if (typeof data === 'string') {
|
} else if (typeof data === 'string') {
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
BlobBuilderPolyfill.prototype.getBlob = function(contentType) {
|
BlobBuilderPolyfill.prototype.getBlob = function (contentType) {
|
||||||
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,11 +108,20 @@
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
// Aplicar tema padrão imediatamente se não houver tema definido
|
// Aplicar tema padrão imediatamente se não houver tema definido
|
||||||
(function() {
|
(function () {
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
var html = document.documentElement;
|
var html = document.documentElement;
|
||||||
if (!html.getAttribute('data-theme')) {
|
if (html && !html.getAttribute('data-theme')) {
|
||||||
html.setAttribute('data-theme', 'aqua');
|
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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,9 +1,177 @@
|
|||||||
import type { Handle } from "@sveltejs/kit";
|
import type { Handle, HandleServerError } from '@sveltejs/kit';
|
||||||
import { createAuth } from "@sgse-app/backend/convex/auth";
|
import { createAuth } from '@sgse-app/backend/convex/auth';
|
||||||
import { getToken } from "@mmailaender/convex-better-auth-svelte/sveltekit";
|
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 }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.token = await getToken(createAuth, event.cookies);
|
event.locals.token = await getToken(createAuth, event.cookies);
|
||||||
|
|
||||||
return resolve(event);
|
// Enforcement para endpoints sensíveis (antes de chegar nas rotas)
|
||||||
|
// - Foco: /api/auth/* (login, logout, etc.)
|
||||||
|
// - Aplica blacklist + rate limit configuráveis via Convex
|
||||||
|
const pathname = event.url.pathname;
|
||||||
|
if (pathname.startsWith('/api/auth/')) {
|
||||||
|
const token = event.locals.token;
|
||||||
|
const client = createConvexHttpClient({ token: token || undefined });
|
||||||
|
|
||||||
|
// Preferir X-Forwarded-For quando existir (proxy), senão fallback do adapter
|
||||||
|
const forwardedFor = event.request.headers.get('x-forwarded-for');
|
||||||
|
const ip =
|
||||||
|
forwardedFor?.split(',')[0]?.trim() ||
|
||||||
|
event.request.headers.get('x-real-ip') ||
|
||||||
|
event.getClientAddress();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Enforcement básico (blacklist + rate limit)
|
||||||
|
const enforcement = await client.mutation(api.security.enforceRequest, {
|
||||||
|
ip,
|
||||||
|
path: pathname,
|
||||||
|
method: event.request.method
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!enforcement.allowed) {
|
||||||
|
const headers = new Headers({ 'Content-Type': 'application/json' });
|
||||||
|
if (enforcement.retryAfterMs) {
|
||||||
|
headers.set('Retry-After', String(Math.ceil(enforcement.retryAfterMs / 1000)));
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(enforcement), { status: enforcement.status, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Análise de ataques e bloqueio automático
|
||||||
|
// Extrair dados da requisição para análise
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
event.request.headers.forEach((value, key) => {
|
||||||
|
headers[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryParams: Record<string, string> = {};
|
||||||
|
event.url.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: string | undefined;
|
||||||
|
try {
|
||||||
|
// Tentar ler body apenas se for POST/PUT/PATCH
|
||||||
|
if (['POST', 'PUT', 'PATCH'].includes(event.request.method)) {
|
||||||
|
const clonedRequest = event.request.clone();
|
||||||
|
body = await clonedRequest.text();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignorar erros ao ler body
|
||||||
|
}
|
||||||
|
|
||||||
|
const analise = await client.mutation(api.security.analisarRequisicaoHTTP, {
|
||||||
|
url: pathname + event.url.search,
|
||||||
|
method: event.request.method,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
queryParams,
|
||||||
|
ipOrigem: ip,
|
||||||
|
userAgent: event.request.headers.get('user-agent') ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
// Se ataque detectado e bloqueio automático aplicado, retornar 403
|
||||||
|
if (analise.ataqueDetectado && analise.bloqueadoAutomatico) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: 'Acesso negado',
|
||||||
|
reason: 'ataque_detectado',
|
||||||
|
tipoAtaque: analise.tipoAtaque,
|
||||||
|
severidade: analise.severidade
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Se o enforcement falhar, não bloquear login (fail-open),
|
||||||
|
// mas registrar erro para observabilidade via handleError (se ocorrer)
|
||||||
|
console.error('❌ Falha no enforcement de segurança:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,14 +5,14 @@
|
|||||||
* Este cliente será usado para autenticação quando Better Auth estiver ativo.
|
* Este cliente será usado para autenticação quando Better Auth estiver ativo.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createAuthClient } from "better-auth/svelte";
|
import { createAuthClient } from 'better-auth/svelte';
|
||||||
import { convexClient } from "@convex-dev/better-auth/client/plugins";
|
import { convexClient } from '@convex-dev/better-auth/client/plugins';
|
||||||
|
|
||||||
// O baseURL deve apontar para o frontend (SvelteKit), não para o Convex diretamente
|
// 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
|
// 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.
|
// ou diretamente se configurado. Com o plugin convexClient, o token é gerenciado automaticamente.
|
||||||
export const authClient = createAuthClient({
|
export const authClient = createAuthClient({
|
||||||
// baseURL padrão é window.location.origin, que é o correto para SvelteKit
|
// baseURL padrão é window.location.origin, que é o correto para SvelteKit
|
||||||
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts
|
// O Better Auth será acessado via rotas HTTP do Convex registradas em http.ts
|
||||||
plugins: [convexClient()],
|
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">
|
<script lang="ts">
|
||||||
import { useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
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'> & {
|
type PeriodoFerias = Doc<'ferias'> & {
|
||||||
funcionario?: Doc<'funcionarios'> | null;
|
funcionario?: Doc<'funcionarios'> | null;
|
||||||
@@ -16,7 +17,7 @@
|
|||||||
onCancelar?: () => void;
|
onCancelar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
|
const { solicitacao, usuarioId, onSucesso, onCancelar }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -129,20 +130,7 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each solicitacao.historicoAlteracoes as hist (hist.data)}
|
{#each solicitacao.historicoAlteracoes as hist (hist.data)}
|
||||||
<div class="text-base-content/70 flex items-center gap-2 text-xs">
|
<div class="text-base-content/70 flex items-center gap-2 text-xs">
|
||||||
<svg
|
<Clock class="h-3 w-3" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-3 w-3"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{formatarData(hist.data)}</span>
|
<span>{formatarData(hist.data)}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{hist.acao}</span>
|
<span>{hist.acao}</span>
|
||||||
@@ -156,23 +144,12 @@
|
|||||||
{#if solicitacao.status !== 'Cancelado_RH'}
|
{#if solicitacao.status !== 'Cancelado_RH'}
|
||||||
<div class="divider mt-6"></div>
|
<div class="divider mt-6"></div>
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<svg
|
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-bold">Cancelar Férias</h3>
|
<h3 class="font-bold">Cancelar Férias</h3>
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não poderá mais ser processada.
|
Ao cancelar as férias, o status será alterado para "Cancelado RH" e a solicitação não
|
||||||
|
poderá mais ser processada.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,39 +161,14 @@
|
|||||||
onclick={cancelarPorRH}
|
onclick={cancelarPorRH}
|
||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
>
|
||||||
<svg
|
<X class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Cancelar Férias (RH)
|
Cancelar Férias (RH)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="divider mt-6"></div>
|
<div class="divider mt-6"></div>
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<svg
|
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="stroke-current h-6 w-6 shrink-0"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<span>Esta solicitação já foi cancelada pelo RH.</span>
|
<span>Esta solicitação já foi cancelada pelo RH.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -224,19 +176,7 @@
|
|||||||
<!-- Motivo Reprovação (se reprovado) -->
|
<!-- Motivo Reprovação (se reprovado) -->
|
||||||
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
|
{#if solicitacao.status === 'reprovado' && solicitacao.motivoReprovacao}
|
||||||
<div class="alert alert-error mt-4">
|
<div class="alert alert-error mt-4">
|
||||||
<svg
|
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
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>
|
||||||
<div class="font-bold">Motivo da Reprovação:</div>
|
<div class="font-bold">Motivo da Reprovação:</div>
|
||||||
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
|
<div class="text-sm">{solicitacao.motivoReprovacao}</div>
|
||||||
@@ -247,19 +187,7 @@
|
|||||||
<!-- Erro -->
|
<!-- Erro -->
|
||||||
{#if erro}
|
{#if erro}
|
||||||
<div class="alert alert-error mt-4">
|
<div class="alert alert-error mt-4">
|
||||||
<svg
|
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
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>
|
<span>{erro}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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">
|
<script lang="ts">
|
||||||
import { useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
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 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'> & {
|
type SolicitacaoAusencia = Doc<'solicitacoesAusencias'> & {
|
||||||
funcionario?: Doc<'funcionarios'> | null;
|
funcionario?: Doc<'funcionarios'> | null;
|
||||||
@@ -17,7 +20,7 @@
|
|||||||
onCancelar?: () => void;
|
onCancelar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
const { solicitacao, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -28,13 +31,13 @@
|
|||||||
let mensagemErroModal = $state('');
|
let mensagemErroModal = $state('');
|
||||||
|
|
||||||
function calcularDias(dataInicio: string, dataFim: string): number {
|
function calcularDias(dataInicio: string, dataFim: string): number {
|
||||||
const inicio = new Date(dataInicio);
|
const inicio = parseLocalDate(dataInicio);
|
||||||
const fim = new Date(dataFim);
|
const fim = parseLocalDate(dataFim);
|
||||||
const diff = fim.getTime() - inicio.getTime();
|
const diff = fim.getTime() - inicio.getTime();
|
||||||
return Math.ceil(diff / (1000 * 60 * 60 * 24)) + 1;
|
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() {
|
async function aprovar() {
|
||||||
try {
|
try {
|
||||||
@@ -132,53 +135,48 @@
|
|||||||
|
|
||||||
<div class="aprovar-ausencia">
|
<div class="aprovar-ausencia">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="mb-6">
|
<div class="mb-4">
|
||||||
<h2 class="text-primary mb-2 text-3xl font-bold">Aprovar/Reprovar Ausência</h2>
|
<h2 class="text-primary mb-1 text-2xl font-bold">Aprovar/Reprovar Ausência</h2>
|
||||||
<p class="text-base-content/70">Analise a solicitação e tome uma decisão</p>
|
<p class="text-base-content/70 text-sm">Analise a solicitação e tome uma decisão</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Principal -->
|
<!-- Card Principal -->
|
||||||
<div class="card bg-base-100 border-t-4 border-primary shadow-2xl">
|
<div class="card bg-base-100 border-primary border-t-4 shadow-2xl">
|
||||||
<div class="card-body p-8">
|
<div class="card-body p-4 md:p-6">
|
||||||
<!-- Informações do Funcionário -->
|
<!-- Informações do Funcionário -->
|
||||||
<div class="mb-8">
|
<div class="mb-4">
|
||||||
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
|
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||||
<div class="rounded-lg bg-primary/10 p-2">
|
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||||
<svg
|
<User class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
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>
|
</div>
|
||||||
Funcionário
|
Funcionário
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
|
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
<div class="rounded-xl bg-base-200/50 p-4 transition-all hover:bg-base-200">
|
<div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
|
||||||
<p class="mb-2 text-sm font-semibold uppercase tracking-wide text-base-content/60">
|
<p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
|
||||||
Nome
|
Nome
|
||||||
</p>
|
</p>
|
||||||
<p class="text-lg font-bold text-base-content">
|
<div class="flex items-center gap-2">
|
||||||
{solicitacao.funcionario?.nome || 'N/A'}
|
<UserAvatar
|
||||||
</p>
|
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>
|
</div>
|
||||||
{#if solicitacao.time}
|
{#if solicitacao.time}
|
||||||
<div class="rounded-xl bg-base-200/50 p-4 transition-all hover:bg-base-200">
|
<div class="bg-base-200/50 hover:bg-base-200 rounded-lg p-3 transition-all">
|
||||||
<p class="mb-2 text-sm font-semibold uppercase tracking-wide text-base-content/60">
|
<p class="text-base-content/60 mb-1.5 text-xs font-semibold tracking-wide uppercase">
|
||||||
Time
|
Time
|
||||||
</p>
|
</p>
|
||||||
<div
|
<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
|
style="background-color: {solicitacao.time.cor}20; border-color: {solicitacao.time
|
||||||
.cor}; color: {solicitacao.time.cor}"
|
.cor}; color: {solicitacao.time.cor}"
|
||||||
|
title={solicitacao.time.nome}
|
||||||
>
|
>
|
||||||
{solicitacao.time.nome}
|
{solicitacao.time.nome}
|
||||||
</div>
|
</div>
|
||||||
@@ -187,84 +185,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider my-6"></div>
|
<div class="divider my-4"></div>
|
||||||
|
|
||||||
<!-- Período da Ausência -->
|
<!-- Período da Ausência -->
|
||||||
<div class="mb-8">
|
<div class="mb-4">
|
||||||
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
|
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||||
<div class="rounded-lg bg-primary/10 p-2">
|
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||||
<svg
|
<Calendar class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
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>
|
</div>
|
||||||
Período da Ausência
|
Período da Ausência
|
||||||
</h3>
|
</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
|
<div
|
||||||
class="stat rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
|
class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
|
||||||
>
|
>
|
||||||
<div class="stat-title text-base-content/70">Data Início</div>
|
<div class="stat-title text-base-content/70 text-xs">Data Início</div>
|
||||||
<div class="stat-value text-2xl text-primary">
|
<div class="stat-value text-primary text-lg font-bold">
|
||||||
{new Date(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
{parseLocalDate(solicitacao.dataInicio).toLocaleDateString('pt-BR')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="stat rounded-xl border-2 border-primary/20 bg-gradient-to-br from-primary/5 to-primary/10 shadow-md transition-all hover:border-primary/30 hover:shadow-lg"
|
class="stat border-primary/20 from-primary/5 to-primary/10 hover:border-primary/30 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
|
||||||
>
|
>
|
||||||
<div class="stat-title text-base-content/70">Data Fim</div>
|
<div class="stat-title text-base-content/70 text-xs">Data Fim</div>
|
||||||
<div class="stat-value text-2xl text-primary">
|
<div class="stat-value text-primary text-lg font-bold">
|
||||||
{new Date(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(solicitacao.dataFim).toLocaleDateString('pt-BR')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="stat rounded-xl border-2 border-primary/30 bg-gradient-to-br from-primary/10 to-primary/15 shadow-md transition-all hover:border-primary/40 hover:shadow-lg"
|
class="stat border-primary/30 from-primary/10 to-primary/15 hover:border-primary/40 rounded-lg border-2 bg-gradient-to-br shadow-md transition-all hover:shadow-lg p-3"
|
||||||
>
|
>
|
||||||
<div class="stat-title text-base-content/70">Total de Dias</div>
|
<div class="stat-title text-base-content/70 text-xs">Total de Dias</div>
|
||||||
<div class="stat-value text-3xl font-bold text-primary">
|
<div class="stat-value text-primary text-2xl font-bold">
|
||||||
{totalDias}
|
{totalDias}
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-desc text-base-content/60">dias corridos</div>
|
<div class="stat-desc text-base-content/60 text-xs">dias corridos</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider my-6"></div>
|
<div class="divider my-4"></div>
|
||||||
|
|
||||||
<!-- Motivo -->
|
<!-- Motivo -->
|
||||||
<div class="mb-8">
|
<div class="mb-4">
|
||||||
<h3 class="mb-5 flex items-center gap-3 text-xl font-bold text-primary">
|
<h3 class="text-primary mb-3 flex items-center gap-2 text-lg font-bold">
|
||||||
<div class="rounded-lg bg-primary/10 p-2">
|
<div class="bg-primary/10 rounded-lg p-1.5">
|
||||||
<svg
|
<FileText class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
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>
|
</div>
|
||||||
Motivo da Ausência
|
Motivo da Ausência
|
||||||
</h3>
|
</h3>
|
||||||
<div class="card rounded-xl border-2 border-primary/10 bg-base-200/50 shadow-sm">
|
<div class="card border-primary/10 bg-base-200/50 rounded-lg border-2 shadow-sm">
|
||||||
<div class="card-body p-5">
|
<div class="card-body p-3">
|
||||||
<p class="whitespace-pre-wrap leading-relaxed text-base-content">
|
<p class="text-base-content text-sm leading-relaxed whitespace-pre-wrap">
|
||||||
{solicitacao.motivo}
|
{solicitacao.motivo}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,89 +244,125 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Atual -->
|
<!-- Status Atual -->
|
||||||
<div class="mb-8 rounded-xl bg-base-200/30 p-4">
|
<div class="bg-base-200/30 mb-4 rounded-lg p-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-semibold uppercase tracking-wide text-base-content/70"
|
<span class="text-base-content/70 text-xs font-semibold tracking-wide uppercase"
|
||||||
>Status:</span
|
>Status:</span
|
||||||
>
|
>
|
||||||
<div class={`badge badge-lg ${getStatusBadge(solicitacao.status)}`}>
|
<div class={`badge badge-sm ${getStatusBadge(solicitacao.status)}`}>
|
||||||
{getStatusTexto(solicitacao.status)}
|
{getStatusTexto(solicitacao.status)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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 -->
|
<!-- Erro -->
|
||||||
{#if erro}
|
{#if erro}
|
||||||
<div class="alert alert-error mb-6 shadow-lg">
|
<div class="alert alert-error mb-4 shadow-lg py-3">
|
||||||
<svg
|
<XCircle class="h-5 w-5 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<span class="text-sm">{erro}</span>
|
||||||
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Ações -->
|
<!-- Ações -->
|
||||||
{#if solicitacao.status === 'aguardando_aprovacao'}
|
{#if solicitacao.status === 'aguardando_aprovacao'}
|
||||||
<div class="card-actions mt-8 justify-end gap-4">
|
<div class="card-actions mt-4 justify-end gap-2 flex-wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-error btn-lg gap-2"
|
class="btn btn-error btn-sm md:btn-md gap-2"
|
||||||
onclick={reprovar}
|
onclick={reprovar}
|
||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
>
|
||||||
{#if processando}
|
{#if processando}
|
||||||
<span class="loading loading-spinner"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<X class="h-4 w-4" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
Reprovar
|
Reprovar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-success btn-lg gap-2"
|
class="btn btn-success btn-sm md:btn-md gap-2"
|
||||||
onclick={aprovar}
|
onclick={aprovar}
|
||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
>
|
||||||
{#if processando}
|
{#if processando}
|
||||||
<span class="loading loading-spinner"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<Check class="h-4 w-4" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
{/if}
|
||||||
Aprovar
|
Aprovar
|
||||||
</button>
|
</button>
|
||||||
@@ -362,14 +370,14 @@
|
|||||||
|
|
||||||
<!-- Modal de Reprovação -->
|
<!-- Modal de Reprovação -->
|
||||||
{#if motivoReprovacao !== undefined}
|
{#if motivoReprovacao !== undefined}
|
||||||
<div class="mt-6 rounded-xl border-2 border-error/20 bg-error/5 p-5">
|
<div class="border-error/20 bg-error/5 mt-4 rounded-lg border-2 p-3">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label" for="motivo-reprovacao">
|
<label class="label py-1" for="motivo-reprovacao">
|
||||||
<span class="label-text font-bold text-error">Motivo da Reprovação</span>
|
<span class="label-text text-error text-sm font-bold">Motivo da Reprovação</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="motivo-reprovacao"
|
id="motivo-reprovacao"
|
||||||
class="textarea textarea-bordered h-24 focus:border-error focus:outline-error"
|
class="textarea textarea-bordered textarea-sm focus:border-error focus:outline-error h-20"
|
||||||
placeholder="Informe o motivo da reprovação..."
|
placeholder="Informe o motivo da reprovação..."
|
||||||
bind:value={motivoReprovacao}
|
bind:value={motivoReprovacao}
|
||||||
></textarea>
|
></textarea>
|
||||||
@@ -377,29 +385,17 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="alert alert-info shadow-lg">
|
<div class="alert alert-info shadow-lg py-3">
|
||||||
<svg
|
<Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<span class="text-sm">Esta solicitação já foi processada.</span>
|
||||||
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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Botão Cancelar -->
|
<!-- Botão Cancelar -->
|
||||||
<div class="mt-6 text-center">
|
<div class="mt-4 text-center">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost"
|
class="btn btn-ghost btn-sm"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
if (onCancelar) onCancelar();
|
if (onCancelar) onCancelar();
|
||||||
}}
|
}}
|
||||||
@@ -422,7 +418,7 @@
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.aprovar-ausencia {
|
.aprovar-ausencia {
|
||||||
max-width: 900px;
|
max-width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
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'> & {
|
type PeriodoFerias = Doc<'ferias'> & {
|
||||||
funcionario?: Doc<'funcionarios'> | null;
|
funcionario?: Doc<'funcionarios'> | null;
|
||||||
@@ -16,7 +18,7 @@
|
|||||||
onCancelar?: () => void;
|
onCancelar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
|
const { periodo, gestorId, onSucesso, onCancelar }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
let erro = $state('');
|
let erro = $state('');
|
||||||
|
|
||||||
// Calcular dias do período ajustado
|
// Calcular dias do período ajustado
|
||||||
const diasAjustados = $derived.by(() => {
|
let diasAjustados = $derived.by(() => {
|
||||||
if (!novaDataInicio || !novaDataFim) return 0;
|
if (!novaDataInicio || !novaDataFim) return 0;
|
||||||
const inicio = new Date(novaDataInicio);
|
const inicio = new Date(novaDataInicio);
|
||||||
const fim = new Date(novaDataFim);
|
const fim = new Date(novaDataFim);
|
||||||
@@ -69,10 +71,13 @@
|
|||||||
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
||||||
funcionarioId: periodo.funcionario._id,
|
funcionarioId: periodo.funcionario._id,
|
||||||
anoReferencia: periodo.anoReferencia,
|
anoReferencia: periodo.anoReferencia,
|
||||||
periodos: [{
|
periodos: [
|
||||||
dataInicio: periodo.dataInicio,
|
{
|
||||||
dataFim: periodo.dataFim
|
dataInicio: periodo.dataInicio,
|
||||||
}]
|
dataFim: periodo.dataFim
|
||||||
|
}
|
||||||
|
],
|
||||||
|
feriasIdExcluir: periodo._id // Excluir este período do cálculo de saldo pendente
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!validacao.valido) {
|
if (!validacao.valido) {
|
||||||
@@ -140,10 +145,12 @@
|
|||||||
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
const validacao = await client.query(api.saldoFerias.validarSolicitacao, {
|
||||||
funcionarioId: periodo.funcionario._id,
|
funcionarioId: periodo.funcionario._id,
|
||||||
anoReferencia: periodo.anoReferencia,
|
anoReferencia: periodo.anoReferencia,
|
||||||
periodos: [{
|
periodos: [
|
||||||
dataInicio: novaDataInicio,
|
{
|
||||||
dataFim: novaDataFim
|
dataInicio: novaDataInicio,
|
||||||
}],
|
dataFim: novaDataFim
|
||||||
|
}
|
||||||
|
],
|
||||||
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
|
feriasIdExcluir: periodo._id // Excluir o período original do cálculo de saldo
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -215,13 +222,20 @@
|
|||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="mb-4 flex items-start justify-between">
|
<div class="mb-4 flex items-start justify-between">
|
||||||
<div>
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="card-title text-2xl">
|
<UserAvatar
|
||||||
{periodo.funcionario?.nome || 'Funcionário'}
|
fotoPerfilUrl={periodo.funcionario?.fotoPerfilUrl}
|
||||||
</h2>
|
nome={periodo.funcionario?.nome || 'Funcionário'}
|
||||||
<p class="text-base-content/70 mt-1 text-sm">
|
size="md"
|
||||||
Ano de Referência: {periodo.anoReferencia}
|
/>
|
||||||
</p>
|
<div>
|
||||||
|
<h2 class="card-title text-2xl">
|
||||||
|
{periodo.funcionario?.nome || 'Funcionário'}
|
||||||
|
</h2>
|
||||||
|
<p class="text-base-content/70 mt-1 text-sm">
|
||||||
|
Ano de Referência: {periodo.anoReferencia}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
|
<div class={`badge ${getStatusBadge(periodo.status)} badge-lg`}>
|
||||||
{getStatusTexto(periodo.status)}
|
{getStatusTexto(periodo.status)}
|
||||||
@@ -235,15 +249,11 @@
|
|||||||
<div class="grid grid-cols-3 gap-4 text-sm">
|
<div class="grid grid-cols-3 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span class="text-base-content/70">Início:</span>
|
<span class="text-base-content/70">Início:</span>
|
||||||
<span class="ml-1 font-semibold"
|
<span class="ml-1 font-semibold">{formatarDataString(periodo.dataInicio)}</span>
|
||||||
>{formatarDataString(periodo.dataInicio)}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-base-content/70">Fim:</span>
|
<span class="text-base-content/70">Fim:</span>
|
||||||
<span class="ml-1 font-semibold"
|
<span class="ml-1 font-semibold">{formatarDataString(periodo.dataFim)}</span>
|
||||||
>{formatarDataString(periodo.dataFim)}</span
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-base-content/70">Dias:</span>
|
<span class="text-base-content/70">Dias:</span>
|
||||||
@@ -270,20 +280,7 @@
|
|||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
{#each periodo.historicoAlteracoes as hist}
|
{#each periodo.historicoAlteracoes as hist}
|
||||||
<div class="text-base-content/70 flex items-center gap-2 text-xs">
|
<div class="text-base-content/70 flex items-center gap-2 text-xs">
|
||||||
<svg
|
<Clock class="h-3 w-3" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-3 w-3"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>{formatarData(hist.data)}</span>
|
<span>{formatarData(hist.data)}</span>
|
||||||
<span>-</span>
|
<span>-</span>
|
||||||
<span>{hist.acao}</span>
|
<span>{hist.acao}</span>
|
||||||
@@ -307,20 +304,7 @@
|
|||||||
onclick={aprovar}
|
onclick={aprovar}
|
||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
>
|
||||||
<svg
|
<Check class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Aprovar
|
Aprovar
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -330,20 +314,7 @@
|
|||||||
onclick={() => (modoAjuste = true)}
|
onclick={() => (modoAjuste = true)}
|
||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
>
|
||||||
<svg
|
<Edit class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Ajustar Datas e Aprovar
|
Ajustar Datas e Aprovar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,20 +335,7 @@
|
|||||||
onclick={reprovar}
|
onclick={reprovar}
|
||||||
disabled={processando || !motivoReprovacao.trim()}
|
disabled={processando || !motivoReprovacao.trim()}
|
||||||
>
|
>
|
||||||
<svg
|
<X class="h-4 w-4" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Reprovar
|
Reprovar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,20 +403,7 @@
|
|||||||
onclick={ajustarEAprovar}
|
onclick={ajustarEAprovar}
|
||||||
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0}
|
disabled={processando || !novaDataInicio || !novaDataFim || diasAjustados <= 0}
|
||||||
>
|
>
|
||||||
<svg
|
<Check class="h-4 w-4" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Confirmar e Aprovar
|
Confirmar e Aprovar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -466,25 +411,48 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/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) -->
|
<!-- Motivo Reprovação (se reprovado) -->
|
||||||
{#if periodo.status === 'reprovado' && periodo.motivoReprovacao}
|
{#if periodo.status === 'reprovado'}
|
||||||
<div class="alert alert-error mt-4">
|
<div class="alert alert-error mt-4">
|
||||||
<svg
|
<XCircle class="h-6 w-6 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<div class="flex-1">
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
<div class="font-bold">Reprovado</div>
|
||||||
fill="none"
|
{#if periodo.gestor}
|
||||||
viewBox="0 0 24 24"
|
<div class="text-sm mt-1">
|
||||||
>
|
Por: <strong>{periodo.gestor.nome}</strong>
|
||||||
<path
|
</div>
|
||||||
stroke-linecap="round"
|
{/if}
|
||||||
stroke-linejoin="round"
|
{#if periodo.dataReprovacao}
|
||||||
stroke-width="2"
|
<div class="text-xs mt-1 opacity-80">
|
||||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
Em: {formatarData(periodo.dataReprovacao)}
|
||||||
/>
|
</div>
|
||||||
</svg>
|
{/if}
|
||||||
<div>
|
{#if periodo.motivoReprovacao}
|
||||||
<div class="font-bold">Motivo da Reprovação:</div>
|
<div class="mt-2">
|
||||||
|
<div class="text-sm font-semibold">Motivo:</div>
|
||||||
<div class="text-sm">{periodo.motivoReprovacao}</div>
|
<div class="text-sm">{periodo.motivoReprovacao}</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -492,19 +460,7 @@
|
|||||||
<!-- Erro -->
|
<!-- Erro -->
|
||||||
{#if erro}
|
{#if erro}
|
||||||
<div class="alert alert-error mt-4">
|
<div class="alert alert-error mt-4">
|
||||||
<svg
|
<XCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
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>
|
<span>{erro}</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
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">
|
<script lang="ts">
|
||||||
import { AlertCircle, X, HelpCircle } from 'lucide-svelte';
|
import { AlertCircle, HelpCircle, X } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -13,25 +13,28 @@
|
|||||||
|
|
||||||
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
// Função para calcular a posição baseada no relógio sincronizado
|
// Função para calcular a posição baseada no card de registro de ponto
|
||||||
function calcularPosicaoModal() {
|
function calcularPosicaoModal() {
|
||||||
// Procurar pelo elemento do relógio sincronizado
|
// Procurar pelo elemento do card de registro de ponto
|
||||||
const relogioRef = document.getElementById('relogio-sincronizado-ref');
|
const cardRef = document.getElementById('card-registro-ponto-ref');
|
||||||
|
|
||||||
if (relogioRef) {
|
if (cardRef) {
|
||||||
const rect = relogioRef.getBoundingClientRect();
|
const rect = cardRef.getBoundingClientRect();
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
// Posicionar o modal na mesma posição do relógio sincronizado
|
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
|
||||||
// Centralizado horizontalmente no card do relógio
|
const top = rect.top;
|
||||||
const left = rect.left + (rect.width / 2);
|
|
||||||
// Posicionar abaixo do card do relógio com um pequeno espaçamento
|
|
||||||
const top = rect.bottom + 20;
|
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
top: top,
|
top: finalTop,
|
||||||
left: left
|
left: window.innerWidth / 2
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,49 +78,26 @@
|
|||||||
// Função para obter estilo do modal baseado na posição calculada
|
// Função para obter estilo do modal baseado na posição calculada
|
||||||
function getModalStyle() {
|
function getModalStyle() {
|
||||||
if (modalPosition) {
|
if (modalPosition) {
|
||||||
// Garantir que o modal não saia da viewport
|
// Posicionar na altura do card, centralizado horizontalmente
|
||||||
const viewportWidth = window.innerWidth;
|
// position: fixed já é relativo à viewport, então podemos usar diretamente
|
||||||
const viewportHeight = window.innerHeight;
|
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
|
||||||
const modalWidth = 700; // Aproximadamente max-w-2xl
|
|
||||||
const modalHeight = Math.min(viewportHeight * 0.9, 600);
|
|
||||||
|
|
||||||
let left = modalPosition.left;
|
|
||||||
let top = modalPosition.top;
|
|
||||||
|
|
||||||
// Ajustar se o modal sair da viewport à direita
|
|
||||||
if (left + (modalWidth / 2) > viewportWidth - 20) {
|
|
||||||
left = viewportWidth - (modalWidth / 2) - 20;
|
|
||||||
}
|
|
||||||
// Ajustar se o modal sair da viewport à esquerda
|
|
||||||
if (left - (modalWidth / 2) < 20) {
|
|
||||||
left = (modalWidth / 2) + 20;
|
|
||||||
}
|
|
||||||
// Ajustar se o modal sair da viewport abaixo
|
|
||||||
if (top + modalHeight > viewportHeight - 20) {
|
|
||||||
top = viewportHeight - modalHeight - 20;
|
|
||||||
}
|
|
||||||
// Ajustar se o modal sair da viewport acima
|
|
||||||
if (top < 20) {
|
|
||||||
top = 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usar transform para centralizar horizontalmente baseado no left calculado
|
|
||||||
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
|
|
||||||
}
|
}
|
||||||
// Se não houver posição calculada, centralizar na tela
|
// Se não houver posição calculada, centralizar na tela
|
||||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
|
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
|
// Verificar se details contém instruções ou apenas detalhes técnicos
|
||||||
const temInstrucoes = $derived.by(() => {
|
let temInstrucoes = $derived.by(() => {
|
||||||
if (!details) return false;
|
if (!details) return false;
|
||||||
// Se contém palavras-chave de instruções, é uma instrução
|
// Se contém palavras-chave de instruções, é uma instrução
|
||||||
return details.includes('Por favor') ||
|
return (
|
||||||
details.includes('aguarde') ||
|
details.includes('Por favor') ||
|
||||||
details.includes('recarregue') ||
|
details.includes('aguarde') ||
|
||||||
details.includes('Verifique') ||
|
details.includes('recarregue') ||
|
||||||
details.includes('tente novamente') ||
|
details.includes('Verifique') ||
|
||||||
details.match(/^\d+\./); // Começa com número (lista numerada)
|
details.includes('tente novamente') ||
|
||||||
|
details.match(/^\d+\./)
|
||||||
|
); // Começa com número (lista numerada)
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
@@ -128,7 +108,7 @@
|
|||||||
|
|
||||||
{#if open}
|
{#if open}
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 pointer-events-none"
|
class="pointer-events-none fixed inset-0 z-50"
|
||||||
style="animation: fadeIn 0.2s ease-out;"
|
style="animation: fadeIn 0.2s ease-out;"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@@ -136,18 +116,20 @@
|
|||||||
>
|
>
|
||||||
<!-- Backdrop leve -->
|
<!-- Backdrop leve -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
|
||||||
onclick={handleClose}
|
onclick={handleClose}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Modal Box -->
|
<!-- Modal Box -->
|
||||||
<div
|
<div
|
||||||
class="absolute bg-base-100 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
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()}"
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<!-- Header fixo -->
|
<!-- Header fixo -->
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-base-300 flex-shrink-0">
|
<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">
|
<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} />
|
<AlertCircle class="h-6 w-6" strokeWidth={2.5} />
|
||||||
{title}
|
{title}
|
||||||
@@ -163,7 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content com rolagem -->
|
<!-- Content com rolagem -->
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
|
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||||
<!-- Mensagem principal -->
|
<!-- Mensagem principal -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<p class="text-base-content text-base leading-relaxed font-medium">{message}</p>
|
<p class="text-base-content text-base leading-relaxed font-medium">{message}</p>
|
||||||
@@ -173,17 +155,26 @@
|
|||||||
{#if details}
|
{#if details}
|
||||||
<div class="bg-info/10 border-info/30 mb-4 rounded-lg border-l-4 p-4">
|
<div class="bg-info/10 border-info/30 mb-4 rounded-lg border-l-4 p-4">
|
||||||
<div class="flex items-start gap-3">
|
<div class="flex items-start gap-3">
|
||||||
<HelpCircle class="text-info h-5 w-5 shrink-0 mt-0.5" strokeWidth={2} />
|
<HelpCircle class="text-info mt-0.5 h-5 w-5 shrink-0" strokeWidth={2} />
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<p class="text-base-content/90 text-sm font-semibold mb-2">
|
<p class="text-base-content/90 mb-2 text-sm font-semibold">
|
||||||
{temInstrucoes ? 'Como resolver:' : 'Informação adicional:'}
|
{temInstrucoes ? 'Como resolver:' : 'Informação adicional:'}
|
||||||
</p>
|
</p>
|
||||||
<div class="text-base-content/80 text-sm space-y-2">
|
<div class="text-base-content/80 space-y-2 text-sm">
|
||||||
{#each details.split('\n').filter(line => line.trim().length > 0) as linha (linha)}
|
{#each details
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim().length > 0) as linha (linha)}
|
||||||
{#if linha.trim().match(/^\d+\./)}
|
{#if linha.trim().match(/^\d+\./)}
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<span class="text-info font-semibold shrink-0">{linha.trim().split('.')[0]}.</span>
|
<span class="text-info shrink-0 font-semibold"
|
||||||
<span class="flex-1 leading-relaxed">{linha.trim().substring(linha.trim().indexOf('.') + 1).trim()}</span>
|
>{linha.trim().split('.')[0]}.</span
|
||||||
|
>
|
||||||
|
<span class="flex-1 leading-relaxed"
|
||||||
|
>{linha
|
||||||
|
.trim()
|
||||||
|
.substring(linha.trim().indexOf('.') + 1)
|
||||||
|
.trim()}</span
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="leading-relaxed">{linha.trim()}</p>
|
<p class="leading-relaxed">{linha.trim()}</p>
|
||||||
@@ -197,7 +188,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer fixo -->
|
<!-- Footer fixo -->
|
||||||
<div class="flex justify-end px-6 py-4 border-t border-base-300 flex-shrink-0">
|
<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>
|
<button class="btn btn-primary" onclick={handleClose}>Entendi, obrigado</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient } from 'convex-svelte';
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { resolve } from '$app/paths';
|
|
||||||
import {
|
import {
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
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">
|
<script lang="ts">
|
||||||
import { useQuery } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { useQuery } from 'convex-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value?: string; // Id do funcionário selecionado
|
value?: string; // Id do funcionário selecionado
|
||||||
@@ -23,10 +23,10 @@
|
|||||||
// Buscar funcionários
|
// Buscar funcionários
|
||||||
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
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
|
// Filtrar funcionários baseado na busca
|
||||||
const funcionariosFiltrados = $derived.by(() => {
|
let funcionariosFiltrados = $derived.by(() => {
|
||||||
if (!busca.trim()) return funcionarios;
|
if (!busca.trim()) return funcionarios;
|
||||||
|
|
||||||
const termo = busca.toLowerCase().trim();
|
const termo = busca.toLowerCase().trim();
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Funcionário selecionado
|
// Funcionário selecionado
|
||||||
const funcionarioSelecionado = $derived.by(() => {
|
let funcionarioSelecionado = $derived.by(() => {
|
||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
return funcionarios.find((f) => f._id === value);
|
return funcionarios.find((f) => f._id === value);
|
||||||
});
|
});
|
||||||
|
|||||||
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">
|
<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>
|
</script>
|
||||||
|
|
||||||
<div class="navbar bg-base-200 shadow-sm p-4 w-76">
|
<header
|
||||||
<img src={logo} alt="Logo" class="" />
|
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>
|
>
|
||||||
|
<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 abertura de chamados são públicos
|
|
||||||
if (menuPath === "/" || menuPath === "/abrir-chamado") {
|
|
||||||
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,207 +1,201 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { modelosDeclaracoes } from "$lib/utils/modelosDeclaracoes";
|
import { modelosDeclaracoes } from '$lib/utils/modelosDeclaracoes';
|
||||||
import {
|
import {
|
||||||
gerarDeclaracaoAcumulacaoCargo,
|
gerarDeclaracaoAcumulacaoCargo,
|
||||||
gerarDeclaracaoDependentesIR,
|
gerarDeclaracaoDependentesIR,
|
||||||
gerarDeclaracaoIdoneidade,
|
gerarDeclaracaoIdoneidade,
|
||||||
gerarTermoNepotismo,
|
gerarTermoNepotismo,
|
||||||
gerarTermoOpcaoRemuneracao,
|
gerarTermoOpcaoRemuneracao,
|
||||||
downloadBlob,
|
downloadBlob
|
||||||
} from "$lib/utils/declaracoesGenerator";
|
} from '$lib/utils/declaracoesGenerator';
|
||||||
import { FileText, Info } from "lucide-svelte";
|
import { FileText, Info } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
funcionario?: any;
|
funcionario?: any;
|
||||||
showPreencherButton?: boolean;
|
showPreencherButton?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionario, showPreencherButton = false }: Props = $props();
|
let { funcionario, showPreencherButton = false }: Props = $props();
|
||||||
let generating = $state(false);
|
let generating = $state(false);
|
||||||
|
|
||||||
function baixarModelo(arquivoUrl: string, nomeModelo: string) {
|
function baixarModelo(arquivoUrl: string, nomeModelo: string) {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement('a');
|
||||||
link.href = arquivoUrl;
|
link.href = arquivoUrl;
|
||||||
link.download = nomeModelo + ".pdf";
|
link.download = nomeModelo + '.pdf';
|
||||||
link.target = "_blank";
|
link.target = '_blank';
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
document.body.removeChild(link);
|
document.body.removeChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gerarPreenchido(modeloId: string) {
|
async function gerarPreenchido(modeloId: string) {
|
||||||
if (!funcionario) {
|
if (!funcionario) {
|
||||||
alert("Dados do funcionário não disponíveis");
|
alert('Dados do funcionário não disponíveis');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
generating = true;
|
generating = true;
|
||||||
let blob: Blob;
|
let blob: Blob;
|
||||||
let nomeArquivo: string;
|
let nomeArquivo: string;
|
||||||
|
|
||||||
switch (modeloId) {
|
switch (modeloId) {
|
||||||
case "acumulacao_cargo":
|
case 'acumulacao_cargo':
|
||||||
blob = await gerarDeclaracaoAcumulacaoCargo(funcionario);
|
blob = await gerarDeclaracaoAcumulacaoCargo(funcionario);
|
||||||
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, "_")}_${Date.now()}.pdf`;
|
nomeArquivo = `Declaracao_Acumulacao_Cargo_${funcionario.nome.replace(/ /g, '_')}_${Date.now()}.pdf`;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "dependentes_ir":
|
case 'dependentes_ir':
|
||||||
blob = await gerarDeclaracaoDependentesIR(funcionario);
|
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;
|
break;
|
||||||
|
|
||||||
case "idoneidade":
|
case 'idoneidade':
|
||||||
blob = await gerarDeclaracaoIdoneidade(funcionario);
|
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;
|
break;
|
||||||
|
|
||||||
case "nepotismo":
|
case 'nepotismo':
|
||||||
blob = await gerarTermoNepotismo(funcionario);
|
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;
|
break;
|
||||||
|
|
||||||
case "opcao_remuneracao":
|
case 'opcao_remuneracao':
|
||||||
blob = await gerarTermoOpcaoRemuneracao(funcionario);
|
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;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
alert("Modelo não encontrado");
|
alert('Modelo não encontrado');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadBlob(blob, nomeArquivo);
|
downloadBlob(blob, nomeArquivo);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erro ao gerar declaração:", error);
|
console.error('Erro ao gerar declaração:', error);
|
||||||
alert("Erro ao gerar declaração preenchida");
|
alert('Erro ao gerar declaração preenchida');
|
||||||
} finally {
|
} finally {
|
||||||
generating = false;
|
generating = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="card bg-base-100 shadow-xl">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="card-title text-xl border-b pb-3">
|
<h2 class="card-title border-b pb-3 text-xl">
|
||||||
<FileText class="h-5 w-5" strokeWidth={2} />
|
<FileText class="h-5 w-5" strokeWidth={2} />
|
||||||
Modelos de Declarações
|
Modelos de Declarações
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="alert alert-info shadow-sm mb-4">
|
<div class="alert alert-info mb-4 shadow-sm">
|
||||||
<Info class="stroke-current shrink-0 h-5 w-5" strokeWidth={2} />
|
<Info class="h-5 w-5 shrink-0 stroke-current" strokeWidth={2} />
|
||||||
<div class="text-sm">
|
<div class="text-sm">
|
||||||
<p class="font-semibold">
|
<p class="font-semibold">Baixe os modelos, preencha, assine e faça upload no sistema</p>
|
||||||
Baixe os modelos, preencha, assine e faça upload no sistema
|
<p class="mt-1 text-xs opacity-80">
|
||||||
</p>
|
Estes documentos são necessários para completar o cadastro do funcionário
|
||||||
<p class="text-xs opacity-80 mt-1">
|
</p>
|
||||||
Estes documentos são necessários para completar o cadastro do
|
</div>
|
||||||
funcionário
|
</div>
|
||||||
</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}
|
{#each modelosDeclaracoes as modelo}
|
||||||
<div
|
<div class="card bg-base-200 shadow-sm transition-shadow hover:shadow-md">
|
||||||
class="card bg-base-200 shadow-sm hover:shadow-md transition-shadow"
|
<div class="card-body p-4">
|
||||||
>
|
<div class="flex items-start gap-3">
|
||||||
<div class="card-body p-4">
|
<!-- Ícone PDF -->
|
||||||
<div class="flex items-start gap-3">
|
<div
|
||||||
<!-- Ícone PDF -->
|
class="bg-error/10 flex h-12 w-12 shrink-0 items-center justify-center rounded-lg"
|
||||||
<div
|
>
|
||||||
class="shrink-0 w-12 h-12 bg-error/10 rounded-lg flex items-center justify-center"
|
<svg
|
||||||
>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<svg
|
class="text-error h-6 w-6"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
fill="none"
|
||||||
class="h-6 w-6 text-error"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
stroke="currentColor"
|
||||||
viewBox="0 0 24 24"
|
>
|
||||||
stroke="currentColor"
|
<path
|
||||||
>
|
stroke-linecap="round"
|
||||||
<path
|
stroke-linejoin="round"
|
||||||
stroke-linecap="round"
|
stroke-width="2"
|
||||||
stroke-linejoin="round"
|
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||||
stroke-width="2"
|
/>
|
||||||
d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
</svg>
|
||||||
/>
|
</div>
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
<!-- Conteúdo -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="font-semibold text-sm mb-1 line-clamp-2">
|
<h3 class="mb-1 line-clamp-2 text-sm font-semibold">
|
||||||
{modelo.nome}
|
{modelo.nome}
|
||||||
</h3>
|
</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}
|
{modelo.descricao}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Ações -->
|
<!-- Ações -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-xs gap-1"
|
class="btn btn-primary btn-xs gap-1"
|
||||||
onclick={() => baixarModelo(modelo.arquivo, modelo.nome)}
|
onclick={() => baixarModelo(modelo.arquivo, modelo.nome)}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-3 w-3"
|
class="h-3 w-3"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
Baixar Modelo
|
Baixar Modelo
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if showPreencherButton && modelo.podePreencherAutomaticamente && funcionario}
|
{#if showPreencherButton && modelo.podePreencherAutomaticamente && funcionario}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline btn-xs gap-1"
|
class="btn btn-outline btn-xs gap-1"
|
||||||
onclick={() => gerarPreenchido(modelo.id)}
|
onclick={() => gerarPreenchido(modelo.id)}
|
||||||
disabled={generating}
|
disabled={generating}
|
||||||
>
|
>
|
||||||
{#if generating}
|
{#if generating}
|
||||||
<span class="loading loading-spinner loading-xs"></span>
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
Gerando...
|
Gerando...
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-3 w-3"
|
class="h-3 w-3"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke-linecap="round"
|
stroke-linecap="round"
|
||||||
stroke-linejoin="round"
|
stroke-linejoin="round"
|
||||||
stroke-width="2"
|
stroke-width="2"
|
||||||
d="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"
|
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>
|
</svg>
|
||||||
Gerar Preenchido
|
Gerar Preenchido
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</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>
|
<p>
|
||||||
💡 Dica: Após preencher e assinar os documentos, faça upload na seção
|
💡 Dica: Após preencher e assinar os documentos, faça upload na seção "Documentação Anexa"
|
||||||
"Documentação Anexa"
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import jsPDF from 'jspdf';
|
import jsPDF from 'jspdf';
|
||||||
import autoTable from 'jspdf-autotable';
|
import autoTable from 'jspdf-autotable';
|
||||||
import { maskCPF, maskCEP, maskPhone } from '$lib/utils/masks';
|
import { CheckCircle2, Printer, X } from 'lucide-svelte';
|
||||||
|
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
import {
|
import {
|
||||||
SEXO_OPTIONS,
|
APOSENTADO_OPTIONS,
|
||||||
ESTADO_CIVIL_OPTIONS,
|
ESTADO_CIVIL_OPTIONS,
|
||||||
|
FATOR_RH_OPTIONS,
|
||||||
GRAU_INSTRUCAO_OPTIONS,
|
GRAU_INSTRUCAO_OPTIONS,
|
||||||
GRUPO_SANGUINEO_OPTIONS,
|
GRUPO_SANGUINEO_OPTIONS,
|
||||||
FATOR_RH_OPTIONS,
|
SEXO_OPTIONS
|
||||||
APOSENTADO_OPTIONS
|
|
||||||
} from '$lib/utils/constants';
|
} from '$lib/utils/constants';
|
||||||
import logoGovPE from '$lib/assets/logo_governo_PE.png';
|
import { maskCEP, maskCPF, maskPhone } from '$lib/utils/masks';
|
||||||
import { CheckCircle2, X, Printer } from 'lucide-svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
funcionario: any;
|
funcionario: any;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionario, onClose }: Props = $props();
|
const { funcionario, onClose }: Props = $props();
|
||||||
|
|
||||||
let modalRef: HTMLDialogElement;
|
let modalRef: HTMLDialogElement;
|
||||||
let generating = $state(false);
|
let generating = $state(false);
|
||||||
@@ -113,7 +113,9 @@
|
|||||||
// Título da ficha
|
// Título da ficha
|
||||||
doc.setFontSize(18);
|
doc.setFontSize(18);
|
||||||
doc.setFont('helvetica', 'bold');
|
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;
|
yPosition += 8;
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
|
|||||||
@@ -1,20 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { onMount } from 'svelte';
|
import { useQuery } from 'convex-svelte';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
let {
|
const {
|
||||||
children,
|
children,
|
||||||
requireAuth = true,
|
requireAuth = true,
|
||||||
allowedRoles = [],
|
allowedRoles = [],
|
||||||
maxLevel = 3,
|
|
||||||
redirectTo = '/'
|
redirectTo = '/'
|
||||||
}: {
|
}: {
|
||||||
children: Snippet;
|
children: Snippet;
|
||||||
requireAuth?: boolean;
|
requireAuth?: boolean;
|
||||||
allowedRoles?: string[];
|
allowedRoles?: string[];
|
||||||
maxLevel?: number;
|
|
||||||
redirectTo?: string;
|
redirectTo?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -72,13 +69,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar nível
|
|
||||||
if (currentUser.data.role?.nivel && currentUser.data.role.nivel > maxLevel) {
|
|
||||||
const currentPath = window.location.pathname;
|
|
||||||
window.location.href = `${redirectTo}?error=access_denied&route=${encodeURIComponent(currentPath)}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Se chegou aqui, permitir acesso
|
// Se chegou aqui, permitir acesso
|
||||||
hasAccess = true;
|
hasAccess = true;
|
||||||
isChecking = false;
|
isChecking = false;
|
||||||
|
|||||||
@@ -1,157 +1,136 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from 'svelte';
|
||||||
import { useConvexClient } from "convex-svelte";
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { useQuery } from "convex-svelte";
|
import { useQuery } from 'convex-svelte';
|
||||||
import {
|
import {
|
||||||
registrarServiceWorker,
|
solicitarPushSubscription,
|
||||||
solicitarPushSubscription,
|
subscriptionToJSON,
|
||||||
subscriptionToJSON,
|
removerPushSubscription
|
||||||
removerPushSubscription,
|
} from '$lib/utils/notifications';
|
||||||
} from "$lib/utils/notifications";
|
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
|
|
||||||
// Capturar erros de Promise não tratados relacionados a message channel
|
// 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
|
// 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(
|
window.addEventListener(
|
||||||
"unhandledrejection",
|
'unhandledrejection',
|
||||||
(event: PromiseRejectionEvent) => {
|
(event: PromiseRejectionEvent) => {
|
||||||
const reason = event.reason;
|
const reason = event.reason;
|
||||||
const errorMessage = reason?.message || reason?.toString() || "";
|
const errorMessage = reason?.message || reason?.toString() || '';
|
||||||
|
|
||||||
// Filtrar apenas erros relacionados a message channel fechado
|
// Filtrar apenas erros relacionados a message channel fechado
|
||||||
if (
|
if (
|
||||||
errorMessage.includes("message channel closed") ||
|
errorMessage.includes('message channel closed') ||
|
||||||
errorMessage.includes("asynchronous response") ||
|
errorMessage.includes('asynchronous response') ||
|
||||||
(errorMessage.includes("message channel") &&
|
(errorMessage.includes('message channel') && errorMessage.includes('closed'))
|
||||||
errorMessage.includes("closed"))
|
) {
|
||||||
) {
|
// Prevenir que o erro apareça no console
|
||||||
// Prevenir que o erro apareça no console
|
event.preventDefault();
|
||||||
event.preventDefault();
|
// Silenciar o erro - é geralmente causado por extensões do Chrome
|
||||||
// Silenciar o erro - é geralmente causado por extensões do Chrome
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
},
|
||||||
},
|
{ capture: true }
|
||||||
{ capture: true },
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
let checkAuth: ReturnType<typeof setInterval> | null = null;
|
let checkAuth: ReturnType<typeof setInterval> | null = null;
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
// Aguardar usuário estar autenticado
|
// Aguardar usuário estar autenticado
|
||||||
checkAuth = setInterval(async () => {
|
checkAuth = setInterval(async () => {
|
||||||
if (currentUser?.data && mounted) {
|
if (currentUser?.data && mounted) {
|
||||||
clearInterval(checkAuth!);
|
clearInterval(checkAuth!);
|
||||||
checkAuth = null;
|
checkAuth = null;
|
||||||
try {
|
try {
|
||||||
await registrarPushSubscription();
|
await registrarPushSubscription();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silenciar erros de push subscription para evitar spam no console
|
// Silenciar erros de push subscription para evitar spam no console
|
||||||
if (
|
if (error instanceof Error && !error.message.includes('message channel')) {
|
||||||
error instanceof Error &&
|
console.error('Erro ao configurar push notifications:', error);
|
||||||
!error.message.includes("message channel")
|
}
|
||||||
) {
|
}
|
||||||
console.error("Erro ao configurar push notifications:", error);
|
}
|
||||||
}
|
}, 500);
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 500);
|
|
||||||
|
|
||||||
// Limpar intervalo após 30 segundos (timeout)
|
// Limpar intervalo após 30 segundos (timeout)
|
||||||
const timeout = setTimeout(() => {
|
const timeout = setTimeout(() => {
|
||||||
if (checkAuth) {
|
if (checkAuth) {
|
||||||
clearInterval(checkAuth);
|
clearInterval(checkAuth);
|
||||||
checkAuth = null;
|
checkAuth = null;
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
if (checkAuth) {
|
if (checkAuth) {
|
||||||
clearInterval(checkAuth);
|
clearInterval(checkAuth);
|
||||||
}
|
}
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
async function registrarPushSubscription() {
|
async function registrarPushSubscription() {
|
||||||
try {
|
try {
|
||||||
// Verificar se Service Worker está disponível antes de tentar
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Solicitar subscription com timeout para evitar travamentos
|
// Solicitar subscription com timeout para evitar travamentos
|
||||||
const subscriptionPromise = solicitarPushSubscription();
|
const subscriptionPromise = solicitarPushSubscription();
|
||||||
const timeoutPromise = new Promise<null>((resolve) =>
|
const timeoutPromise = new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000));
|
||||||
setTimeout(() => resolve(null), 5000),
|
|
||||||
);
|
|
||||||
|
|
||||||
const subscription = await Promise.race([
|
const subscription = await Promise.race([subscriptionPromise, timeoutPromise]);
|
||||||
subscriptionPromise,
|
|
||||||
timeoutPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!subscription) {
|
if (!subscription) {
|
||||||
// Não logar para evitar spam no console quando VAPID key não está configurada
|
// Não logar para evitar spam no console quando VAPID key não está configurada
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Converter para formato serializável
|
// Converter para formato serializável
|
||||||
const subscriptionData = subscriptionToJSON(subscription);
|
const subscriptionData = subscriptionToJSON(subscription);
|
||||||
|
|
||||||
// Registrar no backend com timeout
|
// Registrar no backend com timeout
|
||||||
const mutationPromise = client.mutation(
|
const mutationPromise = client.mutation(api.pushNotifications.registrarPushSubscription, {
|
||||||
api.pushNotifications.registrarPushSubscription,
|
endpoint: subscriptionData.endpoint,
|
||||||
{
|
keys: subscriptionData.keys,
|
||||||
endpoint: subscriptionData.endpoint,
|
userAgent: navigator.userAgent
|
||||||
keys: subscriptionData.keys,
|
});
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeoutMutationPromise = new Promise<{
|
const timeoutMutationPromise = new Promise<{
|
||||||
sucesso: false;
|
sucesso: false;
|
||||||
erro: string;
|
erro: string;
|
||||||
}>((resolve) =>
|
}>((resolve) => setTimeout(() => resolve({ sucesso: false, erro: 'Timeout' }), 5000));
|
||||||
setTimeout(() => resolve({ sucesso: false, erro: "Timeout" }), 5000),
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultado = await Promise.race([
|
const resultado = await Promise.race([mutationPromise, timeoutMutationPromise]);
|
||||||
mutationPromise,
|
|
||||||
timeoutMutationPromise,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (resultado.sucesso) {
|
if (resultado.sucesso) {
|
||||||
console.log("✅ Push subscription registrada com sucesso");
|
console.log('✅ Push subscription registrada com sucesso');
|
||||||
} else if (resultado.erro && !resultado.erro.includes("Timeout")) {
|
} else if (resultado.erro && !resultado.erro.includes('Timeout')) {
|
||||||
console.error(
|
console.error('❌ Erro ao registrar push subscription:', resultado.erro);
|
||||||
"❌ 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')) {
|
||||||
} catch (error) {
|
return;
|
||||||
// Ignorar erros relacionados a message channel fechado
|
}
|
||||||
if (error instanceof Error && error.message.includes("message channel")) {
|
console.error('❌ Erro ao configurar push notifications:', error);
|
||||||
return;
|
}
|
||||||
}
|
}
|
||||||
console.error("❌ Erro ao configurar push notifications:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remover subscription ao fazer logout
|
// Remover subscription ao fazer logout
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!currentUser?.data) {
|
if (!currentUser?.data) {
|
||||||
removerPushSubscription().then(() => {
|
removerPushSubscription().then(() => {
|
||||||
console.log("Push subscription removida");
|
console.log('Push subscription removida');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Componente invisível - apenas lógica -->
|
<!-- Componente invisível - apenas lógica -->
|
||||||
|
|||||||
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>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { useConvexClient } from 'convex-svelte';
|
||||||
|
|
||||||
interface Periodo {
|
interface Periodo {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
onCancelar?: () => void;
|
onCancelar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
const { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
|||||||
89
apps/web/src/lib/components/almoxarifado/AlertaCard.svelte
Normal file
89
apps/web/src/lib/components/almoxarifado/AlertaCard.svelte
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { AlertTriangle, Package } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
alerta: {
|
||||||
|
_id: Id<'alertasEstoque'>;
|
||||||
|
materialId: Id<'materiais'>;
|
||||||
|
tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria';
|
||||||
|
quantidadeAtual: number;
|
||||||
|
quantidadeMinima: number;
|
||||||
|
status: 'ativo' | 'resolvido' | 'ignorado';
|
||||||
|
criadoEm: number;
|
||||||
|
};
|
||||||
|
materialNome?: string;
|
||||||
|
materialCodigo?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { alerta, materialNome = 'Carregando...', materialCodigo = '' }: Props = $props();
|
||||||
|
|
||||||
|
function getTipoBadge(tipo: string) {
|
||||||
|
switch (tipo) {
|
||||||
|
case 'estoque_zerado':
|
||||||
|
return 'badge-error';
|
||||||
|
case 'estoque_minimo':
|
||||||
|
return 'badge-warning';
|
||||||
|
case 'reposicao_necessaria':
|
||||||
|
return 'badge-info';
|
||||||
|
default:
|
||||||
|
return 'badge-ghost';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTipoLabel(tipo: string) {
|
||||||
|
switch (tipo) {
|
||||||
|
case 'estoque_zerado':
|
||||||
|
return 'Estoque Zerado';
|
||||||
|
case 'estoque_minimo':
|
||||||
|
return 'Estoque Mínimo';
|
||||||
|
case 'reposicao_necessaria':
|
||||||
|
return 'Reposição Necessária';
|
||||||
|
default:
|
||||||
|
return tipo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
{@const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-lg border-2 {alerta.status === 'ativo' ? 'border-warning' : 'border-base-300'}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<AlertTriangle class="h-5 w-5 text-warning" />
|
||||||
|
<h3 class="card-title text-lg">{materialNome}</h3>
|
||||||
|
</div>
|
||||||
|
{#if materialCodigo}
|
||||||
|
<p class="text-sm text-base-content/60 font-mono mb-2">Código: {materialCodigo}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<span class="badge {getTipoBadge(alerta.tipo)}">{getTipoLabel(alerta.tipo)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/60 mb-1">Quantidade Atual</p>
|
||||||
|
<p class="text-2xl font-bold text-error">{alerta.quantidadeAtual}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/60 mb-1">Quantidade Mínima</p>
|
||||||
|
<p class="text-xl font-medium">{alerta.quantidadeMinima}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="text-xs text-base-content/60 mb-1">Faltam</p>
|
||||||
|
<p class="text-lg font-bold text-warning">{diferenca} unidades</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 text-xs text-base-content/60">
|
||||||
|
Criado em: {new Date(alerta.criadoEm).toLocaleString('pt-BR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
349
apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte
Normal file
349
apps/web/src/lib/components/almoxarifado/BarcodeScanner.svelte
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import { Html5Qrcode, type Html5QrcodeResult } from 'html5-qrcode';
|
||||||
|
import { Camera, X, Scan } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onScan: (code: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { onScan, onError, enabled = $bindable(false) }: Props = $props();
|
||||||
|
|
||||||
|
let scanner: Html5Qrcode | null = $state(null);
|
||||||
|
let scanning = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let scannerElement = $state<HTMLDivElement | null>(null);
|
||||||
|
let inputBuffer = $state('');
|
||||||
|
let inputTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const scannerId = `barcode-scanner-${Math.random().toString(36).substring(7)}`;
|
||||||
|
|
||||||
|
// Configuração do scanner
|
||||||
|
const config = {
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 },
|
||||||
|
aspectRatio: 1.0
|
||||||
|
// A biblioteca html5-qrcode suporta automaticamente vários formatos:
|
||||||
|
// EAN-13, EAN-8, UPC-A, UPC-E, Code 128, Code 39, Code 93, QR Code, etc.
|
||||||
|
};
|
||||||
|
|
||||||
|
async function startScanning() {
|
||||||
|
// Aguardar o DOM ser atualizado
|
||||||
|
await tick();
|
||||||
|
|
||||||
|
// Verificar se o elemento existe no DOM
|
||||||
|
const element = document.getElementById(scannerId);
|
||||||
|
if (!element) {
|
||||||
|
// Tentar novamente após um pequeno delay
|
||||||
|
setTimeout(async () => {
|
||||||
|
const retryElement = document.getElementById(scannerId);
|
||||||
|
if (!retryElement) {
|
||||||
|
const errorMsg = 'Elemento do scanner não encontrado no DOM';
|
||||||
|
error = errorMsg;
|
||||||
|
scanning = false;
|
||||||
|
if (onError) {
|
||||||
|
onError(errorMsg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await startScanningInternal();
|
||||||
|
}, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await startScanningInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startScanningInternal() {
|
||||||
|
const element = document.getElementById(scannerId);
|
||||||
|
if (!element) {
|
||||||
|
const errorMsg = 'Elemento do scanner não encontrado';
|
||||||
|
error = errorMsg;
|
||||||
|
scanning = false;
|
||||||
|
if (onError) {
|
||||||
|
onError(errorMsg);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
error = null;
|
||||||
|
scanning = true;
|
||||||
|
|
||||||
|
scanner = new Html5Qrcode(scannerId);
|
||||||
|
|
||||||
|
// Tentar primeiro com câmera traseira (environment), depois frontal (user)
|
||||||
|
let cameraConfig = { facingMode: 'environment' as const };
|
||||||
|
|
||||||
|
try {
|
||||||
|
await scanner.start(
|
||||||
|
cameraConfig,
|
||||||
|
config,
|
||||||
|
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||||
|
handleScannedCode(decodedText);
|
||||||
|
},
|
||||||
|
(errorMessage: string) => {
|
||||||
|
// Ignorar erros de leitura contínua
|
||||||
|
if (errorMessage.includes('No MultiFormat Readers')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (cameraError) {
|
||||||
|
// Se falhar com câmera traseira, tentar com frontal (útil para PC)
|
||||||
|
if (cameraConfig.facingMode === 'environment') {
|
||||||
|
console.log('Tentando câmera frontal...');
|
||||||
|
cameraConfig = { facingMode: 'user' };
|
||||||
|
await scanner.start(
|
||||||
|
cameraConfig,
|
||||||
|
config,
|
||||||
|
(decodedText: string, decodedResult: Html5QrcodeResult) => {
|
||||||
|
handleScannedCode(decodedText);
|
||||||
|
},
|
||||||
|
(errorMessage: string) => {
|
||||||
|
if (errorMessage.includes('No MultiFormat Readers')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw cameraError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
let errorMessage = 'Erro ao iniciar scanner';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
errorMessage = err.message;
|
||||||
|
|
||||||
|
// Mensagens de erro mais amigáveis
|
||||||
|
if (errorMessage.includes('Permission denied') || errorMessage.includes('NotAllowedError')) {
|
||||||
|
errorMessage = 'Permissão de câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
|
||||||
|
} else if (errorMessage.includes('NotFoundError') || errorMessage.includes('No camera found')) {
|
||||||
|
errorMessage = 'Nenhuma câmera encontrada. Verifique se há uma câmera conectada ao dispositivo.';
|
||||||
|
} else if (errorMessage.includes('NotReadableError') || errorMessage.includes('TrackStartError')) {
|
||||||
|
errorMessage = 'Câmera está sendo usada por outro aplicativo. Feche outros aplicativos que possam estar usando a câmera.';
|
||||||
|
} else if (errorMessage.includes('OverconstrainedError')) {
|
||||||
|
errorMessage = 'Câmera não suporta as configurações necessárias.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error = errorMessage;
|
||||||
|
scanning = false;
|
||||||
|
|
||||||
|
// Limpar scanner em caso de erro
|
||||||
|
if (scanner) {
|
||||||
|
try {
|
||||||
|
await scanner.clear();
|
||||||
|
} catch (clearErr) {
|
||||||
|
console.error('Erro ao limpar scanner:', clearErr);
|
||||||
|
}
|
||||||
|
scanner = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onError) {
|
||||||
|
onError(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Erro ao iniciar scanner:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopScanning() {
|
||||||
|
if (scanner) {
|
||||||
|
try {
|
||||||
|
await scanner.stop();
|
||||||
|
await scanner.clear();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao parar scanner:', err);
|
||||||
|
}
|
||||||
|
scanner = null;
|
||||||
|
}
|
||||||
|
scanning = false;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScannedCode(code: string) {
|
||||||
|
if (code && code.trim()) {
|
||||||
|
stopScanning();
|
||||||
|
enabled = false;
|
||||||
|
onScan(code.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suporte para leitores USB/Bluetooth (captura de eventos de teclado)
|
||||||
|
function handleKeyPress(event: KeyboardEvent) {
|
||||||
|
// Ignorar se estiver digitando em um input
|
||||||
|
if (
|
||||||
|
event.target instanceof HTMLInputElement ||
|
||||||
|
event.target instanceof HTMLTextAreaElement ||
|
||||||
|
event.target instanceof HTMLSelectElement
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leitores de código de barras geralmente enviam caracteres rapidamente
|
||||||
|
if (event.key === 'Enter' && inputBuffer.trim()) {
|
||||||
|
event.preventDefault();
|
||||||
|
handleScannedCode(inputBuffer.trim());
|
||||||
|
inputBuffer = '';
|
||||||
|
if (inputTimeout) {
|
||||||
|
clearTimeout(inputTimeout);
|
||||||
|
inputTimeout = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acumular caracteres digitados rapidamente
|
||||||
|
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||||
|
inputBuffer += event.key;
|
||||||
|
if (inputTimeout) {
|
||||||
|
clearTimeout(inputTimeout);
|
||||||
|
}
|
||||||
|
inputTimeout = setTimeout(() => {
|
||||||
|
inputBuffer = '';
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScanner() {
|
||||||
|
if (scanning) {
|
||||||
|
stopScanning();
|
||||||
|
enabled = false;
|
||||||
|
} else {
|
||||||
|
enabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (enabled && !scanning) {
|
||||||
|
// Aguardar um pouco para garantir que o DOM foi atualizado
|
||||||
|
setTimeout(() => {
|
||||||
|
if (enabled && !scanning) {
|
||||||
|
startScanning();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
} else if (!enabled && scanning) {
|
||||||
|
stopScanning();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
window.addEventListener('keydown', handleKeyPress);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeyPress);
|
||||||
|
if (inputTimeout) {
|
||||||
|
clearTimeout(inputTimeout);
|
||||||
|
}
|
||||||
|
stopScanning();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="barcode-scanner">
|
||||||
|
{#if enabled}
|
||||||
|
<div class="card bg-base-100 border border-base-300 shadow-xl">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h3 class="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<Scan class="h-5 w-5" />
|
||||||
|
Leitor de Código de Barras
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
onclick={() => {
|
||||||
|
enabled = false;
|
||||||
|
}}
|
||||||
|
aria-label="Fechar scanner"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error mb-4">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-ghost mt-2"
|
||||||
|
onclick={async () => {
|
||||||
|
error = null;
|
||||||
|
scanning = false;
|
||||||
|
// Limpar scanner anterior se existir
|
||||||
|
if (scanner) {
|
||||||
|
try {
|
||||||
|
await scanner.clear();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao limpar scanner:', err);
|
||||||
|
}
|
||||||
|
scanner = null;
|
||||||
|
}
|
||||||
|
// Aguardar um pouco antes de tentar novamente
|
||||||
|
await tick();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (enabled && !scanning) {
|
||||||
|
startScanning();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Tentar novamente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Sempre renderizar o elemento quando enabled for true -->
|
||||||
|
<div class="relative">
|
||||||
|
<div id={scannerId} bind:this={scannerElement}></div>
|
||||||
|
{#if scanning}
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Posicione o código de barras dentro da área de leitura
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-base-content/50 mt-2">
|
||||||
|
Ou use um leitor USB/Bluetooth para escanear
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if !error}
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<Camera class="h-16 w-16 mx-auto mb-4 text-base-content/30" />
|
||||||
|
<p class="text-base-content/70">Iniciando scanner...</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions justify-end mt-4">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={() => { enabled = false; }}>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline btn-primary"
|
||||||
|
onclick={toggleScanner}
|
||||||
|
aria-label="Abrir leitor de código de barras"
|
||||||
|
>
|
||||||
|
<Scan class="h-5 w-5" />
|
||||||
|
Ler Código de Barras
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.barcode-scanner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[id^='barcode-scanner-'] {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
51
apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte
Normal file
51
apps/web/src/lib/components/almoxarifado/EstoqueGauge.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
estoqueAtual: number;
|
||||||
|
estoqueMinimo: number;
|
||||||
|
estoqueMaximo?: number;
|
||||||
|
unidadeMedida: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { estoqueAtual, estoqueMinimo, estoqueMaximo, unidadeMedida }: Props = $props();
|
||||||
|
|
||||||
|
{@const porcentagem = estoqueMaximo
|
||||||
|
? Math.min(100, (estoqueAtual / estoqueMaximo) * 100)
|
||||||
|
: estoqueAtual > estoqueMinimo
|
||||||
|
? 100
|
||||||
|
: Math.max(0, (estoqueAtual / estoqueMinimo) * 100)}
|
||||||
|
|
||||||
|
{@const cor = estoqueAtual <= estoqueMinimo
|
||||||
|
? 'text-error'
|
||||||
|
: estoqueMaximo && estoqueAtual >= estoqueMaximo * 0.8
|
||||||
|
? 'text-warning'
|
||||||
|
: 'text-success'}
|
||||||
|
|
||||||
|
{@const corBarra = estoqueAtual <= estoqueMinimo
|
||||||
|
? 'bg-error'
|
||||||
|
: estoqueMaximo && estoqueAtual >= estoqueMaximo * 0.8
|
||||||
|
? 'bg-warning'
|
||||||
|
: 'bg-success'}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium">Estoque</span>
|
||||||
|
<span class="text-sm font-bold {cor}">
|
||||||
|
{estoqueAtual} {unidadeMedida}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-base-300 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-full {corBarra} transition-all duration-500"
|
||||||
|
style="width: {porcentagem}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between text-xs text-base-content/60">
|
||||||
|
<span>Mín: {estoqueMinimo}</span>
|
||||||
|
{#if estoqueMaximo}
|
||||||
|
<span>Máx: {estoqueMaximo}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Clock, User, FileText } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface HistoricoItem {
|
||||||
|
acao: string;
|
||||||
|
usuarioId: string;
|
||||||
|
usuarioNome?: string;
|
||||||
|
timestamp: number;
|
||||||
|
observacoes?: string;
|
||||||
|
dadosAnteriores?: string;
|
||||||
|
dadosNovos?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
historico: HistoricoItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
let { historico }: Props = $props();
|
||||||
|
|
||||||
|
function getAcaoLabel(acao: string) {
|
||||||
|
switch (acao) {
|
||||||
|
case 'criacao':
|
||||||
|
return 'Criação';
|
||||||
|
case 'edicao':
|
||||||
|
return 'Edição';
|
||||||
|
case 'exclusao':
|
||||||
|
return 'Exclusão';
|
||||||
|
case 'movimentacao':
|
||||||
|
return 'Movimentação';
|
||||||
|
default:
|
||||||
|
return acao;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcaoColor(acao: string) {
|
||||||
|
switch (acao) {
|
||||||
|
case 'criacao':
|
||||||
|
return 'text-success';
|
||||||
|
case 'edicao':
|
||||||
|
return 'text-info';
|
||||||
|
case 'exclusao':
|
||||||
|
return 'text-error';
|
||||||
|
case 'movimentacao':
|
||||||
|
return 'text-warning';
|
||||||
|
default:
|
||||||
|
return 'text-base-content';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each historico as item, index}
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<!-- Linha vertical -->
|
||||||
|
{#if index < historico.length - 1}
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||||
|
<Clock class="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div class="w-0.5 h-full bg-base-300 my-2"></div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-12 h-12 rounded-full bg-primary/20 flex items-center justify-center">
|
||||||
|
<Clock class="h-6 w-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Conteúdo -->
|
||||||
|
<div class="flex-1 pb-4">
|
||||||
|
<div class="card bg-base-100 shadow">
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<User class="h-4 w-4 text-base-content/60" />
|
||||||
|
<span class="font-medium">{item.usuarioNome || 'Usuário'}</span>
|
||||||
|
</div>
|
||||||
|
<span class="badge {getAcaoColor(item.acao)} badge-outline">
|
||||||
|
{getAcaoLabel(item.acao)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/60 mb-2">
|
||||||
|
{new Date(item.timestamp).toLocaleString('pt-BR')}
|
||||||
|
</p>
|
||||||
|
{#if item.observacoes}
|
||||||
|
<div class="flex items-start gap-2 mt-2">
|
||||||
|
<FileText class="h-4 w-4 text-base-content/60 mt-0.5" />
|
||||||
|
<p class="text-sm text-base-content/70">{item.observacoes}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
442
apps/web/src/lib/components/almoxarifado/ImageUpload.svelte
Normal file
442
apps/web/src/lib/components/almoxarifado/ImageUpload.svelte
Normal file
@@ -0,0 +1,442 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Image as ImageIcon, Upload, X, Camera } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: string | null;
|
||||||
|
onChange?: (base64: string | null) => void;
|
||||||
|
maxSizeMB?: number;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(null),
|
||||||
|
onChange,
|
||||||
|
maxSizeMB = 5,
|
||||||
|
maxWidth = 1200,
|
||||||
|
maxHeight = 1200
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let preview = $state<string | null>(value);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let inputElement: HTMLInputElement | null = null;
|
||||||
|
let showCamera = $state(false);
|
||||||
|
let videoElement: HTMLVideoElement | null = null;
|
||||||
|
let stream: MediaStream | null = null;
|
||||||
|
let capturing = $state(false);
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Validar tamanho
|
||||||
|
if (file.size > maxSizeMB * 1024 * 1024) {
|
||||||
|
error = `Arquivo muito grande. Tamanho máximo: ${maxSizeMB}MB`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validar tipo
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
error = 'Por favor, selecione um arquivo de imagem';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader();
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const result = e.target?.result as string;
|
||||||
|
if (result) {
|
||||||
|
// Redimensionar imagem se necessário
|
||||||
|
resizeImage(result, maxWidth, maxHeight)
|
||||||
|
.then((resized) => {
|
||||||
|
preview = resized;
|
||||||
|
value = resized;
|
||||||
|
if (onChange) {
|
||||||
|
onChange(resized);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
error = err instanceof Error ? err.message : 'Erro ao processar imagem';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.onerror = () => {
|
||||||
|
error = 'Erro ao ler arquivo';
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeImage(dataUrl: string, maxWidth: number, maxHeight: number): Promise<string> {
|
||||||
|
// Verificar se estamos no browser (não durante SSR)
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return Promise.reject(new Error('resizeImage não pode ser executada durante SSR'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new window.Image();
|
||||||
|
img.onload = () => {
|
||||||
|
let width = img.width;
|
||||||
|
let height = img.height;
|
||||||
|
|
||||||
|
// Calcular novas dimensões mantendo proporção
|
||||||
|
if (width > maxWidth || height > maxHeight) {
|
||||||
|
const ratio = Math.min(maxWidth / width, maxHeight / height);
|
||||||
|
width = width * ratio;
|
||||||
|
height = height * ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
reject(new Error('Não foi possível criar contexto do canvas'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, width, height);
|
||||||
|
const resizedDataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
resolve(resizedDataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
img.onerror = () => {
|
||||||
|
reject(new Error('Erro ao carregar imagem'));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage() {
|
||||||
|
preview = null;
|
||||||
|
value = null;
|
||||||
|
if (inputElement) {
|
||||||
|
inputElement.value = '';
|
||||||
|
}
|
||||||
|
if (onChange) {
|
||||||
|
onChange(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerFileInput() {
|
||||||
|
inputElement?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCamera() {
|
||||||
|
// Se já estiver inicializando ou já tiver stream, não fazer nada
|
||||||
|
if (stream) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Primeiro, abrir o modal
|
||||||
|
showCamera = true;
|
||||||
|
capturing = false;
|
||||||
|
error = null;
|
||||||
|
|
||||||
|
// Aguardar o próximo tick para garantir que o DOM foi atualizado
|
||||||
|
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Verificar se a API está disponível
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
error = 'Câmera não disponível neste dispositivo ou navegador não suporta acesso à câmera';
|
||||||
|
showCamera = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aguardar o elemento de vídeo estar disponível no DOM
|
||||||
|
let attempts = 0;
|
||||||
|
while (!videoElement && attempts < 30) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!videoElement) {
|
||||||
|
throw new Error('Elemento de vídeo não encontrado no DOM');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solicitar acesso à câmera
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment', // Câmera traseira por padrão
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atribuir stream ao vídeo
|
||||||
|
videoElement.srcObject = stream;
|
||||||
|
|
||||||
|
// Aguardar o vídeo estar pronto e começar a reproduzir
|
||||||
|
await videoElement.play();
|
||||||
|
|
||||||
|
// Aguardar metadata estar carregado
|
||||||
|
if (videoElement.readyState < 2) {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
if (!videoElement) {
|
||||||
|
reject(new Error('Elemento de vídeo não encontrado'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onLoadedMetadata = () => {
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement?.removeEventListener('error', onError);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = () => {
|
||||||
|
videoElement?.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||||
|
videoElement?.removeEventListener('error', onError);
|
||||||
|
reject(new Error('Erro ao carregar vídeo'));
|
||||||
|
};
|
||||||
|
|
||||||
|
videoElement.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
|
||||||
|
videoElement.addEventListener('error', onError, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
capturing = true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Erro ao acessar câmera:', err);
|
||||||
|
let errorMessage = 'Erro ao acessar câmera';
|
||||||
|
|
||||||
|
if (err instanceof Error) {
|
||||||
|
if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') {
|
||||||
|
errorMessage =
|
||||||
|
'Permissão de acesso à câmera negada. Por favor, permita o acesso à câmera nas configurações do navegador.';
|
||||||
|
} else if (err.name === 'NotFoundError' || err.name === 'DevicesNotFoundError') {
|
||||||
|
errorMessage = 'Nenhuma câmera encontrada no dispositivo.';
|
||||||
|
} else if (err.name === 'NotReadableError' || err.name === 'TrackStartError') {
|
||||||
|
errorMessage = 'Câmera está sendo usada por outro aplicativo.';
|
||||||
|
} else {
|
||||||
|
errorMessage = err.message || errorMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
error = errorMessage;
|
||||||
|
showCamera = false;
|
||||||
|
capturing = false;
|
||||||
|
stopCamera();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCamera() {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
if (videoElement) {
|
||||||
|
videoElement.srcObject = null;
|
||||||
|
}
|
||||||
|
capturing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCamera() {
|
||||||
|
stopCamera();
|
||||||
|
showCamera = false;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capturePhoto() {
|
||||||
|
if (!videoElement) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Criar canvas para capturar o frame
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = videoElement.videoWidth;
|
||||||
|
canvas.height = videoElement.videoHeight;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
if (!ctx) {
|
||||||
|
error = 'Não foi possível criar contexto do canvas';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desenhar o frame atual do vídeo no canvas
|
||||||
|
ctx.drawImage(videoElement, 0, 0);
|
||||||
|
|
||||||
|
// Converter para base64
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
|
||||||
|
// Redimensionar e processar
|
||||||
|
const resized = await resizeImage(dataUrl, maxWidth, maxHeight);
|
||||||
|
preview = resized;
|
||||||
|
value = resized;
|
||||||
|
if (onChange) {
|
||||||
|
onChange(resized);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fechar câmera
|
||||||
|
closeCamera();
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof Error ? err.message : 'Erro ao capturar foto';
|
||||||
|
console.error('Erro ao capturar foto:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sincronizar preview com value sempre que value mudar
|
||||||
|
$effect(() => {
|
||||||
|
// Acessar value para criar dependência reativa
|
||||||
|
const currentValue = value;
|
||||||
|
// Sempre sincronizar quando value mudar
|
||||||
|
if (currentValue !== preview) {
|
||||||
|
preview = currentValue;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Limpar stream quando o componente for desmontado
|
||||||
|
$effect(() => {
|
||||||
|
return () => {
|
||||||
|
stopCamera();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="image-upload">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden"
|
||||||
|
bind:this={inputElement}
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
aria-label="Selecionar imagem do produto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<div class="relative inline-block">
|
||||||
|
<img
|
||||||
|
src={preview}
|
||||||
|
alt="Preview da imagem do produto"
|
||||||
|
class="max-h-64 max-w-full rounded-lg"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle btn-error absolute top-2 right-2"
|
||||||
|
onclick={removeImage}
|
||||||
|
aria-label="Remover imagem"
|
||||||
|
>
|
||||||
|
<X class="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
class="border-base-300 hover:border-primary cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors"
|
||||||
|
onclick={triggerFileInput}
|
||||||
|
role="button"
|
||||||
|
tabindex="0"
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
triggerFileInput();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Upload class="text-base-content/40 mx-auto mb-4 h-12 w-12" />
|
||||||
|
<p class="text-base-content/70 mb-2 font-medium">Clique para fazer upload da imagem</p>
|
||||||
|
<p class="text-base-content/50 text-sm">
|
||||||
|
PNG, JPG ou GIF até {maxSizeMB}MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="divider text-sm">ou</div>
|
||||||
|
<button type="button" class="btn btn-outline btn-primary w-full" onclick={openCamera}>
|
||||||
|
<Camera class="h-5 w-5" />
|
||||||
|
Capturar da Câmera
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert alert-error mt-4">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if preview}
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-outline btn-primary flex-1"
|
||||||
|
onclick={triggerFileInput}
|
||||||
|
>
|
||||||
|
<ImageIcon class="h-4 w-4" />
|
||||||
|
Alterar Imagem
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline btn-primary flex-1" onclick={openCamera}>
|
||||||
|
<Camera class="h-4 w-4" />
|
||||||
|
Capturar Foto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal da Câmera -->
|
||||||
|
{#if showCamera}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80"
|
||||||
|
onclick={closeCamera}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-base-100 mx-4 w-full max-w-2xl rounded-lg p-6 shadow-2xl"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold">Capturar Foto</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm btn-circle btn-ghost"
|
||||||
|
onclick={closeCamera}
|
||||||
|
aria-label="Fechar câmera"
|
||||||
|
>
|
||||||
|
<X class="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative mb-4 overflow-hidden rounded-lg bg-black"
|
||||||
|
style="aspect-ratio: 4/3; min-height: 300px;"
|
||||||
|
>
|
||||||
|
{#if showCamera}
|
||||||
|
<video
|
||||||
|
bind:this={videoElement}
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
muted
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
style="transform: scaleX(-1); opacity: {capturing
|
||||||
|
? '1'
|
||||||
|
: '0'}; transition: opacity 0.3s;"
|
||||||
|
></video>
|
||||||
|
{/if}
|
||||||
|
{#if !capturing}
|
||||||
|
<div class="absolute inset-0 z-10 flex h-full items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<span class="loading loading-spinner loading-lg text-primary mb-2"></span>
|
||||||
|
<p class="text-base-content/70 text-sm">Iniciando câmera...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button type="button" class="btn btn-ghost" onclick={closeCamera}> Cancelar </button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick={capturePhoto} disabled={!capturing}>
|
||||||
|
<Camera class="h-5 w-5" />
|
||||||
|
Capturar Foto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-upload {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
68
apps/web/src/lib/components/almoxarifado/MaterialCard.svelte
Normal file
68
apps/web/src/lib/components/almoxarifado/MaterialCard.svelte
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { Package, AlertTriangle } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
material: {
|
||||||
|
_id: Id<'materiais'>;
|
||||||
|
codigo: string;
|
||||||
|
nome: string;
|
||||||
|
descricao?: string;
|
||||||
|
categoria: string;
|
||||||
|
estoqueAtual: number;
|
||||||
|
estoqueMinimo: number;
|
||||||
|
unidadeMedida: string;
|
||||||
|
ativo: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { material }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<Package class="h-5 w-5 text-primary" />
|
||||||
|
<h3 class="card-title text-lg">{material.nome}</h3>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/60 font-mono mb-1">Código: {material.codigo}</p>
|
||||||
|
{#if material.descricao}
|
||||||
|
<p class="text-sm text-base-content/70 mb-2">{material.descricao}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<span class="badge badge-outline">{material.categoria}</span>
|
||||||
|
{#if material.ativo}
|
||||||
|
<span class="badge badge-success">Ativo</span>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-error">Inativo</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider my-2"></div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-base-content/60">Estoque Atual</p>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-2xl font-bold {material.estoqueAtual <= material.estoqueMinimo ? 'text-error' : 'text-success'}">
|
||||||
|
{material.estoqueAtual}
|
||||||
|
</p>
|
||||||
|
<span class="text-sm text-base-content/60">{material.unidadeMedida}</span>
|
||||||
|
{#if material.estoqueAtual <= material.estoqueMinimo}
|
||||||
|
<AlertTriangle class="h-5 w-5 text-warning" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-base-content/60">Mínimo</p>
|
||||||
|
<p class="text-lg font-medium">{material.estoqueMinimo} {material.unidadeMedida}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
219
apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte
Normal file
219
apps/web/src/lib/components/almoxarifado/MovimentacaoForm.svelte
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { ArrowDown, ArrowUp, Settings } from 'lucide-svelte';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tipo: 'entrada' | 'saida' | 'ajuste';
|
||||||
|
materialId?: Id<'materiais'> | '';
|
||||||
|
onSubmit: (data: {
|
||||||
|
materialId: Id<'materiais'>;
|
||||||
|
quantidade: number;
|
||||||
|
motivo: string;
|
||||||
|
documento?: string;
|
||||||
|
funcionarioId?: Id<'funcionarios'>;
|
||||||
|
setorId?: Id<'setores'>;
|
||||||
|
observacoes?: string;
|
||||||
|
quantidadeNova?: number;
|
||||||
|
}) => Promise<void>;
|
||||||
|
materiais?: Array<{
|
||||||
|
_id: Id<'materiais'>;
|
||||||
|
codigo: string;
|
||||||
|
nome: string;
|
||||||
|
estoqueAtual: number;
|
||||||
|
unidadeMedida: string;
|
||||||
|
}>;
|
||||||
|
funcionarios?: Array<{
|
||||||
|
_id: Id<'funcionarios'>;
|
||||||
|
nome: string;
|
||||||
|
}>;
|
||||||
|
setores?: Array<{
|
||||||
|
_id: Id<'setores'>;
|
||||||
|
nome: string;
|
||||||
|
}>;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
tipo,
|
||||||
|
materialId = '',
|
||||||
|
onSubmit,
|
||||||
|
materiais = [],
|
||||||
|
funcionarios = [],
|
||||||
|
setores = [],
|
||||||
|
loading = false
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let quantidade = $state(0);
|
||||||
|
let quantidadeNova = $state(0);
|
||||||
|
let motivo = $state('');
|
||||||
|
let documento = $state('');
|
||||||
|
let funcionarioId = $state<Id<'funcionarios'> | ''>('');
|
||||||
|
let setorId = $state<Id<'setores'> | ''>('');
|
||||||
|
let observacoes = $state('');
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!materialId || !motivo.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipo === 'ajuste') {
|
||||||
|
if (quantidadeNova < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSubmit({
|
||||||
|
materialId: materialId as Id<'materiais'>,
|
||||||
|
quantidadeNova,
|
||||||
|
motivo: motivo.trim(),
|
||||||
|
observacoes: observacoes.trim() || undefined
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (quantidade <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await onSubmit({
|
||||||
|
materialId: materialId as Id<'materiais'>,
|
||||||
|
quantidade,
|
||||||
|
motivo: motivo.trim(),
|
||||||
|
documento: documento.trim() || undefined,
|
||||||
|
funcionarioId: funcionarioId ? (funcionarioId as Id<'funcionarios'>) : undefined,
|
||||||
|
setorId: setorId ? (setorId as Id<'setores'>) : undefined,
|
||||||
|
observacoes: observacoes.trim() || undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpar formulário
|
||||||
|
quantidade = 0;
|
||||||
|
quantidadeNova = 0;
|
||||||
|
motivo = '';
|
||||||
|
documento = '';
|
||||||
|
funcionarioId = '';
|
||||||
|
setorId = '';
|
||||||
|
observacoes = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
|
||||||
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-bold">Material *</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered" bind:value={materialId} required>
|
||||||
|
<option value="">Selecione um material</option>
|
||||||
|
{#each materiais as material}
|
||||||
|
<option value={material._id}>
|
||||||
|
{material.codigo} - {material.nome} (Estoque: {material.estoqueAtual}{material.unidadeMedida})
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tipo === 'ajuste'}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-bold">Nova Quantidade *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered"
|
||||||
|
min="0"
|
||||||
|
bind:value={quantidadeNova}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-bold">Quantidade *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="input input-bordered"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
bind:value={quantidade}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tipo === 'entrada'}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Documento (NF, etc.)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
placeholder="Número da nota fiscal"
|
||||||
|
bind:value={documento}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if tipo === 'saida'}
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Funcionário</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered" bind:value={funcionarioId}>
|
||||||
|
<option value="">Selecione (opcional)</option>
|
||||||
|
{#each funcionarios as funcionario}
|
||||||
|
<option value={funcionario._id}>{funcionario.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-control">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Setor</span>
|
||||||
|
</label>
|
||||||
|
<select class="select select-bordered" bind:value={setorId}>
|
||||||
|
<option value="">Selecione (opcional)</option>
|
||||||
|
{#each setores as setor}
|
||||||
|
<option value={setor._id}>{setor.nome}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text font-bold">Motivo *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered"
|
||||||
|
placeholder={tipo === 'entrada' ? 'Ex: Compra, Doação' : tipo === 'saida' ? 'Ex: Uso interno' : 'Ex: Inventário físico'}
|
||||||
|
bind:value={motivo}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control md:col-span-2">
|
||||||
|
<label class="label">
|
||||||
|
<span class="label-text">Observações</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered"
|
||||||
|
placeholder="Observações adicionais (opcional)"
|
||||||
|
bind:value={observacoes}
|
||||||
|
rows="3"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions mt-6 justify-end">
|
||||||
|
<button type="submit" class="btn {tipo === 'ajuste' ? 'btn-warning' : tipo === 'entrada' ? 'btn-success' : 'btn-error'}" disabled={loading}>
|
||||||
|
{#if loading}
|
||||||
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
|
{:else if tipo === 'entrada'}
|
||||||
|
<ArrowDown class="h-5 w-5" />
|
||||||
|
{:else if tipo === 'saida'}
|
||||||
|
<ArrowUp class="h-5 w-5" />
|
||||||
|
{:else}
|
||||||
|
<Settings class="h-5 w-5" />
|
||||||
|
{/if}
|
||||||
|
Registrar {tipo === 'entrada' ? 'Entrada' : tipo === 'saida' ? 'Saída' : 'Ajuste'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
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 type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
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 {
|
interface Props {
|
||||||
funcionarioId: Id<'funcionarios'>;
|
funcionarioId: Id<'funcionarios'>;
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
onCancelar?: () => void;
|
onCancelar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
const { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||||
|
|
||||||
// Cliente Convex
|
// Cliente Convex
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
// Filtrar apenas ausências aprovadas ou aguardando aprovação (que bloqueiam novas solicitações)
|
||||||
const ausenciasExistentes = $derived(
|
let ausenciasExistentes = $derived(
|
||||||
(ausenciasExistentesQuery?.data || [])
|
(ausenciasExistentesQuery?.data || [])
|
||||||
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
|
.filter((a) => a.status === 'aprovado' || a.status === 'aguardando_aprovacao')
|
||||||
.map((a) => ({
|
.map((a) => ({
|
||||||
@@ -58,7 +60,7 @@
|
|||||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
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
|
// Funções de navegação
|
||||||
function proximoPasso() {
|
function proximoPasso() {
|
||||||
@@ -70,14 +72,14 @@
|
|||||||
|
|
||||||
const hoje = new SvelteDate();
|
const hoje = new SvelteDate();
|
||||||
hoje.setHours(0, 0, 0, 0);
|
hoje.setHours(0, 0, 0, 0);
|
||||||
const inicio = new Date(dataInicio);
|
const inicio = parseLocalDate(dataInicio);
|
||||||
|
|
||||||
if (inicio < hoje) {
|
if (inicio < hoje) {
|
||||||
toast.error('A data de início não pode ser no passado');
|
toast.error('A data de início não pode ser no passado');
|
||||||
return;
|
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');
|
toast.error('A data de fim deve ser maior ou igual à data de início');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -133,7 +135,7 @@
|
|||||||
mensagemErro.includes('solicitação aprovada ou pendente')
|
mensagemErro.includes('solicitação aprovada ou pendente')
|
||||||
) {
|
) {
|
||||||
mensagemErroModal = 'Não é possível criar esta solicitação.';
|
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;
|
mostrarModalErro = true;
|
||||||
} else {
|
} else {
|
||||||
// Outros erros continuam usando toast
|
// Outros erros continuam usando toast
|
||||||
@@ -168,20 +170,7 @@
|
|||||||
<div class="step-item">
|
<div class="step-item">
|
||||||
<div class="step-marker">
|
<div class="step-marker">
|
||||||
{#if passoAtual > 1}
|
{#if passoAtual > 1}
|
||||||
<svg
|
<Check class="h-6 w-6" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
{:else}
|
||||||
{passoAtual}
|
{passoAtual}
|
||||||
{/if}
|
{/if}
|
||||||
@@ -196,20 +185,7 @@
|
|||||||
<div class="step-item">
|
<div class="step-item">
|
||||||
<div class="step-marker">
|
<div class="step-marker">
|
||||||
{#if passoAtual > 2}
|
{#if passoAtual > 2}
|
||||||
<svg
|
<Check class="h-6 w-6" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
{:else}
|
||||||
2
|
2
|
||||||
{/if}
|
{/if}
|
||||||
@@ -251,24 +227,12 @@
|
|||||||
|
|
||||||
{#if dataInicio && dataFim}
|
{#if dataInicio && dataFim}
|
||||||
<div class="alert alert-success shadow-lg">
|
<div class="alert alert-success shadow-lg">
|
||||||
<svg
|
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<h4 class="font-bold">Período selecionado!</h4>
|
<h4 class="font-bold">Período selecionado!</h4>
|
||||||
<p>
|
<p>
|
||||||
De {new Date(dataInicio).toLocaleDateString('pt-BR')} até
|
De {parseLocalDate(dataInicio).toLocaleDateString('pt-BR')} até
|
||||||
{new Date(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')} ({totalDias} dias)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,38 +250,23 @@
|
|||||||
|
|
||||||
<!-- Resumo do período -->
|
<!-- Resumo do período -->
|
||||||
{#if dataInicio && dataFim}
|
{#if dataInicio && dataFim}
|
||||||
<div
|
<div class="card border-base-content/20 border-2">
|
||||||
class="card border-2 border-base-content/20"
|
|
||||||
>
|
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h4 class="card-title text-orange-700 dark:text-orange-400">
|
<h4 class="card-title text-orange-700 dark:text-orange-400">
|
||||||
<svg
|
<Calendar class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Resumo do Período
|
Resumo do Período
|
||||||
</h4>
|
</h4>
|
||||||
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div class="mt-2 grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 text-sm">Data Início</p>
|
<p class="text-base-content/70 text-sm">Data Início</p>
|
||||||
<p class="font-bold">
|
<p class="font-bold">
|
||||||
{new Date(dataInicio).toLocaleDateString('pt-BR')}
|
{parseLocalDate(dataInicio).toLocaleDateString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-base-content/70 text-sm">Data Fim</p>
|
<p class="text-base-content/70 text-sm">Data Fim</p>
|
||||||
<p class="font-bold">
|
<p class="font-bold">
|
||||||
{new Date(dataFim).toLocaleDateString('pt-BR')}
|
{parseLocalDate(dataFim).toLocaleDateString('pt-BR')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -355,19 +304,7 @@
|
|||||||
|
|
||||||
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
|
{#if motivo.trim().length > 0 && motivo.trim().length < 10}
|
||||||
<div class="alert alert-warning shadow-lg">
|
<div class="alert alert-warning shadow-lg">
|
||||||
<svg
|
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
<span>O motivo deve ter no mínimo 10 caracteres</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -382,20 +319,7 @@
|
|||||||
onclick={passoAnterior}
|
onclick={passoAnterior}
|
||||||
disabled={passoAtual === 1 || processando}
|
disabled={passoAtual === 1 || processando}
|
||||||
>
|
>
|
||||||
<svg
|
<ChevronLeft class="mr-2 h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="mr-2 h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15 19l-7-7 7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Voltar
|
Voltar
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -407,20 +331,7 @@
|
|||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
>
|
||||||
Próximo
|
Próximo
|
||||||
<svg
|
<ChevronRight class="ml-2 h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="ml-2 h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -433,20 +344,7 @@
|
|||||||
<span class="loading loading-spinner"></span>
|
<span class="loading loading-spinner"></span>
|
||||||
Enviando...
|
Enviando...
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<Check class="mr-2 h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="mr-2 h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Enviar Solicitação
|
Enviar Solicitação
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Mic, MicOff, Video, VideoOff, Radio, Square, Settings, PhoneOff, Circle } from 'lucide-svelte';
|
import {
|
||||||
|
Mic,
|
||||||
|
MicOff,
|
||||||
|
Video,
|
||||||
|
VideoOff,
|
||||||
|
Radio,
|
||||||
|
Square,
|
||||||
|
Settings,
|
||||||
|
PhoneOff,
|
||||||
|
Circle
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
audioHabilitado: boolean;
|
audioHabilitado: boolean;
|
||||||
@@ -15,7 +25,7 @@
|
|||||||
onEncerrar: () => void;
|
onEncerrar: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
const {
|
||||||
audioHabilitado,
|
audioHabilitado,
|
||||||
videoHabilitado,
|
videoHabilitado,
|
||||||
gravando,
|
gravando,
|
||||||
@@ -41,7 +51,7 @@
|
|||||||
return `${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
|
return `${minutos.toString().padStart(2, '0')}:${segs.toString().padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const duracaoFormatada = $derived(formatarDuracao(duracaoSegundos));
|
let duracaoFormatada = $derived(formatarDuracao(duracaoSegundos));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-base-200 flex items-center justify-between gap-2 px-4 py-3">
|
<div class="bg-base-200 flex items-center justify-between gap-2 px-4 py-3">
|
||||||
@@ -129,5 +139,3 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { X, Check, Volume2, VolumeX } from 'lucide-svelte';
|
import { Check, Volume2, X } from 'lucide-svelte';
|
||||||
import { obterDispositivosDisponiveis, solicitarPermissaoMidia } from '$lib/utils/jitsi';
|
|
||||||
import type { DispositivoMedia } from '$lib/utils/jitsi';
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import type { DispositivoMedia } from '$lib/utils/jitsi';
|
||||||
|
import { obterDispositivosDisponiveis, solicitarPermissaoMidia } from '$lib/utils/jitsi';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -19,12 +19,7 @@
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { open, dispositivoAtual, onClose, onAplicar }: Props = $props();
|
||||||
open,
|
|
||||||
dispositivoAtual,
|
|
||||||
onClose,
|
|
||||||
onAplicar
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
let dispositivos = $state<{
|
let dispositivos = $state<{
|
||||||
microphones: DispositivoMedia[];
|
microphones: DispositivoMedia[];
|
||||||
@@ -81,7 +76,9 @@
|
|||||||
const constraints: MediaStreamConstraints = {
|
const constraints: MediaStreamConstraints = {
|
||||||
audio: audio
|
audio: audio
|
||||||
? {
|
? {
|
||||||
deviceId: selecionados.microphoneId ? { exact: selecionados.microphoneId } : undefined
|
deviceId: selecionados.microphoneId
|
||||||
|
? { exact: selecionados.microphoneId }
|
||||||
|
: undefined
|
||||||
}
|
}
|
||||||
: false,
|
: false,
|
||||||
video: video
|
video: video
|
||||||
@@ -222,9 +219,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- Seleção de Microfone -->
|
<!-- Seleção de Microfone -->
|
||||||
<div>
|
<div>
|
||||||
<label class="text-base-content mb-2 block text-sm font-medium">
|
<label class="text-base-content mb-2 block text-sm font-medium"> Microfone </label>
|
||||||
Microfone
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
bind:value={selecionados.microphoneId}
|
bind:value={selecionados.microphoneId}
|
||||||
@@ -236,11 +231,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</select>
|
</select>
|
||||||
{#if selecionados.microphoneId}
|
{#if selecionados.microphoneId}
|
||||||
<button
|
<button type="button" class="btn btn-sm btn-ghost mt-2" onclick={testarAudio}>
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-ghost mt-2"
|
|
||||||
onclick={testarAudio}
|
|
||||||
>
|
|
||||||
<Volume2 class="h-4 w-4" />
|
<Volume2 class="h-4 w-4" />
|
||||||
Testar
|
Testar
|
||||||
</button>
|
</button>
|
||||||
@@ -249,9 +240,7 @@
|
|||||||
|
|
||||||
<!-- Seleção de Câmera -->
|
<!-- Seleção de Câmera -->
|
||||||
<div>
|
<div>
|
||||||
<label class="text-base-content mb-2 block text-sm font-medium">
|
<label class="text-base-content mb-2 block text-sm font-medium"> Câmera </label>
|
||||||
Câmera
|
|
||||||
</label>
|
|
||||||
<select
|
<select
|
||||||
class="select select-bordered w-full"
|
class="select select-bordered w-full"
|
||||||
bind:value={selecionados.cameraId}
|
bind:value={selecionados.cameraId}
|
||||||
@@ -267,9 +256,7 @@
|
|||||||
<!-- Preview de Vídeo -->
|
<!-- Preview de Vídeo -->
|
||||||
{#if selecionados.cameraId}
|
{#if selecionados.cameraId}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-base-content mb-2 block text-sm font-medium">
|
<label class="text-base-content mb-2 block text-sm font-medium"> Preview </label>
|
||||||
Preview
|
|
||||||
</label>
|
|
||||||
<div class="bg-base-300 aspect-video w-full overflow-hidden rounded-lg">
|
<div class="bg-base-300 aspect-video w-full overflow-hidden rounded-lg">
|
||||||
<video
|
<video
|
||||||
bind:this={previewVideo}
|
bind:this={previewVideo}
|
||||||
@@ -285,13 +272,8 @@
|
|||||||
<!-- Seleção de Alto-falante (se disponível) -->
|
<!-- Seleção de Alto-falante (se disponível) -->
|
||||||
{#if dispositivos.speakers.length > 0}
|
{#if dispositivos.speakers.length > 0}
|
||||||
<div>
|
<div>
|
||||||
<label class="text-base-content mb-2 block text-sm font-medium">
|
<label class="text-base-content mb-2 block text-sm font-medium"> Alto-falante </label>
|
||||||
Alto-falante
|
<select class="select select-bordered w-full" bind:value={selecionados.speakerId}>
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
class="select select-bordered w-full"
|
|
||||||
bind:value={selecionados.speakerId}
|
|
||||||
>
|
|
||||||
<option value={null}>Padrão do Sistema</option>
|
<option value={null}>Padrão do Sistema</option>
|
||||||
{#each dispositivos.speakers as speaker}
|
{#each dispositivos.speakers as speaker}
|
||||||
<option value={speaker.deviceId}>{speaker.label}</option>
|
<option value={speaker.deviceId}>{speaker.label}</option>
|
||||||
@@ -304,15 +286,8 @@
|
|||||||
|
|
||||||
<!-- Footer -->
|
<!-- Footer -->
|
||||||
<div class="modal-action border-base-300 border-t px-6 py-4">
|
<div class="modal-action border-base-300 border-t px-6 py-4">
|
||||||
<button type="button" class="btn btn-ghost" onclick={handleFechar}>
|
<button type="button" class="btn btn-ghost" onclick={handleFechar}> Cancelar </button>
|
||||||
Cancelar
|
<button type="button" class="btn btn-primary" onclick={handleAplicar} disabled={carregando}>
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary"
|
|
||||||
onclick={handleAplicar}
|
|
||||||
disabled={carregando}
|
|
||||||
>
|
|
||||||
<Check class="h-4 w-4" />
|
<Check class="h-4 w-4" />
|
||||||
Aplicar
|
Aplicar
|
||||||
</button>
|
</button>
|
||||||
@@ -324,4 +299,3 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
|
||||||
import { browser } from '$app/environment';
|
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { X, GripVertical, GripHorizontal } from 'lucide-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
|
import { GripVertical, X } from 'lucide-svelte';
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
import type {
|
import type {
|
||||||
JitsiConnection,
|
|
||||||
JitsiConference,
|
JitsiConference,
|
||||||
JitsiTrack,
|
JitsiConnection,
|
||||||
JitsiMeetJSLib,
|
|
||||||
JitsiConnectionOptions,
|
JitsiConnectionOptions,
|
||||||
|
JitsiMeetJSLib,
|
||||||
|
JitsiTrack,
|
||||||
WindowWithBlobBuilder
|
WindowWithBlobBuilder
|
||||||
} from '$lib/types/jitsi';
|
} from '$lib/types/jitsi';
|
||||||
|
|
||||||
@@ -21,38 +21,36 @@
|
|||||||
import CallSettings from './CallSettings.svelte';
|
import CallSettings from './CallSettings.svelte';
|
||||||
import HostControls from './HostControls.svelte';
|
import HostControls from './HostControls.svelte';
|
||||||
import RecordingIndicator from './RecordingIndicator.svelte';
|
import RecordingIndicator from './RecordingIndicator.svelte';
|
||||||
import ErrorModal from '../ErrorModal.svelte';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
callState,
|
callState,
|
||||||
toggleAudio,
|
inicializarChamada,
|
||||||
toggleVideo,
|
setJitsiApi,
|
||||||
iniciarGravacao as iniciarGravacaoStore,
|
|
||||||
pararGravacao as pararGravacaoStore,
|
|
||||||
atualizarDuracao,
|
|
||||||
atualizarStatusConexao,
|
atualizarStatusConexao,
|
||||||
|
setStreamLocal,
|
||||||
atualizarParticipantes,
|
atualizarParticipantes,
|
||||||
setAudioHabilitado,
|
setAudioHabilitado,
|
||||||
setVideoHabilitado,
|
setVideoHabilitado,
|
||||||
atualizarDispositivos,
|
atualizarDispositivos,
|
||||||
setJitsiApi,
|
atualizarDuracao
|
||||||
setStreamLocal,
|
|
||||||
finalizarChamada as finalizarChamadaStore,
|
|
||||||
inicializarChamada
|
|
||||||
} from '$lib/stores/callStore';
|
} from '$lib/stores/callStore';
|
||||||
|
|
||||||
import { obterConfiguracaoJitsi, obterHostEPorta } from '$lib/utils/jitsi';
|
|
||||||
import { traduzirErro } from '$lib/utils/erroHelpers';
|
import { traduzirErro } from '$lib/utils/erroHelpers';
|
||||||
import { GravadorMedia, gerarNomeArquivo, salvarGravacao } from '$lib/utils/mediaRecorder';
|
|
||||||
import {
|
import {
|
||||||
criarDragHandler,
|
|
||||||
criarResizeHandler,
|
|
||||||
salvarPosicaoJanela,
|
|
||||||
restaurarPosicaoJanela,
|
restaurarPosicaoJanela,
|
||||||
obterPosicaoInicial
|
obterPosicaoInicial,
|
||||||
|
criarDragHandler,
|
||||||
|
salvarPosicaoJanela,
|
||||||
|
criarResizeHandler
|
||||||
} from '$lib/utils/floatingWindow';
|
} from '$lib/utils/floatingWindow';
|
||||||
|
import { obterConfiguracaoJitsi, obterHostEPorta } from '$lib/utils/jitsi';
|
||||||
|
import { GravadorMedia, gerarNomeArquivo, salvarGravacao } from '$lib/utils/mediaRecorder';
|
||||||
|
import type { get } from 'svelte/store';
|
||||||
|
import ErrorModal from '../ErrorModal.svelte';
|
||||||
|
|
||||||
import { get } from 'svelte/store';
|
import {
|
||||||
|
iniciarGravacao as iniciarGravacaoStore,
|
||||||
|
pararGravacao as pararGravacaoStore,
|
||||||
|
finalizarChamada as finalizarChamadaStore
|
||||||
|
} from '$lib/stores/callStore';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
chamadaId: Id<'chamadas'>;
|
chamadaId: Id<'chamadas'>;
|
||||||
@@ -63,14 +61,7 @@
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let { chamadaId, conversaId, tipo, roomName, ehAnfitriao, onClose }: Props = $props();
|
||||||
chamadaId,
|
|
||||||
conversaId,
|
|
||||||
tipo,
|
|
||||||
roomName,
|
|
||||||
ehAnfitriao,
|
|
||||||
onClose
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -95,22 +86,24 @@
|
|||||||
let errorDetails = $state<string | undefined>(undefined);
|
let errorDetails = $state<string | undefined>(undefined);
|
||||||
|
|
||||||
// Estados de conexão e qualidade
|
// Estados de conexão e qualidade
|
||||||
let qualidadeConexao = $state<'excelente' | 'boa' | 'regular' | 'ruim' | 'desconhecida'>('desconhecida');
|
let qualidadeConexao = $state<'excelente' | 'boa' | 'regular' | 'ruim' | 'desconhecida'>(
|
||||||
|
'desconhecida'
|
||||||
|
);
|
||||||
let tentativasReconexao = $state(0);
|
let tentativasReconexao = $state(0);
|
||||||
const MAX_TENTATIVAS_RECONEXAO = 3;
|
const MAX_TENTATIVAS_RECONEXAO = 3;
|
||||||
let reconectando = $state(false);
|
let reconectando = $state(false);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
|
const chamadaQuery = useQuery(api.chamadas.obterChamada, { chamadaId });
|
||||||
const chamada = $derived(chamadaQuery?.data);
|
let chamada = $derived(chamadaQuery?.data);
|
||||||
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
||||||
const configJitsiBackend = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
const configJitsiBackend = useQuery(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
|
||||||
// Estado derivado do store
|
// Estado derivado do store
|
||||||
const estadoChamada = $derived(get(callState));
|
let estadoChamada = $derived(get(callState));
|
||||||
|
|
||||||
// Configuração Jitsi (busca do backend primeiro, depois fallback para env vars)
|
// Configuração Jitsi (busca do backend primeiro, depois fallback para env vars)
|
||||||
const configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null));
|
let configJitsi = $derived.by(() => obterConfiguracaoJitsi(configJitsiBackend?.data || null));
|
||||||
|
|
||||||
// Handler de erro melhorado
|
// Handler de erro melhorado
|
||||||
function handleError(message: string, details?: string, podeReconectar: boolean = false): void {
|
function handleError(message: string, details?: string, podeReconectar: boolean = false): void {
|
||||||
@@ -121,11 +114,18 @@
|
|||||||
// Adicionar sugestões de solução baseadas no tipo de erro
|
// Adicionar sugestões de solução baseadas no tipo de erro
|
||||||
let sugestoes = '';
|
let sugestoes = '';
|
||||||
if (message.includes('conectar') || message.includes('servidor')) {
|
if (message.includes('conectar') || message.includes('servidor')) {
|
||||||
sugestoes = '\n\nSugestões:\n• Verifique sua conexão com a internet\n• Verifique se o servidor Jitsi está acessível\n• Tente recarregar a página';
|
sugestoes =
|
||||||
} else if (message.includes('permissão') || message.includes('microfone') || message.includes('câmera')) {
|
'\n\nSugestões:\n• Verifique sua conexão com a internet\n• Verifique se o servidor Jitsi está acessível\n• Tente recarregar a página';
|
||||||
sugestoes = '\n\nSugestões:\n• Verifique as permissões do navegador para microfone e câmera\n• Certifique-se de que nenhum outro aplicativo está usando os dispositivos\n• Tente recarregar a página e permitir novamente';
|
} else if (
|
||||||
|
message.includes('permissão') ||
|
||||||
|
message.includes('microfone') ||
|
||||||
|
message.includes('câmera')
|
||||||
|
) {
|
||||||
|
sugestoes =
|
||||||
|
'\n\nSugestões:\n• Verifique as permissões do navegador para microfone e câmera\n• Certifique-se de que nenhum outro aplicativo está usando os dispositivos\n• Tente recarregar a página e permitir novamente';
|
||||||
} else if (message.includes('certificado') || message.includes('SSL')) {
|
} else if (message.includes('certificado') || message.includes('SSL')) {
|
||||||
sugestoes = '\n\nSugestões:\n• Se estiver em desenvolvimento local, aceite o certificado autoassinado\n• Verifique as configurações de segurança do navegador';
|
sugestoes =
|
||||||
|
'\n\nSugestões:\n• Se estiver em desenvolvimento local, aceite o certificado autoassinado\n• Verifique as configurações de segurança do navegador';
|
||||||
}
|
}
|
||||||
|
|
||||||
errorDetails = (details || erroTraduzido.instrucoes) + sugestoes;
|
errorDetails = (details || erroTraduzido.instrucoes) + sugestoes;
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
this.parts = [];
|
this.parts = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
BlobBuilderPolyfill.prototype.append = function(data: Blob | string) {
|
BlobBuilderPolyfill.prototype.append = function (data: Blob | string) {
|
||||||
if (data instanceof Blob) {
|
if (data instanceof Blob) {
|
||||||
this.parts.push(data);
|
this.parts.push(data);
|
||||||
} else if (typeof data === 'string') {
|
} else if (typeof data === 'string') {
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
BlobBuilderPolyfill.prototype.getBlob = function(contentType?: string) {
|
BlobBuilderPolyfill.prototype.getBlob = function (contentType?: string) {
|
||||||
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
return new Blob(this.parts, contentType ? { type: contentType } : undefined);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -209,18 +209,21 @@
|
|||||||
try {
|
try {
|
||||||
module = await import('lib-jitsi-meet');
|
module = await import('lib-jitsi-meet');
|
||||||
} catch (importError) {
|
} catch (importError) {
|
||||||
const importErrorMessage = importError instanceof Error ? importError.message : String(importError);
|
const importErrorMessage =
|
||||||
|
importError instanceof Error ? importError.message : String(importError);
|
||||||
console.error('❌ Erro ao importar lib-jitsi-meet:', importError);
|
console.error('❌ Erro ao importar lib-jitsi-meet:', importError);
|
||||||
|
|
||||||
// Verificar se é um erro de módulo não encontrado
|
// Verificar se é um erro de módulo não encontrado
|
||||||
if (importErrorMessage.includes('Failed to fetch') ||
|
if (
|
||||||
importErrorMessage.includes('Cannot find module') ||
|
importErrorMessage.includes('Failed to fetch') ||
|
||||||
importErrorMessage.includes('Failed to resolve') ||
|
importErrorMessage.includes('Cannot find module') ||
|
||||||
importErrorMessage.includes('Dynamic import')) {
|
importErrorMessage.includes('Failed to resolve') ||
|
||||||
|
importErrorMessage.includes('Dynamic import')
|
||||||
|
) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'A biblioteca Jitsi não pôde ser carregada. ' +
|
'A biblioteca Jitsi não pôde ser carregada. ' +
|
||||||
'Verifique se o pacote "lib-jitsi-meet" está instalado corretamente. ' +
|
'Verifique se o pacote "lib-jitsi-meet" está instalado corretamente. ' +
|
||||||
'Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
|
'Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw importError;
|
throw importError;
|
||||||
@@ -259,7 +262,7 @@
|
|||||||
if (!jitsiModule) {
|
if (!jitsiModule) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Não foi possível encontrar JitsiMeetJS no módulo. ' +
|
'Não foi possível encontrar JitsiMeetJS no módulo. ' +
|
||||||
'Verifique se lib-jitsi-meet está instalado corretamente.'
|
'Verifique se lib-jitsi-meet está instalado corretamente.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,11 +308,13 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Verificar se é um erro de módulo não encontrado
|
// Verificar se é um erro de módulo não encontrado
|
||||||
if (errorMessage.includes('Failed to fetch') ||
|
if (
|
||||||
errorMessage.includes('Cannot find module') ||
|
errorMessage.includes('Failed to fetch') ||
|
||||||
errorMessage.includes('Failed to resolve') ||
|
errorMessage.includes('Cannot find module') ||
|
||||||
errorMessage.includes('Dynamic import') ||
|
errorMessage.includes('Failed to resolve') ||
|
||||||
errorMessage.includes('biblioteca Jitsi não pôde ser carregada')) {
|
errorMessage.includes('Dynamic import') ||
|
||||||
|
errorMessage.includes('biblioteca Jitsi não pôde ser carregada')
|
||||||
|
) {
|
||||||
handleError(
|
handleError(
|
||||||
'Biblioteca de vídeo não encontrada',
|
'Biblioteca de vídeo não encontrada',
|
||||||
'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
|
'A biblioteca Jitsi não pôde ser encontrada. Verifique se o pacote "lib-jitsi-meet" está instalado. Se o problema persistir, tente limpar o cache do navegador e recarregar a página.'
|
||||||
@@ -332,7 +337,7 @@
|
|||||||
tipo,
|
tipo,
|
||||||
roomName,
|
roomName,
|
||||||
ehAnfitriao,
|
ehAnfitriao,
|
||||||
chamada.participantes.map(pId => ({
|
chamada.participantes.map((pId) => ({
|
||||||
usuarioId: pId,
|
usuarioId: pId,
|
||||||
nome: 'Participante', // Será atualizado depois
|
nome: 'Participante', // Será atualizado depois
|
||||||
audioHabilitado: true,
|
audioHabilitado: true,
|
||||||
@@ -358,10 +363,51 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = configJitsi();
|
const config = configJitsi;
|
||||||
|
if (!config) {
|
||||||
|
handleError(
|
||||||
|
'Configuração Jitsi não encontrada',
|
||||||
|
'Não foi possível obter a configuração do Jitsi. Verifique as configurações no painel de administração.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const { host, porta } = obterHostEPorta(config.domain);
|
const { host, porta } = obterHostEPorta(config.domain);
|
||||||
const protocol = config.useHttps ? 'https' : 'http';
|
const protocol = config.useHttps ? 'https' : 'http';
|
||||||
|
|
||||||
|
// Buscar token JWT se configurado
|
||||||
|
let jwtToken: string | null = null;
|
||||||
|
try {
|
||||||
|
const tokenResult = await client.action(api.configuracaoJitsi.gerarTokenJitsi, {
|
||||||
|
roomName,
|
||||||
|
conversaId,
|
||||||
|
chamadaId,
|
||||||
|
ambiente: config.ambiente || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tokenResult.sucesso) {
|
||||||
|
jwtToken = tokenResult.token;
|
||||||
|
console.log('✅ Token JWT gerado com sucesso');
|
||||||
|
} else {
|
||||||
|
console.warn('⚠️ Não foi possível gerar token JWT:', tokenResult.erro);
|
||||||
|
// Se JWT está configurado mas falhou, mostrar erro
|
||||||
|
if (tokenResult.erro?.includes('JWT Secret não configurado')) {
|
||||||
|
// JWT não está configurado, continuar sem token
|
||||||
|
console.log('ℹ️ JWT não configurado, continuando sem autenticação');
|
||||||
|
} else {
|
||||||
|
// Outro erro (permissão, etc.)
|
||||||
|
handleError(
|
||||||
|
'Erro ao gerar token de autenticação',
|
||||||
|
`Não foi possível gerar o token de autenticação: ${tokenResult.erro}. Verifique as configurações do Jitsi.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
console.warn('⚠️ Erro ao buscar token JWT (continuando sem token):', errorMessage);
|
||||||
|
// Continuar sem token se não for crítico
|
||||||
|
}
|
||||||
|
|
||||||
// Configuração conforme documentação oficial do Jitsi Meet
|
// Configuração conforme documentação oficial do Jitsi Meet
|
||||||
// https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api/
|
// https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api/
|
||||||
const baseUrl = `${protocol}://${host}${porta && porta !== (config.useHttps ? 443 : 80) ? `:${porta}` : ''}`;
|
const baseUrl = `${protocol}://${host}${porta && porta !== (config.useHttps ? 443 : 80) ? `:${porta}` : ''}`;
|
||||||
@@ -370,9 +416,10 @@
|
|||||||
// Determinar MUC baseado no host
|
// Determinar MUC baseado no host
|
||||||
// Para localhost, usar conference.localhost
|
// Para localhost, usar conference.localhost
|
||||||
// Para domínios reais, usar conference.{host}
|
// Para domínios reais, usar conference.{host}
|
||||||
const mucDomain = host === 'localhost' || host.startsWith('127.0.0.1')
|
const mucDomain =
|
||||||
? `conference.${host}`
|
host === 'localhost' || host.startsWith('127.0.0.1')
|
||||||
: `conference.${host}`;
|
? `conference.${host}`
|
||||||
|
: `conference.${host}`;
|
||||||
|
|
||||||
const options: JitsiConnectionOptions = {
|
const options: JitsiConnectionOptions = {
|
||||||
hosts: {
|
hosts: {
|
||||||
@@ -409,99 +456,167 @@
|
|||||||
baseUrl,
|
baseUrl,
|
||||||
serviceUrl: options.serviceUrl,
|
serviceUrl: options.serviceUrl,
|
||||||
muc: options.hosts?.muc,
|
muc: options.hosts?.muc,
|
||||||
focus: options.hosts?.focus
|
focus: options.hosts?.focus,
|
||||||
|
usandoJWT: jwtToken !== null
|
||||||
});
|
});
|
||||||
|
|
||||||
const connection = new JitsiMeetJS.JitsiConnection(null, null, options);
|
// Criar conexão com token JWT (se disponível)
|
||||||
|
// Segundo parâmetro é o token JWT, primeiro é appId (pode ser null se usar token)
|
||||||
|
const connection = new JitsiMeetJS.JitsiConnection(
|
||||||
|
jwtToken ? null : config.appId, // App ID (null se usar token)
|
||||||
|
jwtToken, // Token JWT
|
||||||
|
options
|
||||||
|
);
|
||||||
jitsiConnection = connection;
|
jitsiConnection = connection;
|
||||||
setJitsiApi(connection);
|
setJitsiApi(connection);
|
||||||
|
|
||||||
// Eventos de conexão
|
// Eventos de conexão
|
||||||
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_ESTABLISHED, () => {
|
connection.addEventListener(
|
||||||
console.log('✅ Conexão estabelecida');
|
JitsiMeetJS.constants.events.connection.CONNECTION_ESTABLISHED,
|
||||||
atualizarStatusConexao(true);
|
() => {
|
||||||
tentativasReconexao = 0; // Resetar contador de tentativas
|
console.log('✅ Conexão estabelecida');
|
||||||
reconectando = false;
|
atualizarStatusConexao(true);
|
||||||
qualidadeConexao = 'boa'; // Inicial como boa
|
tentativasReconexao = 0; // Resetar contador de tentativas
|
||||||
|
|
||||||
// Iniciar chamada no backend
|
|
||||||
client.mutation(api.chamadas.iniciarChamada, { chamadaId });
|
|
||||||
|
|
||||||
// Criar conferência com opções recomendadas pela documentação oficial
|
|
||||||
const estadoAtual = get(callState);
|
|
||||||
const conferenceOptions: Record<string, unknown> = {
|
|
||||||
startAudioMuted: !estadoAtual.audioHabilitado,
|
|
||||||
startVideoMuted: !estadoAtual.videoHabilitado,
|
|
||||||
// Opções de P2P (peer-to-peer) para melhor performance
|
|
||||||
p2p: {
|
|
||||||
enabled: true,
|
|
||||||
stunServers: [
|
|
||||||
{ urls: 'stun:stun.l.google.com:19302' },
|
|
||||||
{ urls: 'stun:stun1.l.google.com:19302' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
// Configurações de qualidade de vídeo
|
|
||||||
resolution: 720,
|
|
||||||
maxBitrate: 2500000, // 2.5 Mbps
|
|
||||||
// Configurações de áudio
|
|
||||||
audioQuality: {
|
|
||||||
stereo: false,
|
|
||||||
opusMaxAverageBitrate: 64000
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const conference = connection.initJitsiConference(roomName, conferenceOptions);
|
|
||||||
jitsiConference = conference;
|
|
||||||
setJitsiApi(conference);
|
|
||||||
|
|
||||||
// Eventos da conferência
|
|
||||||
configurarEventosConferencia(conference);
|
|
||||||
|
|
||||||
// Entrar na conferência
|
|
||||||
conference.join();
|
|
||||||
});
|
|
||||||
|
|
||||||
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_FAILED, (error: unknown) => {
|
|
||||||
console.error('❌ Falha na conexão:', error);
|
|
||||||
atualizarStatusConexao(false);
|
|
||||||
|
|
||||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
|
||||||
|
|
||||||
// Tentar reconectar se ainda houver tentativas
|
|
||||||
if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO) {
|
|
||||||
tentativasReconexao++;
|
|
||||||
reconectando = true;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log(`🔄 Tentativa de reconexão ${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO}...`);
|
|
||||||
connection.connect();
|
|
||||||
}, 2000 * tentativasReconexao); // Backoff exponencial
|
|
||||||
} else {
|
|
||||||
reconectando = false;
|
reconectando = false;
|
||||||
handleError(
|
qualidadeConexao = 'boa'; // Inicial como boa
|
||||||
'Erro ao conectar com servidor de vídeo',
|
|
||||||
`Não foi possível conectar ao servidor Jitsi após ${MAX_TENTATIVAS_RECONEXAO} tentativas.\n\nErro: ${errorMsg}`,
|
// Iniciar chamada no backend
|
||||||
false
|
client.mutation(api.chamadas.iniciarChamada, { chamadaId });
|
||||||
);
|
|
||||||
|
// Criar conferência com opções recomendadas pela documentação oficial
|
||||||
|
const estadoAtual = get(callState);
|
||||||
|
const conferenceOptions: Record<string, unknown> = {
|
||||||
|
startAudioMuted: !estadoAtual.audioHabilitado,
|
||||||
|
startVideoMuted: !estadoAtual.videoHabilitado,
|
||||||
|
// Opções de P2P (peer-to-peer) para melhor performance
|
||||||
|
p2p: {
|
||||||
|
enabled: true,
|
||||||
|
stunServers: [
|
||||||
|
{ urls: 'stun:stun.l.google.com:19302' },
|
||||||
|
{ urls: 'stun:stun1.l.google.com:19302' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
// Configurações de qualidade de vídeo
|
||||||
|
resolution: 720,
|
||||||
|
maxBitrate: 2500000, // 2.5 Mbps
|
||||||
|
// Configurações de áudio
|
||||||
|
audioQuality: {
|
||||||
|
stereo: false,
|
||||||
|
opusMaxAverageBitrate: 64000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const conference = connection.initJitsiConference(roomName, conferenceOptions);
|
||||||
|
jitsiConference = conference;
|
||||||
|
setJitsiApi(conference);
|
||||||
|
|
||||||
|
// Eventos da conferência
|
||||||
|
configurarEventosConferencia(conference);
|
||||||
|
|
||||||
|
// Entrar na conferência
|
||||||
|
conference.join();
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
connection.addEventListener(JitsiMeetJS.constants.events.connection.CONNECTION_DISCONNECTED, () => {
|
connection.addEventListener(
|
||||||
console.log('🔌 Conexão desconectada');
|
JitsiMeetJS.constants.events.connection.CONNECTION_FAILED,
|
||||||
atualizarStatusConexao(false);
|
(error: unknown) => {
|
||||||
qualidadeConexao = 'desconhecida';
|
console.error('❌ Falha na conexão:', error);
|
||||||
|
atualizarStatusConexao(false);
|
||||||
|
|
||||||
// Tentar reconectar automaticamente se não foi intencional
|
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||||
if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO && !reconectando) {
|
const errorStr = String(error).toLowerCase();
|
||||||
tentativasReconexao++;
|
|
||||||
reconectando = true;
|
|
||||||
|
|
||||||
setTimeout(() => {
|
// Verificar se é erro relacionado a JWT
|
||||||
console.log(`🔄 Tentando reconectar após desconexão (${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO})...`);
|
const isJWTError =
|
||||||
connection.connect();
|
errorStr.includes('jwt') ||
|
||||||
}, 3000);
|
errorStr.includes('token') ||
|
||||||
|
errorStr.includes('authentication') ||
|
||||||
|
errorStr.includes('unauthorized') ||
|
||||||
|
errorStr.includes('forbidden') ||
|
||||||
|
errorMsg.includes('401') ||
|
||||||
|
errorMsg.includes('403');
|
||||||
|
|
||||||
|
if (isJWTError) {
|
||||||
|
reconectando = false;
|
||||||
|
handleError(
|
||||||
|
'Erro de autenticação JWT',
|
||||||
|
`Falha na autenticação com o servidor Jitsi. Isso pode ocorrer se:\n\n` +
|
||||||
|
`• O token JWT está expirado ou inválido\n` +
|
||||||
|
`• O JWT Secret está incorreto nas configurações\n` +
|
||||||
|
`• Você não tem permissão para acessar esta sala\n\n` +
|
||||||
|
`Verifique as configurações do Jitsi no painel de administração.\n\nErro: ${errorMsg}`,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se é erro de servidor inacessível
|
||||||
|
const isServerError =
|
||||||
|
errorStr.includes('network') ||
|
||||||
|
errorStr.includes('timeout') ||
|
||||||
|
errorStr.includes('connection refused') ||
|
||||||
|
errorStr.includes('dns') ||
|
||||||
|
errorMsg.includes('ECONNREFUSED') ||
|
||||||
|
errorMsg.includes('ENOTFOUND');
|
||||||
|
|
||||||
|
if (isServerError) {
|
||||||
|
reconectando = false;
|
||||||
|
handleError(
|
||||||
|
'Servidor Jitsi inacessível',
|
||||||
|
`Não foi possível conectar ao servidor Jitsi. Verifique:\n\n` +
|
||||||
|
`• Se o domínio está correto nas configurações\n` +
|
||||||
|
`• Se o servidor está online e acessível\n` +
|
||||||
|
`• Se há problemas de firewall ou rede\n\n` +
|
||||||
|
`Erro: ${errorMsg}`,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tentar reconectar se ainda houver tentativas
|
||||||
|
if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO) {
|
||||||
|
tentativasReconexao++;
|
||||||
|
reconectando = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`🔄 Tentativa de reconexão ${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO}...`
|
||||||
|
);
|
||||||
|
connection.connect();
|
||||||
|
}, 2000 * tentativasReconexao); // Backoff exponencial
|
||||||
|
} else {
|
||||||
|
reconectando = false;
|
||||||
|
handleError(
|
||||||
|
'Erro ao conectar com servidor de vídeo',
|
||||||
|
`Não foi possível conectar ao servidor Jitsi após ${MAX_TENTATIVAS_RECONEXAO} tentativas.\n\nErro: ${errorMsg}`,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
|
connection.addEventListener(
|
||||||
|
JitsiMeetJS.constants.events.connection.CONNECTION_DISCONNECTED,
|
||||||
|
() => {
|
||||||
|
console.log('🔌 Conexão desconectada');
|
||||||
|
atualizarStatusConexao(false);
|
||||||
|
qualidadeConexao = 'desconhecida';
|
||||||
|
|
||||||
|
// Tentar reconectar automaticamente se não foi intencional
|
||||||
|
if (tentativasReconexao < MAX_TENTATIVAS_RECONEXAO && !reconectando) {
|
||||||
|
tentativasReconexao++;
|
||||||
|
reconectando = true;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log(
|
||||||
|
`🔄 Tentando reconectar após desconexão (${tentativasReconexao}/${MAX_TENTATIVAS_RECONEXAO})...`
|
||||||
|
);
|
||||||
|
connection.connect();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Conectar
|
// Conectar
|
||||||
console.log('🔄 Tentando conectar ao servidor Jitsi...');
|
console.log('🔄 Tentando conectar ao servidor Jitsi...');
|
||||||
@@ -517,22 +632,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Configurar eventos da conferência
|
// Configurar eventos da conferência
|
||||||
function configurarEventosConferencia(
|
function configurarEventosConferencia(conference: JitsiConference): void {
|
||||||
conference: JitsiConference
|
|
||||||
): void {
|
|
||||||
if (!JitsiMeetJS) return;
|
if (!JitsiMeetJS) return;
|
||||||
|
|
||||||
// Participante entrou
|
// Participante entrou
|
||||||
conference.on(JitsiMeetJS.constants.events.conference.USER_JOINED, (id: unknown, user: unknown) => {
|
conference.on(
|
||||||
console.log('👤 Participante entrou:', id, user);
|
JitsiMeetJS.constants.events.conference.USER_JOINED,
|
||||||
// Atualizar lista de participantes
|
(id: unknown, user: unknown) => {
|
||||||
atualizarListaParticipantes().then(() => {
|
console.log('👤 Participante entrou:', id, user);
|
||||||
// Atualizar nomes nos overlays
|
// Atualizar lista de participantes
|
||||||
if (typeof id === 'string') {
|
atualizarListaParticipantes().then(() => {
|
||||||
atualizarNomeParticipante(id);
|
// Atualizar nomes nos overlays
|
||||||
}
|
if (typeof id === 'string') {
|
||||||
});
|
atualizarNomeParticipante(id);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Participante saiu
|
// Participante saiu
|
||||||
conference.on(JitsiMeetJS.constants.events.conference.USER_LEFT, (id: unknown) => {
|
conference.on(JitsiMeetJS.constants.events.conference.USER_LEFT, (id: unknown) => {
|
||||||
@@ -554,31 +670,30 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Novo track remoto
|
// Novo track remoto
|
||||||
conference.on(
|
conference.on(JitsiMeetJS.constants.events.conference.TRACK_ADDED, (track: unknown) => {
|
||||||
JitsiMeetJS.constants.events.conference.TRACK_ADDED,
|
const jitsiTrack = track as JitsiTrack;
|
||||||
(track: unknown) => {
|
console.log('📹 Track adicionado:', jitsiTrack);
|
||||||
const jitsiTrack = track as JitsiTrack;
|
adicionarTrackRemoto(jitsiTrack);
|
||||||
console.log('📹 Track adicionado:', jitsiTrack);
|
});
|
||||||
adicionarTrackRemoto(jitsiTrack);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Track removido
|
// Track removido
|
||||||
conference.on(
|
conference.on(JitsiMeetJS.constants.events.conference.TRACK_REMOVED, (track: unknown) => {
|
||||||
JitsiMeetJS.constants.events.conference.TRACK_REMOVED,
|
const jitsiTrack = track as JitsiTrack;
|
||||||
(track: unknown) => {
|
console.log('📹 Track removido:', jitsiTrack);
|
||||||
const jitsiTrack = track as JitsiTrack;
|
removerTrackRemoto(jitsiTrack);
|
||||||
console.log('📹 Track removido:', jitsiTrack);
|
});
|
||||||
removerTrackRemoto(jitsiTrack);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Monitorar qualidade de conexão (se disponível)
|
// Monitorar qualidade de conexão (se disponível)
|
||||||
if ('getConnectionQuality' in conference && typeof conference.getConnectionQuality === 'function') {
|
if (
|
||||||
|
'getConnectionQuality' in conference &&
|
||||||
|
typeof conference.getConnectionQuality === 'function'
|
||||||
|
) {
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
try {
|
try {
|
||||||
// Tentar obter estatísticas de conexão
|
// Tentar obter estatísticas de conexão
|
||||||
const stats = (conference as unknown as { getConnectionQuality(): string }).getConnectionQuality();
|
const stats = (
|
||||||
|
conference as unknown as { getConnectionQuality(): string }
|
||||||
|
).getConnectionQuality();
|
||||||
if (stats === 'high' || stats === 'veryhigh') {
|
if (stats === 'high' || stats === 'veryhigh') {
|
||||||
qualidadeConexao = 'excelente';
|
qualidadeConexao = 'excelente';
|
||||||
} else if (stats === 'medium') {
|
} else if (stats === 'medium') {
|
||||||
@@ -610,16 +725,20 @@
|
|||||||
audio?: boolean | Record<string, unknown>;
|
audio?: boolean | Record<string, unknown>;
|
||||||
video?: boolean | Record<string, unknown>;
|
video?: boolean | Record<string, unknown>;
|
||||||
} = {
|
} = {
|
||||||
audio: estadoAtual.audioHabilitado ? {
|
audio: estadoAtual.audioHabilitado
|
||||||
echoCancellation: true,
|
? {
|
||||||
noiseSuppression: true,
|
echoCancellation: true,
|
||||||
autoGainControl: true
|
noiseSuppression: true,
|
||||||
} : false,
|
autoGainControl: true
|
||||||
video: estadoAtual.videoHabilitado ? {
|
}
|
||||||
facingMode: 'user',
|
: false,
|
||||||
width: { ideal: 1280 },
|
video: estadoAtual.videoHabilitado
|
||||||
height: { ideal: 720 }
|
? {
|
||||||
} : false
|
facingMode: 'user',
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
: false
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log('📹 Criando tracks locais com constraints:', constraints);
|
console.log('📹 Criando tracks locais com constraints:', constraints);
|
||||||
@@ -653,7 +772,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Criar MediaStream para gravação (se necessário)
|
// Criar MediaStream para gravação (se necessário)
|
||||||
const stream = new MediaStream(tracks.map(t => t.track));
|
const stream = new MediaStream(tracks.map((t) => t.track));
|
||||||
setStreamLocal(stream);
|
setStreamLocal(stream);
|
||||||
|
|
||||||
// Definir nome do display
|
// Definir nome do display
|
||||||
@@ -684,8 +803,8 @@
|
|||||||
console.log('🚪 Conferência finalizada');
|
console.log('🚪 Conferência finalizada');
|
||||||
|
|
||||||
// Limpar tracks locais
|
// Limpar tracks locais
|
||||||
localTracks.forEach(track => {
|
localTracks.forEach((track) => {
|
||||||
track.dispose().catch(err => console.error('Erro ao liberar track:', err));
|
track.dispose().catch((err) => console.error('Erro ao liberar track:', err));
|
||||||
});
|
});
|
||||||
localTracks = [];
|
localTracks = [];
|
||||||
|
|
||||||
@@ -710,7 +829,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Mapa para rastrear elementos de vídeo remotos
|
// Mapa para rastrear elementos de vídeo remotos
|
||||||
let remoteVideoElements = $state<Map<string, { element: HTMLElement; track: JitsiTrack }>>(new Map());
|
let remoteVideoElements = $state<Map<string, { element: HTMLElement; track: JitsiTrack }>>(
|
||||||
|
new Map()
|
||||||
|
);
|
||||||
let remoteAudioElements = $state<Map<string, HTMLAudioElement>>(new Map());
|
let remoteAudioElements = $state<Map<string, HTMLAudioElement>>(new Map());
|
||||||
|
|
||||||
// Adicionar track remoto ao container
|
// Adicionar track remoto ao container
|
||||||
@@ -749,7 +870,8 @@
|
|||||||
// Criar container para o vídeo com indicadores
|
// Criar container para o vídeo com indicadores
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = `remote-video-container-${participantId}`;
|
container.id = `remote-video-container-${participantId}`;
|
||||||
container.className = 'relative aspect-video w-full rounded-lg bg-base-200 overflow-hidden shadow-lg transition-all duration-300';
|
container.className =
|
||||||
|
'relative aspect-video w-full rounded-lg bg-base-200 overflow-hidden shadow-lg transition-all duration-300';
|
||||||
|
|
||||||
// Criar elemento de vídeo
|
// Criar elemento de vídeo
|
||||||
const videoElement = document.createElement('video');
|
const videoElement = document.createElement('video');
|
||||||
@@ -760,7 +882,8 @@
|
|||||||
|
|
||||||
// Criar overlay com nome do participante
|
// Criar overlay com nome do participante
|
||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
overlay.className = 'absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2';
|
overlay.className =
|
||||||
|
'absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2';
|
||||||
|
|
||||||
const nomeParticipante = document.createElement('div');
|
const nomeParticipante = document.createElement('div');
|
||||||
nomeParticipante.className = 'text-white text-sm font-medium';
|
nomeParticipante.className = 'text-white text-sm font-medium';
|
||||||
@@ -774,13 +897,15 @@
|
|||||||
const audioIndicator = document.createElement('div');
|
const audioIndicator = document.createElement('div');
|
||||||
audioIndicator.id = `audio-indicator-${participantId}`;
|
audioIndicator.id = `audio-indicator-${participantId}`;
|
||||||
audioIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1';
|
audioIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1';
|
||||||
audioIndicator.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/></svg>';
|
audioIndicator.innerHTML =
|
||||||
|
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/></svg>';
|
||||||
|
|
||||||
// Indicador de vídeo
|
// Indicador de vídeo
|
||||||
const videoIndicator = document.createElement('div');
|
const videoIndicator = document.createElement('div');
|
||||||
videoIndicator.id = `video-indicator-${participantId}`;
|
videoIndicator.id = `video-indicator-${participantId}`;
|
||||||
videoIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1';
|
videoIndicator.className = 'bg-base-300 opacity-80 rounded-full p-1';
|
||||||
videoIndicator.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/></svg>';
|
videoIndicator.innerHTML =
|
||||||
|
'<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/></svg>';
|
||||||
|
|
||||||
indicators.appendChild(audioIndicator);
|
indicators.appendChild(audioIndicator);
|
||||||
indicators.appendChild(videoIndicator);
|
indicators.appendChild(videoIndicator);
|
||||||
@@ -811,9 +936,7 @@
|
|||||||
if (!nomeElement) return;
|
if (!nomeElement) return;
|
||||||
|
|
||||||
// Buscar nome do participante no estado
|
// Buscar nome do participante no estado
|
||||||
const participante = estadoChamada.participantes.find(
|
const participante = estadoChamada.participantes.find((p) => p.participantId === participantId);
|
||||||
p => p.participantId === participantId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (participante) {
|
if (participante) {
|
||||||
nomeElement.textContent = participante.nome;
|
nomeElement.textContent = participante.nome;
|
||||||
@@ -825,9 +948,7 @@
|
|||||||
const container = remoteVideoElements.get(participantId);
|
const container = remoteVideoElements.get(participantId);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const participante = estadoChamada.participantes.find(
|
const participante = estadoChamada.participantes.find((p) => p.participantId === participantId);
|
||||||
p => p.participantId === participantId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!participante) return;
|
if (!participante) return;
|
||||||
|
|
||||||
@@ -905,7 +1026,7 @@
|
|||||||
setAudioHabilitado(novoEstadoAudio);
|
setAudioHabilitado(novoEstadoAudio);
|
||||||
|
|
||||||
// Atualizar track local
|
// Atualizar track local
|
||||||
const audioTrack = localTracks.find(t => t.getType() === 'audio');
|
const audioTrack = localTracks.find((t) => t.getType() === 'audio');
|
||||||
if (audioTrack) {
|
if (audioTrack) {
|
||||||
if (novoEstadoAudio) {
|
if (novoEstadoAudio) {
|
||||||
await audioTrack.unmute();
|
await audioTrack.unmute();
|
||||||
@@ -931,12 +1052,14 @@
|
|||||||
|
|
||||||
// Notificar backend se for anfitrião
|
// Notificar backend se for anfitrião
|
||||||
if (ehAnfitriao && meuPerfil?.data) {
|
if (ehAnfitriao && meuPerfil?.data) {
|
||||||
await client.mutation(api.chamadas.toggleAudioVideoParticipante, {
|
await client
|
||||||
chamadaId,
|
.mutation(api.chamadas.toggleAudioVideoParticipante, {
|
||||||
participanteId: meuPerfil.data._id,
|
chamadaId,
|
||||||
tipo: 'audio',
|
participanteId: meuPerfil.data._id,
|
||||||
habilitado: novoEstadoAudio
|
tipo: 'audio',
|
||||||
}).catch(err => console.error('Erro ao atualizar backend:', err));
|
habilitado: novoEstadoAudio
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Erro ao atualizar backend:', err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -950,7 +1073,7 @@
|
|||||||
setVideoHabilitado(novoEstadoVideo);
|
setVideoHabilitado(novoEstadoVideo);
|
||||||
|
|
||||||
// Atualizar track local
|
// Atualizar track local
|
||||||
const videoTrack = localTracks.find(t => t.getType() === 'video');
|
const videoTrack = localTracks.find((t) => t.getType() === 'video');
|
||||||
if (videoTrack) {
|
if (videoTrack) {
|
||||||
if (novoEstadoVideo) {
|
if (novoEstadoVideo) {
|
||||||
await videoTrack.unmute();
|
await videoTrack.unmute();
|
||||||
@@ -984,12 +1107,14 @@
|
|||||||
|
|
||||||
// Notificar backend se for anfitrião
|
// Notificar backend se for anfitrião
|
||||||
if (ehAnfitriao && meuPerfil?.data) {
|
if (ehAnfitriao && meuPerfil?.data) {
|
||||||
await client.mutation(api.chamadas.toggleAudioVideoParticipante, {
|
await client
|
||||||
chamadaId,
|
.mutation(api.chamadas.toggleAudioVideoParticipante, {
|
||||||
participanteId: meuPerfil.data._id,
|
chamadaId,
|
||||||
tipo: 'video',
|
participanteId: meuPerfil.data._id,
|
||||||
habilitado: novoEstadoVideo
|
tipo: 'video',
|
||||||
}).catch(err => console.error('Erro ao atualizar backend:', err));
|
habilitado: novoEstadoVideo
|
||||||
|
})
|
||||||
|
.catch((err) => console.error('Erro ao atualizar backend:', err));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1016,7 +1141,7 @@
|
|||||||
if (iniciou) {
|
if (iniciou) {
|
||||||
iniciarGravacaoStore();
|
iniciarGravacaoStore();
|
||||||
// Notificar backend
|
// Notificar backend
|
||||||
await client.mutation(api.chamadas.iniciarGravacao, { chamadaId }).catch(err => {
|
await client.mutation(api.chamadas.iniciarGravacao, { chamadaId }).catch((err) => {
|
||||||
console.error('Erro ao notificar backend sobre gravação:', err);
|
console.error('Erro ao notificar backend sobre gravação:', err);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -1045,7 +1170,7 @@
|
|||||||
gravador = null;
|
gravador = null;
|
||||||
|
|
||||||
// Notificar backend
|
// Notificar backend
|
||||||
await client.mutation(api.chamadas.finalizarGravacao, { chamadaId }).catch(err => {
|
await client.mutation(api.chamadas.finalizarGravacao, { chamadaId }).catch((err) => {
|
||||||
console.error('Erro ao notificar backend sobre finalização de gravação:', err);
|
console.error('Erro ao notificar backend sobre finalização de gravação:', err);
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1126,7 +1251,7 @@
|
|||||||
setStreamLocal(null);
|
setStreamLocal(null);
|
||||||
|
|
||||||
// Finalizar no backend
|
// Finalizar no backend
|
||||||
await client.mutation(api.chamadas.finalizarChamada, { chamadaId }).catch(err => {
|
await client.mutation(api.chamadas.finalizarChamada, { chamadaId }).catch((err) => {
|
||||||
console.error('Erro ao finalizar chamada no backend:', err);
|
console.error('Erro ao finalizar chamada no backend:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1285,7 +1410,7 @@
|
|||||||
{@const numColunas = totalVideos === 1 ? 1 : totalVideos <= 4 ? 2 : 3}
|
{@const numColunas = totalVideos === 1 ? 1 : totalVideos <= 4 ? 2 : 3}
|
||||||
<div
|
<div
|
||||||
bind:this={videoContainer}
|
bind:this={videoContainer}
|
||||||
class="bg-base-300 flex-1 gap-4 p-4 overflow-auto"
|
class="bg-base-300 flex-1 gap-4 overflow-auto p-4"
|
||||||
class:grid={usarGrid}
|
class:grid={usarGrid}
|
||||||
class:flex={!usarGrid}
|
class:flex={!usarGrid}
|
||||||
class:flex-wrap={!usarGrid}
|
class:flex-wrap={!usarGrid}
|
||||||
@@ -1293,73 +1418,88 @@
|
|||||||
class:grid-cols-2={usarGrid && numColunas === 2}
|
class:grid-cols-2={usarGrid && numColunas === 2}
|
||||||
class:grid-cols-3={usarGrid && numColunas === 3}
|
class:grid-cols-3={usarGrid && numColunas === 3}
|
||||||
>
|
>
|
||||||
{#if !estadoChamada.estaConectado}
|
{#if !estadoChamada.estaConectado}
|
||||||
<div class="flex h-full w-full items-center justify-center col-span-full">
|
<div class="col-span-full flex h-full w-full items-center justify-center">
|
||||||
<div class="text-center">
|
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
|
||||||
<p class="mt-4 text-lg font-medium">Conectando à chamada...</p>
|
|
||||||
<p class="mt-2 text-sm text-base-content/70">
|
|
||||||
Aguarde enquanto estabelecemos a conexão
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Vídeo Local -->
|
|
||||||
{#if tipo === 'video' && estadoChamada.videoHabilitado && localVideo}
|
|
||||||
<div class="relative aspect-video w-full rounded-lg bg-base-200 overflow-hidden shadow-lg transition-all duration-300">
|
|
||||||
<video
|
|
||||||
bind:this={localVideo}
|
|
||||||
autoplay
|
|
||||||
muted
|
|
||||||
playsinline
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
></video>
|
|
||||||
<!-- Overlay com nome local -->
|
|
||||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
|
||||||
<div class="text-white text-sm font-medium">
|
|
||||||
{meuPerfil?.data?.nome || 'Você'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- Indicadores locais -->
|
|
||||||
<div class="absolute top-2 right-2 flex gap-1">
|
|
||||||
<div
|
|
||||||
class="rounded-full p-1 opacity-80"
|
|
||||||
class:bg-success={estadoChamada.audioHabilitado}
|
|
||||||
class:bg-error={!estadoChamada.audioHabilitado}
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="rounded-full p-1 opacity-80"
|
|
||||||
class:bg-success={estadoChamada.videoHabilitado}
|
|
||||||
class:bg-error={!estadoChamada.videoHabilitado}
|
|
||||||
>
|
|
||||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else if tipo === 'audio'}
|
|
||||||
<!-- Placeholder para chamada de áudio -->
|
|
||||||
<div class="flex h-full w-full items-center justify-center col-span-full">
|
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<div class="bg-primary/20 rounded-full p-8 mx-auto w-32 h-32 flex items-center justify-center mb-4">
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
<svg class="w-16 h-16 text-primary" fill="currentColor" viewBox="0 0 20 20">
|
<p class="mt-4 text-lg font-medium">Conectando à chamada...</p>
|
||||||
<path d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"/>
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
</svg>
|
Aguarde enquanto estabelecemos a conexão
|
||||||
</div>
|
|
||||||
<p class="text-lg font-medium">Chamada de Áudio</p>
|
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
|
||||||
{estadoChamada.participantes.length} participante{estadoChamada.participantes.length !== 1 ? 's' : ''}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Vídeo Local -->
|
||||||
|
{#if tipo === 'video' && estadoChamada.videoHabilitado && localVideo}
|
||||||
|
<div
|
||||||
|
class="bg-base-200 relative aspect-video w-full overflow-hidden rounded-lg shadow-lg transition-all duration-300"
|
||||||
|
>
|
||||||
|
<video
|
||||||
|
bind:this={localVideo}
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
></video>
|
||||||
|
<!-- Overlay com nome local -->
|
||||||
|
<div
|
||||||
|
class="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/70 to-transparent p-2"
|
||||||
|
>
|
||||||
|
<div class="text-sm font-medium text-white">
|
||||||
|
{meuPerfil?.data?.nome || 'Você'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Indicadores locais -->
|
||||||
|
<div class="absolute top-2 right-2 flex gap-1">
|
||||||
|
<div
|
||||||
|
class="rounded-full p-1 opacity-80"
|
||||||
|
class:bg-success={estadoChamada.audioHabilitado}
|
||||||
|
class:bg-error={!estadoChamada.audioHabilitado}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-full p-1 opacity-80"
|
||||||
|
class:bg-success={estadoChamada.videoHabilitado}
|
||||||
|
class:bg-error={!estadoChamada.videoHabilitado}
|
||||||
|
>
|
||||||
|
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M2 6a2 2 0 012-2h6a2 2 0 012 2v8a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if tipo === 'audio'}
|
||||||
|
<!-- Placeholder para chamada de áudio -->
|
||||||
|
<div class="col-span-full flex h-full w-full items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<div
|
||||||
|
class="bg-primary/20 mx-auto mb-4 flex h-32 w-32 items-center justify-center rounded-full p-8"
|
||||||
|
>
|
||||||
|
<svg class="text-primary h-16 w-16" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.617.793L4.383 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.383l4-3.617a1 1 0 011.617.793zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-medium">Chamada de Áudio</p>
|
||||||
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
|
{estadoChamada.participantes.length} participante{estadoChamada.participantes
|
||||||
|
.length !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<!-- Vídeos remotos serão adicionados dinamicamente pelo JavaScript -->
|
||||||
{/if}
|
{/if}
|
||||||
<!-- Vídeos remotos serão adicionados dinamicamente pelo JavaScript -->
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -1383,7 +1523,7 @@
|
|||||||
audioHabilitado={estadoChamada.audioHabilitado}
|
audioHabilitado={estadoChamada.audioHabilitado}
|
||||||
videoHabilitado={estadoChamada.videoHabilitado}
|
videoHabilitado={estadoChamada.videoHabilitado}
|
||||||
gravando={estadoChamada.gravando}
|
gravando={estadoChamada.gravando}
|
||||||
ehAnfitriao={ehAnfitriao}
|
{ehAnfitriao}
|
||||||
duracaoSegundos={estadoChamada.duracaoSegundos}
|
duracaoSegundos={estadoChamada.duracaoSegundos}
|
||||||
onToggleAudio={handleToggleAudio}
|
onToggleAudio={handleToggleAudio}
|
||||||
onToggleVideo={handleToggleVideo}
|
onToggleVideo={handleToggleVideo}
|
||||||
@@ -1491,4 +1631,3 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Mic, MicOff, Video, VideoOff, User, Shield } from 'lucide-svelte';
|
|
||||||
import UserAvatar from '../chat/UserAvatar.svelte';
|
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
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 {
|
interface ParticipanteHost {
|
||||||
usuarioId: Id<'usuarios'>;
|
usuarioId: Id<'usuarios'>;
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
|
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { participantes, onToggleParticipanteAudio, onToggleParticipanteVideo }: Props = $props();
|
const { participantes, onToggleParticipanteAudio, onToggleParticipanteVideo }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="bg-base-200 border-base-300 flex flex-col border-t">
|
<div class="bg-base-200 border-base-300 flex flex-col border-t">
|
||||||
@@ -34,9 +34,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each participantes as participante}
|
{#each participantes as participante}
|
||||||
<div
|
<div class="bg-base-100 flex items-center justify-between rounded-lg p-3 shadow-sm">
|
||||||
class="bg-base-100 flex items-center justify-between rounded-lg p-3 shadow-sm"
|
|
||||||
>
|
|
||||||
<!-- Informações do participante -->
|
<!-- Informações do participante -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<UserAvatar usuarioId={participante.usuarioId} avatar={participante.avatar} />
|
<UserAvatar usuarioId={participante.usuarioId} avatar={participante.avatar} />
|
||||||
@@ -45,9 +43,7 @@
|
|||||||
{participante.nome}
|
{participante.nome}
|
||||||
</span>
|
</span>
|
||||||
{#if participante.forcadoPeloAnfitriao}
|
{#if participante.forcadoPeloAnfitriao}
|
||||||
<span class="text-base-content/60 text-xs">
|
<span class="text-base-content/60 text-xs"> Controlado pelo anfitrião </span>
|
||||||
Controlado pelo anfitrião
|
|
||||||
</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,16 +57,12 @@
|
|||||||
class:btn-primary={participante.audioHabilitado}
|
class:btn-primary={participante.audioHabilitado}
|
||||||
class:btn-error={!participante.audioHabilitado}
|
class:btn-error={!participante.audioHabilitado}
|
||||||
onclick={() => onToggleParticipanteAudio(participante.usuarioId)}
|
onclick={() => onToggleParticipanteAudio(participante.usuarioId)}
|
||||||
title={
|
title={participante.audioHabilitado
|
||||||
participante.audioHabilitado
|
? `Desabilitar áudio de ${participante.nome}`
|
||||||
? `Desabilitar áudio de ${participante.nome}`
|
: `Habilitar áudio de ${participante.nome}`}
|
||||||
: `Habilitar áudio de ${participante.nome}`
|
aria-label={participante.audioHabilitado
|
||||||
}
|
? `Desabilitar áudio de ${participante.nome}`
|
||||||
aria-label={
|
: `Habilitar áudio de ${participante.nome}`}
|
||||||
participante.audioHabilitado
|
|
||||||
? `Desabilitar áudio de ${participante.nome}`
|
|
||||||
: `Habilitar áudio de ${participante.nome}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{#if participante.audioHabilitado}
|
{#if participante.audioHabilitado}
|
||||||
<Mic class="h-3 w-3" />
|
<Mic class="h-3 w-3" />
|
||||||
@@ -86,16 +78,12 @@
|
|||||||
class:btn-primary={participante.videoHabilitado}
|
class:btn-primary={participante.videoHabilitado}
|
||||||
class:btn-error={!participante.videoHabilitado}
|
class:btn-error={!participante.videoHabilitado}
|
||||||
onclick={() => onToggleParticipanteVideo(participante.usuarioId)}
|
onclick={() => onToggleParticipanteVideo(participante.usuarioId)}
|
||||||
title={
|
title={participante.videoHabilitado
|
||||||
participante.videoHabilitado
|
? `Desabilitar vídeo de ${participante.nome}`
|
||||||
? `Desabilitar vídeo de ${participante.nome}`
|
: `Habilitar vídeo de ${participante.nome}`}
|
||||||
: `Habilitar vídeo de ${participante.nome}`
|
aria-label={participante.videoHabilitado
|
||||||
}
|
? `Desabilitar vídeo de ${participante.nome}`
|
||||||
aria-label={
|
: `Habilitar vídeo de ${participante.nome}`}
|
||||||
participante.videoHabilitado
|
|
||||||
? `Desabilitar vídeo de ${participante.nome}`
|
|
||||||
: `Habilitar vídeo de ${participante.nome}`
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{#if participante.videoHabilitado}
|
{#if participante.videoHabilitado}
|
||||||
<Video class="h-3 w-3" />
|
<Video class="h-3 w-3" />
|
||||||
@@ -109,5 +97,3 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
iniciadoPor?: string;
|
iniciadoPor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { gravando, iniciadoPor }: Props = $props();
|
const { gravando, iniciadoPor }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if gravando}
|
{#if gravando}
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<div class="animate-pulse">
|
<div class="animate-pulse">
|
||||||
<div class="h-3 w-3 rounded-full bg-error-content"></div>
|
<div class="bg-error-content h-3 w-3 rounded-full"></div>
|
||||||
</div>
|
</div>
|
||||||
<span>
|
<span>
|
||||||
{iniciadoPor ? `Gravando iniciada por ${iniciadoPor}` : 'Chamada está sendo gravada'}
|
{iniciadoPor ? `Gravando iniciada por ${iniciadoPor}` : 'Chamada está sendo gravada'}
|
||||||
|
|||||||
@@ -1,183 +1,182 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { Chart, registerables } from 'chart.js';
|
import { Chart, registerables } from 'chart.js';
|
||||||
|
|
||||||
Chart.register(...registerables);
|
Chart.register(...registerables);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
dadosSla: {
|
dadosSla: {
|
||||||
statusSla: {
|
statusSla: {
|
||||||
dentroPrazo: number;
|
dentroPrazo: number;
|
||||||
proximoVencimento: number;
|
proximoVencimento: number;
|
||||||
vencido: number;
|
vencido: number;
|
||||||
semPrazo: number;
|
semPrazo: number;
|
||||||
};
|
};
|
||||||
porPrioridade: {
|
porPrioridade: {
|
||||||
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
baixa: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
media: { 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 };
|
alta: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
critica: { dentroPrazo: number; proximoVencimento: number; vencido: number; total: number };
|
||||||
};
|
};
|
||||||
taxaCumprimento: number;
|
taxaCumprimento: number;
|
||||||
totalComPrazo: number;
|
totalComPrazo: number;
|
||||||
atualizadoEm: number;
|
atualizadoEm: number;
|
||||||
};
|
};
|
||||||
height?: number;
|
height?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
let { dadosSla, height = 400 }: Props = $props();
|
let { dadosSla, height = 400 }: Props = $props();
|
||||||
|
|
||||||
let canvas: HTMLCanvasElement;
|
let canvas: HTMLCanvasElement;
|
||||||
let chart: Chart | null = null;
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
function prepararDados() {
|
function prepararDados() {
|
||||||
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
|
const prioridades = ['Baixa', 'Média', 'Alta', 'Crítica'];
|
||||||
const cores = {
|
const cores = {
|
||||||
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
|
dentroPrazo: 'rgba(34, 197, 94, 0.8)', // verde
|
||||||
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
|
proximoVencimento: 'rgba(251, 191, 36, 0.8)', // amarelo
|
||||||
vencido: 'rgba(239, 68, 68, 0.8)', // vermelho
|
vencido: 'rgba(239, 68, 68, 0.8)' // vermelho
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
labels: prioridades,
|
labels: prioridades,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'Dentro do Prazo',
|
label: 'Dentro do Prazo',
|
||||||
data: [
|
data: [
|
||||||
dadosSla.porPrioridade.baixa.dentroPrazo,
|
dadosSla.porPrioridade.baixa.dentroPrazo,
|
||||||
dadosSla.porPrioridade.media.dentroPrazo,
|
dadosSla.porPrioridade.media.dentroPrazo,
|
||||||
dadosSla.porPrioridade.alta.dentroPrazo,
|
dadosSla.porPrioridade.alta.dentroPrazo,
|
||||||
dadosSla.porPrioridade.critica.dentroPrazo,
|
dadosSla.porPrioridade.critica.dentroPrazo
|
||||||
],
|
],
|
||||||
backgroundColor: cores.dentroPrazo,
|
backgroundColor: cores.dentroPrazo,
|
||||||
borderColor: 'rgba(34, 197, 94, 1)',
|
borderColor: 'rgba(34, 197, 94, 1)',
|
||||||
borderWidth: 2,
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Próximo ao Vencimento',
|
label: 'Próximo ao Vencimento',
|
||||||
data: [
|
data: [
|
||||||
dadosSla.porPrioridade.baixa.proximoVencimento,
|
dadosSla.porPrioridade.baixa.proximoVencimento,
|
||||||
dadosSla.porPrioridade.media.proximoVencimento,
|
dadosSla.porPrioridade.media.proximoVencimento,
|
||||||
dadosSla.porPrioridade.alta.proximoVencimento,
|
dadosSla.porPrioridade.alta.proximoVencimento,
|
||||||
dadosSla.porPrioridade.critica.proximoVencimento,
|
dadosSla.porPrioridade.critica.proximoVencimento
|
||||||
],
|
],
|
||||||
backgroundColor: cores.proximoVencimento,
|
backgroundColor: cores.proximoVencimento,
|
||||||
borderColor: 'rgba(251, 191, 36, 1)',
|
borderColor: 'rgba(251, 191, 36, 1)',
|
||||||
borderWidth: 2,
|
borderWidth: 2
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Vencido',
|
label: 'Vencido',
|
||||||
data: [
|
data: [
|
||||||
dadosSla.porPrioridade.baixa.vencido,
|
dadosSla.porPrioridade.baixa.vencido,
|
||||||
dadosSla.porPrioridade.media.vencido,
|
dadosSla.porPrioridade.media.vencido,
|
||||||
dadosSla.porPrioridade.alta.vencido,
|
dadosSla.porPrioridade.alta.vencido,
|
||||||
dadosSla.porPrioridade.critica.vencido,
|
dadosSla.porPrioridade.critica.vencido
|
||||||
],
|
],
|
||||||
backgroundColor: cores.vencido,
|
backgroundColor: cores.vencido,
|
||||||
borderColor: 'rgba(239, 68, 68, 1)',
|
borderColor: 'rgba(239, 68, 68, 1)',
|
||||||
borderWidth: 2,
|
borderWidth: 2
|
||||||
},
|
}
|
||||||
],
|
]
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if (canvas) {
|
if (canvas) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
const chartData = prepararDados();
|
const chartData = prepararDados();
|
||||||
chart = new Chart(ctx, {
|
chart = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: chartData,
|
data: chartData,
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
display: true,
|
display: true,
|
||||||
position: 'top',
|
position: 'top',
|
||||||
labels: {
|
labels: {
|
||||||
color: '#a6adbb',
|
color: '#a6adbb',
|
||||||
font: {
|
font: {
|
||||||
size: 12,
|
size: 12,
|
||||||
family: "'Inter', sans-serif",
|
family: "'Inter', sans-serif"
|
||||||
},
|
},
|
||||||
usePointStyle: true,
|
usePointStyle: true,
|
||||||
padding: 15,
|
padding: 15
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||||
titleColor: '#fff',
|
titleColor: '#fff',
|
||||||
bodyColor: '#fff',
|
bodyColor: '#fff',
|
||||||
borderColor: '#570df8',
|
borderColor: '#570df8',
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: function(context) {
|
label: function (context) {
|
||||||
const label = context.dataset.label || '';
|
const label = context.dataset.label || '';
|
||||||
const value = context.parsed.y;
|
const value = context.parsed.y;
|
||||||
const prioridade = context.label;
|
const prioridade = context.label;
|
||||||
return `${label}: ${value} chamado(s)`;
|
return `${label}: ${value} chamado(s)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
stacked: true,
|
stacked: true,
|
||||||
grid: {
|
grid: {
|
||||||
color: 'rgba(255, 255, 255, 0.05)',
|
color: 'rgba(255, 255, 255, 0.05)'
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#a6adbb',
|
color: '#a6adbb',
|
||||||
font: {
|
font: {
|
||||||
size: 11,
|
size: 11,
|
||||||
weight: '500',
|
weight: '500'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
stacked: true,
|
stacked: true,
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
grid: {
|
grid: {
|
||||||
color: 'rgba(255, 255, 255, 0.05)',
|
color: 'rgba(255, 255, 255, 0.05)'
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
color: '#a6adbb',
|
color: '#a6adbb',
|
||||||
font: {
|
font: {
|
||||||
size: 11,
|
size: 11
|
||||||
},
|
},
|
||||||
stepSize: 1,
|
stepSize: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
duration: 800,
|
duration: 800,
|
||||||
easing: 'easeInOutQuart'
|
easing: 'easeInOutQuart'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (chart && dadosSla) {
|
if (chart && dadosSla) {
|
||||||
const chartData = prepararDados();
|
const chartData = prepararDados();
|
||||||
chart.data = chartData;
|
chart.data = chartData;
|
||||||
chart.update('active');
|
chart.update('active');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy();
|
chart.destroy();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="height: {height}px; position: relative;">
|
<div style="height: {height}px; position: relative;">
|
||||||
<canvas bind:this={canvas}></canvas>
|
<canvas bind:this={canvas}></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,107 +1,106 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Doc, Id } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import {
|
import {
|
||||||
corPrazo,
|
corPrazo,
|
||||||
formatarData,
|
formatarData,
|
||||||
getStatusBadge,
|
getStatusBadge,
|
||||||
getStatusDescription,
|
getStatusDescription,
|
||||||
getStatusLabel,
|
getStatusLabel,
|
||||||
prazoRestante,
|
prazoRestante
|
||||||
} from "$lib/utils/chamados";
|
} from '$lib/utils/chamados';
|
||||||
import { createEventDispatcher } from "svelte";
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
type Ticket = Doc<"tickets">;
|
type Ticket = Doc<'tickets'>;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ticket: Ticket;
|
ticket: Ticket;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ select: { ticketId: Id<"tickets"> } }>();
|
const dispatch = createEventDispatcher<{ select: { ticketId: Id<'tickets'> } }>();
|
||||||
const props = $props<Props>();
|
const props: Props = $props();
|
||||||
const ticket = $derived(props.ticket);
|
const ticket = $derived(props.ticket);
|
||||||
const selected = $derived(props.selected ?? false);
|
const selected = $derived(props.selected ?? false);
|
||||||
|
|
||||||
const prioridadeClasses: Record<string, string> = {
|
const prioridadeClasses: Record<string, string> = {
|
||||||
baixa: "badge badge-sm bg-base-200 text-base-content/70",
|
baixa: 'badge badge-sm bg-base-200 text-base-content/70',
|
||||||
media: "badge badge-sm badge-info badge-outline",
|
media: 'badge badge-sm badge-info badge-outline',
|
||||||
alta: "badge badge-sm badge-warning",
|
alta: 'badge badge-sm badge-warning',
|
||||||
critica: "badge badge-sm badge-error",
|
critica: 'badge badge-sm badge-error'
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleSelect() {
|
function handleSelect() {
|
||||||
dispatch("select", { ticketId: ticket._id });
|
dispatch('select', { ticketId: ticket._id });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrazoBadges() {
|
function getPrazoBadges() {
|
||||||
const badges: Array<{ label: string; classe: string }> = [];
|
const badges: Array<{ label: string; classe: string }> = [];
|
||||||
if (ticket.prazoResposta) {
|
if (ticket.prazoResposta) {
|
||||||
const cor = corPrazo(ticket.prazoResposta);
|
const cor = corPrazo(ticket.prazoResposta);
|
||||||
badges.push({
|
badges.push({
|
||||||
label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ""}`,
|
label: `Resposta ${prazoRestante(ticket.prazoResposta) ?? ''}`,
|
||||||
classe: `badge badge-xs ${
|
classe: `badge badge-xs ${
|
||||||
cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success"
|
cor === 'error' ? 'badge-error' : cor === 'warning' ? 'badge-warning' : 'badge-success'
|
||||||
}`,
|
}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (ticket.prazoConclusao) {
|
if (ticket.prazoConclusao) {
|
||||||
const cor = corPrazo(ticket.prazoConclusao);
|
const cor = corPrazo(ticket.prazoConclusao);
|
||||||
badges.push({
|
badges.push({
|
||||||
label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ""}`,
|
label: `Conclusão ${prazoRestante(ticket.prazoConclusao) ?? ''}`,
|
||||||
classe: `badge badge-xs ${
|
classe: `badge badge-xs ${
|
||||||
cor === "error" ? "badge-error" : cor === "warning" ? "badge-warning" : "badge-success"
|
cor === 'error' ? 'badge-error' : cor === 'warning' ? 'badge-warning' : 'badge-success'
|
||||||
}`,
|
}`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return badges;
|
return badges;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article
|
<article
|
||||||
class={`rounded-2xl border p-4 transition-all duration-200 ${
|
class={`rounded-2xl border p-4 transition-all duration-200 ${
|
||||||
selected
|
selected
|
||||||
? "border-primary bg-primary/5 shadow-lg"
|
? 'border-primary bg-primary/5 shadow-lg'
|
||||||
: "border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md"
|
: 'border-base-200 bg-base-100/70 hover:border-primary/40 hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<button class="w-full text-left" type="button" onclick={handleSelect}>
|
<button class="w-full text-left" type="button" onclick={handleSelect}>
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-base-content/50">
|
<p class="text-base-content/50 text-xs tracking-wide uppercase">
|
||||||
Ticket {ticket.numero}
|
Ticket {ticket.numero}
|
||||||
</p>
|
</p>
|
||||||
<h3 class="text-lg font-semibold text-base-content">{ticket.titulo}</h3>
|
<h3 class="text-base-content text-lg font-semibold">{ticket.titulo}</h3>
|
||||||
</div>
|
</div>
|
||||||
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
<span class={getStatusBadge(ticket.status)}>{getStatusLabel(ticket.status)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-base-content/60 mt-2 text-sm line-clamp-2">{ticket.descricao}</p>
|
<p class="text-base-content/60 mt-2 line-clamp-2 text-sm">{ticket.descricao}</p>
|
||||||
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-2 text-xs text-base-content/60">
|
<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"}>
|
<span class={prioridadeClasses[ticket.prioridade] ?? 'badge badge-sm'}>
|
||||||
Prioridade {ticket.prioridade}
|
Prioridade {ticket.prioridade}
|
||||||
</span>
|
</span>
|
||||||
<span class="badge badge-xs badge-outline">
|
<span class="badge badge-xs badge-outline">
|
||||||
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
|
{ticket.tipo.charAt(0).toUpperCase() + ticket.tipo.slice(1)}
|
||||||
</span>
|
</span>
|
||||||
{#if ticket.setorResponsavel}
|
{#if ticket.setorResponsavel}
|
||||||
<span class="badge badge-xs badge-outline badge-ghost">
|
<span class="badge badge-xs badge-outline badge-ghost">
|
||||||
{ticket.setorResponsavel}
|
{ticket.setorResponsavel}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-1 text-xs text-base-content/50">
|
<div class="text-base-content/50 mt-4 space-y-1 text-xs">
|
||||||
<p>
|
<p>
|
||||||
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
|
Última interação: {formatarData(ticket.ultimaInteracaoEm)}
|
||||||
</p>
|
</p>
|
||||||
<p>{getStatusDescription(ticket.status)}</p>
|
<p>{getStatusDescription(ticket.status)}</p>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
{#each getPrazoBadges() as badge (badge.label)}
|
{#each getPrazoBadges() as badge (badge.label)}
|
||||||
<span class={badge.classe}>{badge.label}</span>
|
<span class={badge.classe}>{badge.label}</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -1,308 +1,261 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { createEventDispatcher } from "svelte";
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
import { Paperclip, X, Send } from 'lucide-svelte';
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
titulo: string;
|
titulo: string;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
tipo: Doc<"tickets">["tipo"];
|
tipo: Doc<'tickets'>['tipo'];
|
||||||
prioridade: Doc<"tickets">["prioridade"];
|
prioridade: Doc<'tickets'>['prioridade'];
|
||||||
categoria: string;
|
categoria: string;
|
||||||
canalOrigem?: string;
|
canalOrigem?: string;
|
||||||
anexos: File[];
|
anexos: File[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
|
const dispatch = createEventDispatcher<{ submit: { values: FormValues } }>();
|
||||||
const props = $props<Props>();
|
const props = $props<Props>();
|
||||||
const loading = $derived(props.loading ?? false);
|
const loading = $derived(props.loading ?? false);
|
||||||
|
|
||||||
let titulo = $state("");
|
let titulo = $state('');
|
||||||
let descricao = $state("");
|
let descricao = $state('');
|
||||||
let tipo = $state<Doc<"tickets">["tipo"]>("chamado");
|
let tipo = $state<Doc<'tickets'>['tipo']>('chamado');
|
||||||
let prioridade = $state<Doc<"tickets">["prioridade"]>("media");
|
let prioridade = $state<Doc<'tickets'>['prioridade']>('media');
|
||||||
let categoria = $state("");
|
let categoria = $state('');
|
||||||
let canalOrigem = $state("Portal SGSE");
|
let canalOrigem = $state('Portal SGSE');
|
||||||
let anexos = $state<Array<File>>([]);
|
let anexos = $state<Array<File>>([]);
|
||||||
let errors = $state<Record<string, string>>({});
|
let errors = $state<Record<string, string>>({});
|
||||||
function validate(): boolean {
|
function validate(): boolean {
|
||||||
const novoErros: Record<string, string> = {};
|
const novoErros: Record<string, string> = {};
|
||||||
if (!titulo.trim()) novoErros.titulo = "Informe um título para o chamado.";
|
if (!titulo.trim()) novoErros.titulo = 'Informe um título para o chamado.';
|
||||||
if (!descricao.trim()) novoErros.descricao = "Descrição é obrigatória.";
|
if (!descricao.trim()) novoErros.descricao = 'Descrição é obrigatória.';
|
||||||
if (!categoria.trim()) novoErros.categoria = "Informe uma categoria.";
|
if (!categoria.trim()) novoErros.categoria = 'Informe uma categoria.';
|
||||||
errors = novoErros;
|
errors = novoErros;
|
||||||
return Object.keys(novoErros).length === 0;
|
return Object.keys(novoErros).length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFiles(event: Event) {
|
function handleFiles(event: Event) {
|
||||||
const target = event.target as HTMLInputElement;
|
const target = event.target as HTMLInputElement;
|
||||||
const files = Array.from(target.files ?? []);
|
const files = Array.from(target.files ?? []);
|
||||||
anexos = files.slice(0, 5); // limitar para 5 anexos
|
anexos = files.slice(0, 5); // limitar para 5 anexos
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeFile(index: number) {
|
function removeFile(index: number) {
|
||||||
anexos = anexos.filter((_, idx) => idx !== index);
|
anexos = anexos.filter((_, idx) => idx !== index);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
titulo = "";
|
titulo = '';
|
||||||
descricao = "";
|
descricao = '';
|
||||||
categoria = "";
|
categoria = '';
|
||||||
tipo = "chamado";
|
tipo = 'chamado';
|
||||||
prioridade = "media";
|
prioridade = 'media';
|
||||||
anexos = [];
|
anexos = [];
|
||||||
errors = {};
|
errors = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit(event: SubmitEvent) {
|
function handleSubmit(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
|
|
||||||
dispatch("submit", {
|
dispatch('submit', {
|
||||||
values: {
|
values: {
|
||||||
titulo: titulo.trim(),
|
titulo: titulo.trim(),
|
||||||
descricao: descricao.trim(),
|
descricao: descricao.trim(),
|
||||||
tipo,
|
tipo,
|
||||||
prioridade,
|
prioridade,
|
||||||
categoria: categoria.trim(),
|
categoria: categoria.trim(),
|
||||||
canalOrigem,
|
canalOrigem,
|
||||||
anexos,
|
anexos
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="space-y-8" onsubmit={handleSubmit}>
|
<form class="space-y-8" onsubmit={handleSubmit}>
|
||||||
<!-- Título do Chamado -->
|
<!-- Título do Chamado -->
|
||||||
<section class="form-control">
|
<section class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content">Título do chamado</span>
|
<span class="label-text text-base-content font-semibold">Título do chamado</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-primary w-full"
|
class="input input-bordered input-primary w-full"
|
||||||
placeholder="Ex: Erro ao acessar o módulo de licitações"
|
placeholder="Ex: Erro ao acessar o módulo de licitações"
|
||||||
bind:value={titulo}
|
bind:value={titulo}
|
||||||
/>
|
/>
|
||||||
{#if errors.titulo}
|
{#if errors.titulo}
|
||||||
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
|
<span class="text-error mt-1 text-sm">{errors.titulo}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Tipo de Solicitação e Prioridade -->
|
<!-- Tipo de Solicitação e Prioridade -->
|
||||||
<section class="grid gap-6 md:grid-cols-2">
|
<section class="grid gap-6 md:grid-cols-2">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content">Tipo de solicitação</span>
|
<span class="label-text text-base-content font-semibold">Tipo de solicitação</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-2 gap-2 rounded-xl border border-base-300 bg-base-200/30 p-3">
|
<div class="border-base-300 bg-base-200/30 grid grid-cols-2 gap-2 rounded-xl border p-3">
|
||||||
{#each [
|
{#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}
|
||||||
{ value: "chamado", label: "Chamado", icon: "📋" },
|
<label
|
||||||
{ value: "reclamacao", label: "Reclamação", icon: "⚠️" },
|
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
|
||||||
{ value: "elogio", label: "Elogio", icon: "⭐" },
|
tipo === opcao.value
|
||||||
{ value: "sugestao", label: "Sugestão", icon: "💡" }
|
? 'border-primary bg-primary/10 shadow-md'
|
||||||
] as opcao}
|
: 'border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50'
|
||||||
<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
|
<input
|
||||||
? "border-primary bg-primary/10 shadow-md"
|
type="radio"
|
||||||
: "border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50"
|
name="tipo"
|
||||||
}`}
|
class="radio radio-primary radio-sm shrink-0"
|
||||||
>
|
value={opcao.value}
|
||||||
<input
|
checked={tipo === opcao.value}
|
||||||
type="radio"
|
onclick={() => (tipo = opcao.value as typeof tipo)}
|
||||||
name="tipo"
|
/>
|
||||||
class="radio radio-primary radio-sm shrink-0"
|
<span class="shrink-0 text-base">{opcao.icon}</span>
|
||||||
value={opcao.value}
|
<span class="flex-1 text-center text-sm font-medium">{opcao.label}</span>
|
||||||
checked={tipo === opcao.value}
|
</label>
|
||||||
onclick={() => (tipo = opcao.value as typeof tipo)}
|
{/each}
|
||||||
/>
|
</div>
|
||||||
<span class="text-base shrink-0">{opcao.icon}</span>
|
</div>
|
||||||
<span class="text-sm font-medium flex-1 text-center">{opcao.label}</span>
|
|
||||||
</label>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content">Prioridade</span>
|
<span class="label-text text-base-content font-semibold">Prioridade</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="grid grid-cols-2 gap-2 rounded-xl border border-base-300 bg-base-200/30 p-3">
|
<div class="border-base-300 bg-base-200/30 grid grid-cols-2 gap-2 rounded-xl border p-3">
|
||||||
{#each [
|
{#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}
|
||||||
{ value: "baixa", label: "Baixa", color: "badge-success" },
|
<label
|
||||||
{ value: "media", label: "Média", color: "badge-info" },
|
class={`flex w-full cursor-pointer items-center justify-start gap-2 rounded-lg border-2 p-2.5 transition-all ${
|
||||||
{ value: "alta", label: "Alta", color: "badge-warning" },
|
prioridade === opcao.value
|
||||||
{ value: "critica", label: "Crítica", color: "badge-error" }
|
? 'border-primary bg-primary/10 shadow-md'
|
||||||
] as opcao}
|
: 'border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50'
|
||||||
<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
|
<input
|
||||||
? "border-primary bg-primary/10 shadow-md"
|
type="radio"
|
||||||
: "border-base-300 bg-base-100 hover:border-primary/50 hover:bg-base-200/50"
|
name="prioridade"
|
||||||
}`}
|
class={`radio radio-sm shrink-0 ${
|
||||||
>
|
opcao.value === 'baixa'
|
||||||
<input
|
? 'radio-success'
|
||||||
type="radio"
|
: opcao.value === 'media'
|
||||||
name="prioridade"
|
? 'radio-info'
|
||||||
class={`radio radio-sm shrink-0 ${
|
: opcao.value === 'alta'
|
||||||
opcao.value === "baixa" ? "radio-success" :
|
? 'radio-warning'
|
||||||
opcao.value === "media" ? "radio-info" :
|
: 'radio-error'
|
||||||
opcao.value === "alta" ? "radio-warning" :
|
}`}
|
||||||
"radio-error"
|
value={opcao.value}
|
||||||
}`}
|
checked={prioridade === opcao.value}
|
||||||
value={opcao.value}
|
onclick={() => (prioridade = opcao.value as typeof prioridade)}
|
||||||
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>
|
||||||
<span class={`badge badge-sm ${opcao.color} flex-1 justify-center`}>{opcao.label}</span>
|
{/each}
|
||||||
</label>
|
</div>
|
||||||
{/each}
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Categoria -->
|
<!-- Categoria -->
|
||||||
<section class="form-control">
|
<section class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content">Categoria</span>
|
<span class="label-text text-base-content font-semibold">Categoria</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered w-full"
|
class="input input-bordered w-full"
|
||||||
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
|
placeholder="Ex: Infraestrutura, Sistemas, Acesso"
|
||||||
bind:value={categoria}
|
bind:value={categoria}
|
||||||
/>
|
/>
|
||||||
{#if errors.categoria}
|
{#if errors.categoria}
|
||||||
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
|
<span class="text-error mt-1 text-sm">{errors.categoria}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Descrição Detalhada -->
|
<!-- Descrição Detalhada -->
|
||||||
<section class="form-control">
|
<section class="form-control">
|
||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold text-base-content">Descrição detalhada</span>
|
<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>
|
<span class="label-text-alt text-base-content/50">Obrigatório</span>
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
class="textarea textarea-bordered textarea-lg min-h-[180px]"
|
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..."
|
placeholder="Descreva o problema, erro ou sugestão com o máximo de detalhes possível..."
|
||||||
bind:value={descricao}
|
bind:value={descricao}
|
||||||
></textarea>
|
></textarea>
|
||||||
{#if errors.descricao}
|
{#if errors.descricao}
|
||||||
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
|
<span class="text-error mt-1 text-sm">{errors.descricao}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Anexos -->
|
<!-- Anexos -->
|
||||||
<section class="space-y-4 rounded-xl border border-base-300 bg-base-200/30 p-4">
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-base-content">Anexos (opcional)</p>
|
<p class="text-base-content font-semibold">Anexos (opcional)</p>
|
||||||
<p class="text-base-content/60 text-sm">
|
<p class="text-base-content/60 text-sm">Suporte a PDF e imagens (máx. 10MB por arquivo)</p>
|
||||||
Suporte a PDF e imagens (máx. 10MB por arquivo)
|
</div>
|
||||||
</p>
|
<label class="btn btn-outline btn-sm">
|
||||||
</div>
|
<Paperclip class="h-4 w-4" strokeWidth={2} />
|
||||||
<label class="btn btn-outline btn-sm">
|
Selecionar arquivos
|
||||||
<svg
|
<input
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
type="file"
|
||||||
class="h-4 w-4"
|
class="hidden"
|
||||||
fill="none"
|
multiple
|
||||||
viewBox="0 0 24 24"
|
accept=".pdf,.png,.jpg,.jpeg"
|
||||||
stroke="currentColor"
|
onchange={handleFiles}
|
||||||
>
|
/>
|
||||||
<path
|
</label>
|
||||||
stroke-linecap="round"
|
</div>
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Selecionar arquivos
|
|
||||||
<input type="file" class="hidden" multiple accept=".pdf,.png,.jpg,.jpeg" onchange={handleFiles} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if anexos.length > 0}
|
{#if anexos.length > 0}
|
||||||
<div class="space-y-2 rounded-2xl border border-base-200 bg-base-100/70 p-4">
|
<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)}
|
{#each anexos as file, index (file.name + index)}
|
||||||
<div class="flex items-center justify-between gap-3 rounded-xl border border-base-200 bg-base-100 px-3 py-2">
|
<div
|
||||||
<div>
|
class="border-base-200 bg-base-100 flex items-center justify-between gap-3 rounded-xl border px-3 py-2"
|
||||||
<p class="text-sm font-medium">{file.name}</p>
|
>
|
||||||
<p class="text-xs text-base-content/60">
|
<div>
|
||||||
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
|
<p class="text-sm font-medium">{file.name}</p>
|
||||||
</p>
|
<p class="text-base-content/60 text-xs">
|
||||||
</div>
|
{(file.size / 1024 / 1024).toFixed(2)} MB • {file.type}
|
||||||
<button
|
</p>
|
||||||
type="button"
|
</div>
|
||||||
class="btn btn-ghost btn-sm text-error"
|
<button
|
||||||
onclick={() => removeFile(index)}
|
type="button"
|
||||||
>
|
class="btn btn-ghost btn-sm text-error"
|
||||||
Remover
|
onclick={() => removeFile(index)}
|
||||||
</button>
|
>
|
||||||
</div>
|
Remover
|
||||||
{/each}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/each}
|
||||||
<div class="rounded-2xl border border-dashed border-base-300 bg-base-100/50 p-6 text-center text-sm text-base-content/60">
|
</div>
|
||||||
Nenhum arquivo selecionado.
|
{:else}
|
||||||
</div>
|
<div
|
||||||
{/if}
|
class="border-base-300 bg-base-100/50 text-base-content/60 rounded-2xl border border-dashed p-6 text-center text-sm"
|
||||||
</section>
|
>
|
||||||
|
Nenhum arquivo selecionado.
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Ações do Formulário -->
|
<!-- Ações do Formulário -->
|
||||||
<section class="flex flex-wrap gap-3 border-t border-base-300 pt-6">
|
<section class="border-base-300 flex flex-wrap gap-3 border-t pt-6">
|
||||||
<button
|
<button type="submit" class="btn btn-primary min-w-[200px] flex-1 shadow-lg" disabled={loading}>
|
||||||
type="submit"
|
{#if loading}
|
||||||
class="btn btn-primary flex-1 min-w-[200px] shadow-lg"
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
disabled={loading}
|
Enviando...
|
||||||
>
|
{:else}
|
||||||
{#if loading}
|
<Send class="h-5 w-5" strokeWidth={2} />
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
Registrar chamado
|
||||||
Enviando...
|
{/if}
|
||||||
{:else}
|
</button>
|
||||||
<svg
|
<button type="button" class="btn btn-ghost" onclick={resetForm} disabled={loading}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<X class="h-5 w-5" strokeWidth={2} />
|
||||||
class="h-5 w-5"
|
Limpar
|
||||||
fill="none"
|
</button>
|
||||||
viewBox="0 0 24 24"
|
</section>
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Registrar chamado
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-ghost"
|
|
||||||
onclick={resetForm}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Limpar
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,86 +1,85 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Doc } from "@sgse-app/backend/convex/_generated/dataModel";
|
import type { Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import {
|
import {
|
||||||
formatarData,
|
formatarData,
|
||||||
formatarTimelineEtapa,
|
formatarTimelineEtapa,
|
||||||
prazoRestante,
|
prazoRestante,
|
||||||
timelineStatus,
|
timelineStatus
|
||||||
} from "$lib/utils/chamados";
|
} from '$lib/utils/chamados';
|
||||||
|
|
||||||
type Ticket = Doc<"tickets">;
|
type Ticket = Doc<'tickets'>;
|
||||||
type TimelineEntry = NonNullable<Ticket["timeline"]>[number];
|
type TimelineEntry = NonNullable<Ticket['timeline']>[number];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
timeline?: Array<TimelineEntry>;
|
timeline?: Array<TimelineEntry>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = $props<Props>();
|
const props: Props = $props();
|
||||||
const timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
|
let timeline = $derived<Array<TimelineEntry>>(props.timeline ?? []);
|
||||||
|
|
||||||
const badgeClasses: Record<string, string> = {
|
const badgeClasses: Record<string, string> = {
|
||||||
success: "bg-success/20 text-success border-success/40",
|
success: 'bg-success/20 text-success border-success/40',
|
||||||
warning: "bg-warning/20 text-warning border-warning/40",
|
warning: 'bg-warning/20 text-warning border-warning/40',
|
||||||
error: "bg-error/20 text-error border-error/40",
|
error: 'bg-error/20 text-error border-error/40',
|
||||||
info: "bg-info/20 text-info border-info/40",
|
info: 'bg-info/20 text-info border-info/40'
|
||||||
};
|
};
|
||||||
|
|
||||||
function getBadgeClass(entry: TimelineEntry) {
|
function getBadgeClass(entry: TimelineEntry) {
|
||||||
const status = timelineStatus(entry);
|
const status = timelineStatus(entry);
|
||||||
return badgeClasses[status] ?? badgeClasses.info;
|
return badgeClasses[status] ?? badgeClasses.info;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(entry: TimelineEntry) {
|
function getStatusLabel(entry: TimelineEntry) {
|
||||||
if (entry.status === "concluido") return "Concluído";
|
if (entry.status === 'concluido') return 'Concluído';
|
||||||
if (entry.status === "em_andamento") return "Em andamento";
|
if (entry.status === 'em_andamento') return 'Em andamento';
|
||||||
if (entry.status === "vencido") return "Vencido";
|
if (entry.status === 'vencido') return 'Vencido';
|
||||||
return "Pendente";
|
return 'Pendente';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPrazoDescricao(entry: TimelineEntry) {
|
function getPrazoDescricao(entry: TimelineEntry) {
|
||||||
if (entry.status === "concluido" && entry.concluidoEm) {
|
if (entry.status === 'concluido' && entry.concluidoEm) {
|
||||||
return `Concluído em ${formatarData(entry.concluidoEm)}`;
|
return `Concluído em ${formatarData(entry.concluidoEm)}`;
|
||||||
}
|
}
|
||||||
if (!entry.prazo) return "Sem prazo definido";
|
if (!entry.prazo) return 'Sem prazo definido';
|
||||||
return `${formatarData(entry.prazo)} • ${prazoRestante(entry.prazo) ?? ""}`;
|
return `${formatarData(entry.prazo)} • ${prazoRestante(entry.prazo) ?? ''}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
{#if timeline.length === 0}
|
{#if timeline.length === 0}
|
||||||
<div class="alert alert-info">
|
<div class="alert alert-info">
|
||||||
<span>Nenhuma etapa registrada ainda.</span>
|
<span>Nenhuma etapa registrada ainda.</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#each timeline as entry (entry.etapa + entry.prazo)}
|
{#each timeline as entry (entry.etapa + entry.prazo)}
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<div class="relative flex flex-col items-center">
|
<div class="relative flex flex-col items-center">
|
||||||
<div class={`badge border ${getBadgeClass(entry)}`}>
|
<div class={`badge border ${getBadgeClass(entry)}`}>
|
||||||
{formatarTimelineEtapa(entry.etapa)}
|
{formatarTimelineEtapa(entry.etapa)}
|
||||||
</div>
|
</div>
|
||||||
{#if entry !== timeline[timeline.length - 1]}
|
{#if entry !== timeline[timeline.length - 1]}
|
||||||
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
|
<div class="bg-base-200/80 mt-2 h-full w-px flex-1"></div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 rounded-2xl border border-base-200 bg-base-100/80 p-4 shadow-sm">
|
<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">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm font-semibold text-base-content">
|
<span class="text-base-content text-sm font-semibold">
|
||||||
{getStatusLabel(entry)}
|
{getStatusLabel(entry)}
|
||||||
</span>
|
</span>
|
||||||
{#if entry.status !== "concluido" && entry.prazo}
|
{#if entry.status !== 'concluido' && entry.prazo}
|
||||||
<span class="badge badge-sm badge-outline">
|
<span class="badge badge-sm badge-outline">
|
||||||
{prazoRestante(entry.prazo)}
|
{prazoRestante(entry.prazo)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if entry.observacao}
|
{#if entry.observacao}
|
||||||
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
|
<p class="text-base-content/70 mt-2 text-sm">{entry.observacao}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<p class="text-base-content/50 mt-3 text-xs uppercase tracking-wide">
|
<p class="text-base-content/50 mt-3 text-xs tracking-wide uppercase">
|
||||||
{getPrazoDescricao(entry)}
|
{getPrazoDescricao(entry)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,514 +1,455 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from "convex-svelte";
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { abrirConversa } from "$lib/stores/chatStore";
|
import { abrirConversa } from '$lib/stores/chatStore';
|
||||||
import { formatDistanceToNow } from "date-fns";
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { ptBR } from "date-fns/locale";
|
import { ptBR } from 'date-fns/locale';
|
||||||
import UserStatusBadge from "./UserStatusBadge.svelte";
|
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||||
import UserAvatar from "./UserAvatar.svelte";
|
import UserAvatar from './UserAvatar.svelte';
|
||||||
import NewConversationModal from "./NewConversationModal.svelte";
|
import NewConversationModal from './NewConversationModal.svelte';
|
||||||
|
import { Search, Plus, MessageSquare, Users, UsersRound } from 'lucide-svelte';
|
||||||
|
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { obterCoresDoTema } from '$lib/utils/temas';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
// Buscar todos os usuários para o chat
|
// Buscar todos os usuários para o chat
|
||||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||||
|
|
||||||
// Buscar o perfil do usuário logado
|
// Buscar o perfil do usuário logado
|
||||||
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
const meuPerfil = useQuery(api.usuarios.obterPerfil, {});
|
||||||
|
|
||||||
// Buscar conversas (grupos e salas de reunião)
|
// Buscar conversas (grupos e salas de reunião)
|
||||||
const conversas = useQuery(api.chat.listarConversas, {});
|
const conversas = useQuery(api.chat.listarConversas, {});
|
||||||
|
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state('');
|
||||||
let activeTab = $state<"usuarios" | "conversas">("usuarios");
|
let activeTab = $state<'usuarios' | 'conversas'>('usuarios');
|
||||||
|
|
||||||
// Debug: monitorar carregamento de dados
|
// Obter cores do tema atual (reativo)
|
||||||
$effect(() => {
|
let coresTema = $state(obterCoresDoTema());
|
||||||
console.log(
|
|
||||||
"📊 [ChatList] Usuários carregados:",
|
|
||||||
usuarios?.data?.length || 0,
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"👤 [ChatList] Meu perfil:",
|
|
||||||
meuPerfil?.data?.nome || "Carregando...",
|
|
||||||
);
|
|
||||||
console.log(
|
|
||||||
"🆔 [ChatList] Meu ID:",
|
|
||||||
meuPerfil?.data?._id || "Não encontrado",
|
|
||||||
);
|
|
||||||
if (usuarios?.data) {
|
|
||||||
const meuId = meuPerfil?.data?._id;
|
|
||||||
const meusDadosNaLista = usuarios.data.find((u: any) => u._id === meuId);
|
|
||||||
if (meusDadosNaLista) {
|
|
||||||
console.warn(
|
|
||||||
"⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!",
|
|
||||||
meusDadosNaLista.nome,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const usuariosFiltrados = $derived.by(() => {
|
// Atualizar cores quando o tema mudar
|
||||||
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
|
const atualizarCores = () => {
|
||||||
if (!meuPerfil?.data) {
|
coresTema = obterCoresDoTema();
|
||||||
console.log("⏳ [ChatList] Aguardando perfil do usuário...");
|
};
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const meuId = meuPerfil.data._id;
|
atualizarCores();
|
||||||
|
|
||||||
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
|
window.addEventListener('themechange', atualizarCores);
|
||||||
let listaFiltrada = usuarios.data.filter((u: any) => u._id !== meuId);
|
|
||||||
|
|
||||||
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
const observer = new MutationObserver(atualizarCores);
|
||||||
const aindaNaLista = listaFiltrada.find((u: any) => u._id === meuId);
|
const htmlElement = document.documentElement;
|
||||||
if (aindaNaLista) {
|
observer.observe(htmlElement, {
|
||||||
console.error(
|
attributes: true,
|
||||||
"❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!",
|
attributeFilter: ['data-theme']
|
||||||
);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Aplicar busca por nome/email/matrícula
|
return () => {
|
||||||
if (searchQuery.trim()) {
|
window.removeEventListener('themechange', atualizarCores);
|
||||||
const query = searchQuery.toLowerCase();
|
observer.disconnect();
|
||||||
listaFiltrada = listaFiltrada.filter(
|
};
|
||||||
(u: any) =>
|
});
|
||||||
u.nome?.toLowerCase().includes(query) ||
|
|
||||||
u.email?.toLowerCase().includes(query) ||
|
|
||||||
u.matricula?.toLowerCase().includes(query),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ordenar: Online primeiro, depois por nome
|
// Função para obter rgba da cor primária
|
||||||
return listaFiltrada.sort((a: any, b: any) => {
|
function obterPrimariaRgba(alpha: number = 1) {
|
||||||
const statusOrder = {
|
const primary = coresTema.primary;
|
||||||
online: 0,
|
if (primary.startsWith('rgba')) {
|
||||||
ausente: 1,
|
const match = primary.match(/rgba?\(([^)]+)\)/);
|
||||||
externo: 2,
|
if (match) {
|
||||||
em_reuniao: 3,
|
const values = match[1].split(',');
|
||||||
offline: 4,
|
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
|
||||||
};
|
}
|
||||||
const statusA =
|
}
|
||||||
statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
if (primary.startsWith('#')) {
|
||||||
const statusB =
|
const hex = primary.replace('#', '');
|
||||||
statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
if (primary.startsWith('hsl')) {
|
||||||
|
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
|
||||||
|
}
|
||||||
|
return `rgba(102, 126, 234, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
if (statusA !== statusB) return statusA - statusB;
|
// Debug: monitorar carregamento de dados
|
||||||
return a.nome.localeCompare(b.nome);
|
$effect(() => {
|
||||||
});
|
console.log('📊 [ChatList] Usuários carregados:', usuarios?.data?.length || 0);
|
||||||
});
|
console.log('👤 [ChatList] Meu perfil:', meuPerfil?.data?.nome || 'Carregando...');
|
||||||
|
console.log('🆔 [ChatList] Meu ID:', meuPerfil?.data?._id || 'Não encontrado');
|
||||||
|
if (usuarios?.data) {
|
||||||
|
const meuId = meuPerfil?.data?._id;
|
||||||
|
const meusDadosNaLista = usuarios.data.find((u) => u._id === meuId);
|
||||||
|
if (meusDadosNaLista) {
|
||||||
|
console.warn(
|
||||||
|
'⚠️ [ChatList] ATENÇÃO: Meu usuário está na lista do backend!',
|
||||||
|
meusDadosNaLista.nome
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function formatarTempo(timestamp: number | undefined): string {
|
let usuariosFiltrados = $derived.by(() => {
|
||||||
if (!timestamp) return "";
|
if (!usuarios?.data || !Array.isArray(usuarios.data)) return [];
|
||||||
try {
|
|
||||||
return formatDistanceToNow(new Date(timestamp), {
|
|
||||||
addSuffix: true,
|
|
||||||
locale: ptBR,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let processando = $state(false);
|
// Se não temos o perfil ainda, retornar lista vazia para evitar mostrar usuários incorretos
|
||||||
let showNewConversationModal = $state(false);
|
if (!meuPerfil?.data) {
|
||||||
|
console.log('⏳ [ChatList] Aguardando perfil do usuário...');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
async function handleClickUsuario(usuario: any) {
|
const meuId = meuPerfil.data._id;
|
||||||
if (processando) {
|
|
||||||
console.log("⏳ Já está processando uma ação, aguarde...");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Filtrar o próprio usuário da lista (filtro de segurança no frontend)
|
||||||
processando = true;
|
let listaFiltrada = usuarios.data.filter((u) => u._id !== meuId);
|
||||||
console.log("🔄 Clicou no usuário:", usuario.nome, "ID:", usuario._id);
|
|
||||||
|
|
||||||
// Criar ou buscar conversa individual com este usuário
|
// Log se ainda estiver na lista após filtro (não deveria acontecer)
|
||||||
console.log("📞 Chamando mutation criarOuBuscarConversaIndividual...");
|
const aindaNaLista = listaFiltrada.find((u) => u._id === meuId);
|
||||||
const conversaId = await client.mutation(
|
if (aindaNaLista) {
|
||||||
api.chat.criarOuBuscarConversaIndividual,
|
console.error('❌ [ChatList] ERRO: Meu usuário ainda está na lista após filtro!');
|
||||||
{
|
}
|
||||||
outroUsuarioId: usuario._id,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("✅ Conversa criada/encontrada. ID:", conversaId);
|
// Aplicar busca por nome/email/matrícula
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
listaFiltrada = listaFiltrada.filter(
|
||||||
|
(u) =>
|
||||||
|
u.nome?.toLowerCase().includes(query) ||
|
||||||
|
u.email?.toLowerCase().includes(query) ||
|
||||||
|
u.matricula?.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Abrir a conversa
|
// Ordenar: Online primeiro, depois por nome
|
||||||
console.log("📂 Abrindo conversa...");
|
return listaFiltrada.sort((a, b) => {
|
||||||
abrirConversa(conversaId as any);
|
const statusOrder = {
|
||||||
|
online: 0,
|
||||||
|
ausente: 1,
|
||||||
|
externo: 2,
|
||||||
|
em_reuniao: 3,
|
||||||
|
offline: 4
|
||||||
|
};
|
||||||
|
const statusA = statusOrder[a.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||||
|
const statusB = statusOrder[b.statusPresenca as keyof typeof statusOrder] ?? 4;
|
||||||
|
|
||||||
console.log("✅ Conversa aberta com sucesso!");
|
if (statusA !== statusB) return statusA - statusB;
|
||||||
} catch (error) {
|
return a.nome.localeCompare(b.nome);
|
||||||
console.error("❌ Erro ao abrir conversa:", error);
|
});
|
||||||
console.error("Detalhes do erro:", {
|
});
|
||||||
message: error instanceof Error ? error.message : String(error),
|
|
||||||
stack: error instanceof Error ? error.stack : undefined,
|
|
||||||
usuario: usuario,
|
|
||||||
});
|
|
||||||
alert(
|
|
||||||
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
processando = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStatusLabel(status: string | undefined): string {
|
function formatarTempo(timestamp: number | undefined): string {
|
||||||
const labels: Record<string, string> = {
|
if (!timestamp) return '';
|
||||||
online: "Online",
|
try {
|
||||||
offline: "Offline",
|
return formatDistanceToNow(new Date(timestamp), {
|
||||||
ausente: "Ausente",
|
addSuffix: true,
|
||||||
externo: "Externo",
|
locale: ptBR
|
||||||
em_reuniao: "Em Reunião",
|
});
|
||||||
};
|
} catch {
|
||||||
return labels[status || "offline"] || "Offline";
|
return '';
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Filtrar conversas por tipo e busca
|
let processando = $state(false);
|
||||||
const conversasFiltradas = $derived(() => {
|
let showNewConversationModal = $state(false);
|
||||||
if (!conversas?.data) return [];
|
|
||||||
|
|
||||||
let lista = conversas.data.filter(
|
async function handleClickUsuario(usuario: any) {
|
||||||
(c: any) => c.tipo === "grupo" || c.tipo === "sala_reuniao",
|
if (processando) {
|
||||||
);
|
console.log('⏳ Já está processando uma ação, aguarde...');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Aplicar busca
|
try {
|
||||||
if (searchQuery.trim()) {
|
processando = true;
|
||||||
const query = searchQuery.toLowerCase();
|
console.log('🔄 Clicou no usuário:', usuario.nome, 'ID:', usuario._id);
|
||||||
lista = lista.filter((c: any) => c.nome?.toLowerCase().includes(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
return lista;
|
// 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
|
||||||
|
});
|
||||||
|
|
||||||
function handleClickConversa(conversa: any) {
|
console.log('✅ Conversa criada/encontrada. ID:', conversaId);
|
||||||
if (processando) return;
|
|
||||||
try {
|
// Abrir a conversa
|
||||||
processando = true;
|
console.log('📂 Abrindo conversa...');
|
||||||
abrirConversa(conversa._id);
|
abrirConversa(conversaId as Id<'conversas'>);
|
||||||
} catch (error) {
|
|
||||||
console.error("Erro ao abrir conversa:", error);
|
console.log('✅ Conversa aberta com sucesso!');
|
||||||
alert(
|
} catch (error) {
|
||||||
`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`,
|
console.error('❌ Erro ao abrir conversa:', error);
|
||||||
);
|
console.error('Detalhes do erro:', {
|
||||||
} finally {
|
message: error instanceof Error ? error.message : String(error),
|
||||||
processando = false;
|
stack: error instanceof Error ? error.stack : undefined,
|
||||||
}
|
usuario: usuario
|
||||||
}
|
});
|
||||||
|
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusLabel(status: string | undefined): string {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
online: 'Online',
|
||||||
|
offline: 'Offline',
|
||||||
|
ausente: 'Ausente',
|
||||||
|
externo: 'Externo',
|
||||||
|
em_reuniao: 'Em Reunião'
|
||||||
|
};
|
||||||
|
return labels[status || 'offline'] || 'Offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrar conversas por tipo e busca
|
||||||
|
let conversasFiltradas = $derived.by(() => {
|
||||||
|
if (!conversas?.data) return [];
|
||||||
|
|
||||||
|
let lista = conversas.data.filter(
|
||||||
|
(c: Doc<'conversas'>) => c.tipo === 'grupo' || c.tipo === 'sala_reuniao'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Aplicar busca
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
lista = lista.filter((c: Doc<'conversas'>) => c.nome?.toLowerCase().includes(query));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lista;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Conversa {
|
||||||
|
_id: Id<'conversas'>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickConversa(conversa: Conversa) {
|
||||||
|
if (processando) return;
|
||||||
|
try {
|
||||||
|
processando = true;
|
||||||
|
abrirConversa(conversa._id);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Erro ao abrir conversa:', error);
|
||||||
|
alert(`Erro ao abrir conversa: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
} finally {
|
||||||
|
processando = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex h-full flex-col">
|
||||||
<!-- Search bar -->
|
<!-- Search bar -->
|
||||||
<div class="p-4 border-b border-base-300">
|
<div class="border-base-300 border-b p-4">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar usuários (nome, email, matrícula)..."
|
placeholder="Buscar usuários (nome, email, matrícula)..."
|
||||||
class="input input-bordered w-full pl-10"
|
class="input input-bordered w-full pl-10"
|
||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
/>
|
aria-label="Buscar usuários ou conversas"
|
||||||
<svg
|
aria-describedby="search-help"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
/>
|
||||||
fill="none"
|
<span id="search-help" class="sr-only"
|
||||||
viewBox="0 0 24 24"
|
>Digite para buscar usuários por nome, email ou matrícula</span
|
||||||
stroke-width="1.5"
|
>
|
||||||
stroke="currentColor"
|
<Search
|
||||||
class="w-5 h-5 absolute left-3 top-1/2 -translate-y-1/2 text-base-content/50"
|
class="text-base-content/50 absolute top-1/2 left-3 h-5 w-5 -translate-y-1/2"
|
||||||
>
|
strokeWidth={1.5}
|
||||||
<path
|
/>
|
||||||
stroke-linecap="round"
|
</div>
|
||||||
stroke-linejoin="round"
|
</div>
|
||||||
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tabs e Título -->
|
<!-- 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 -->
|
<!-- Tabs -->
|
||||||
<div class="tabs tabs-boxed p-2">
|
<div class="tabs tabs-boxed p-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`tab flex-1 ${activeTab === "usuarios" ? "tab-active" : ""}`}
|
class={`tab flex-1 ${activeTab === 'usuarios' ? 'tab-active' : ''}`}
|
||||||
onclick={() => (activeTab = "usuarios")}
|
onclick={() => (activeTab = 'usuarios')}
|
||||||
>
|
>
|
||||||
👥 Usuários ({usuariosFiltrados.length})
|
👥 Usuários ({usuariosFiltrados.length})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={`tab flex-1 ${activeTab === "conversas" ? "tab-active" : ""}`}
|
class={`tab flex-1 ${activeTab === 'conversas' ? 'tab-active' : ''}`}
|
||||||
onclick={() => (activeTab = "conversas")}
|
onclick={() => (activeTab = 'conversas')}
|
||||||
>
|
>
|
||||||
💬 Conversas ({conversasFiltradas().length})
|
💬 Conversas ({conversasFiltradas.length})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Botão Nova Conversa -->
|
<!-- Botão Nova Conversa -->
|
||||||
<div class="px-4 pb-2 flex justify-end">
|
<div class="flex justify-end px-4 pb-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
onclick={() => (showNewConversationModal = true)}
|
onclick={() => (showNewConversationModal = true)}
|
||||||
title="Nova conversa (grupo ou sala de reunião)"
|
title="Nova conversa (grupo ou sala de reunião)"
|
||||||
aria-label="Nova conversa"
|
aria-label="Nova conversa"
|
||||||
>
|
>
|
||||||
<svg
|
<Plus class="mr-1 h-4 w-4" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
Nova Conversa
|
||||||
fill="none"
|
</button>
|
||||||
viewBox="0 0 24 24"
|
</div>
|
||||||
stroke-width="2"
|
</div>
|
||||||
stroke="currentColor"
|
|
||||||
class="w-4 h-4 mr-1"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M12 4.5v15m7.5-7.5h-15"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Nova Conversa
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Lista de conteúdo -->
|
<!-- Lista de conteúdo -->
|
||||||
<div class="flex-1 overflow-y-auto">
|
<div class="flex-1 overflow-y-auto">
|
||||||
{#if activeTab === "usuarios"}
|
{#if activeTab === 'usuarios'}
|
||||||
<!-- Lista de usuários -->
|
<!-- Lista de usuários -->
|
||||||
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
{#if usuarios?.data && usuariosFiltrados.length > 0}
|
||||||
{#each usuariosFiltrados as usuario (usuario._id)}
|
{#each usuariosFiltrados as usuario (usuario._id)}
|
||||||
<button
|
<button
|
||||||
type="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
|
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
|
||||||
? 'opacity-50 cursor-wait'
|
? 'cursor-wait opacity-50'
|
||||||
: 'cursor-pointer'}"
|
: 'cursor-pointer'}"
|
||||||
onclick={() => handleClickUsuario(usuario)}
|
onclick={() => handleClickUsuario(usuario)}
|
||||||
disabled={processando}
|
disabled={processando}
|
||||||
>
|
aria-label="Abrir conversa com {usuario.nome}"
|
||||||
<!-- Ícone de mensagem -->
|
aria-describedby="usuario-status-{usuario._id}"
|
||||||
<div
|
>
|
||||||
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110"
|
<!-- Ícone de mensagem -->
|
||||||
style="background: linear-gradient(135deg, rgba(102, 126, 234, 0.1) 0%, rgba(118, 75, 162, 0.1) 100%); border: 1px solid rgba(102, 126, 234, 0.2);"
|
<div
|
||||||
>
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110"
|
||||||
<svg
|
style="background: linear-gradient(135deg, {obterPrimariaRgba(0.1)} 0%, {obterPrimariaRgba(0.1)} 100%); border: 1px solid {obterPrimariaRgba(0.2)};"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
>
|
||||||
viewBox="0 0 24 24"
|
<MessageSquare class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
fill="none"
|
</div>
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="w-5 h-5 text-primary"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
|
|
||||||
/>
|
|
||||||
<path d="M9 10h.01M15 10h.01" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Avatar -->
|
<!-- Avatar -->
|
||||||
<div class="relative shrink-0">
|
<div class="relative shrink-0">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
avatar={usuario.avatar}
|
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
||||||
fotoPerfilUrl={usuario.fotoPerfilUrl}
|
nome={usuario.nome}
|
||||||
nome={usuario.nome}
|
size="md"
|
||||||
size="md"
|
userId={usuario._id}
|
||||||
/>
|
/>
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
<div class="absolute bottom-0 right-0">
|
<div class="absolute right-0 bottom-0">
|
||||||
<UserStatusBadge
|
<UserStatusBadge status={usuario.statusPresenca || 'offline'} size="sm" />
|
||||||
status={usuario.statusPresenca || "offline"}
|
</div>
|
||||||
size="sm"
|
</div>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
<!-- Conteúdo -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="mb-1 flex items-center justify-between">
|
||||||
<p class="font-semibold text-base-content truncate">
|
<p class="text-base-content truncate font-semibold">
|
||||||
{usuario.nome}
|
{usuario.nome}
|
||||||
</p>
|
</p>
|
||||||
<span
|
<span
|
||||||
class="text-xs px-2 py-0.5 rounded-full {usuario.statusPresenca ===
|
class="rounded-full px-2 py-0.5 text-xs {usuario.statusPresenca === 'online'
|
||||||
'online'
|
? 'bg-success/20 text-success'
|
||||||
? 'bg-success/20 text-success'
|
: usuario.statusPresenca === 'ausente'
|
||||||
: usuario.statusPresenca === 'ausente'
|
? 'bg-warning/20 text-warning'
|
||||||
? 'bg-warning/20 text-warning'
|
: usuario.statusPresenca === 'em_reuniao'
|
||||||
: usuario.statusPresenca === 'em_reuniao'
|
? 'bg-error/20 text-error'
|
||||||
? 'bg-error/20 text-error'
|
: 'bg-base-300 text-base-content/50'}"
|
||||||
: 'bg-base-300 text-base-content/50'}"
|
>
|
||||||
>
|
{getStatusLabel(usuario.statusPresenca)}
|
||||||
{getStatusLabel(usuario.statusPresenca)}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-2">
|
<p class="text-base-content/70 truncate text-sm">
|
||||||
<p class="text-sm text-base-content/70 truncate">
|
{usuario.statusMensagem || usuario.email}
|
||||||
{usuario.statusMensagem || usuario.email}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<span id="usuario-status-{usuario._id}" class="sr-only">
|
||||||
</div>
|
Status: {getStatusLabel(usuario.statusPresenca)}
|
||||||
</button>
|
</span>
|
||||||
{/each}
|
</div>
|
||||||
{:else if !usuarios?.data}
|
</button>
|
||||||
<!-- Loading -->
|
{/each}
|
||||||
<div class="flex items-center justify-center h-full">
|
{:else if !usuarios?.data}
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<!-- Loading -->
|
||||||
</div>
|
<div class="flex h-full items-center justify-center">
|
||||||
{:else}
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
<!-- Nenhum usuário encontrado -->
|
</div>
|
||||||
<div
|
{:else}
|
||||||
class="flex flex-col items-center justify-center h-full text-center px-4"
|
<!-- Nenhum usuário encontrado -->
|
||||||
>
|
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||||
<svg
|
<UsersRound class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
||||||
fill="none"
|
</div>
|
||||||
viewBox="0 0 24 24"
|
{/if}
|
||||||
stroke-width="1.5"
|
{:else}
|
||||||
stroke="currentColor"
|
<!-- Lista de conversas (grupos e salas) -->
|
||||||
class="w-16 h-16 text-base-content/30 mb-4"
|
{#if conversas?.data && conversasFiltradas.length > 0}
|
||||||
>
|
{#each conversasFiltradas as conversa (conversa._id)}
|
||||||
<path
|
<button
|
||||||
stroke-linecap="round"
|
type="button"
|
||||||
stroke-linejoin="round"
|
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
|
||||||
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"
|
? 'cursor-wait opacity-50'
|
||||||
/>
|
: 'cursor-pointer'}"
|
||||||
</svg>
|
onclick={() => handleClickConversa(conversa)}
|
||||||
<p class="text-base-content/70">Nenhum usuário encontrado</p>
|
disabled={processando}
|
||||||
</div>
|
>
|
||||||
{/if}
|
<!-- Ícone de grupo/sala -->
|
||||||
{:else}
|
<div
|
||||||
<!-- Lista de conversas (grupos e salas) -->
|
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
||||||
{#if conversas?.data && conversasFiltradas().length > 0}
|
'sala_reuniao'
|
||||||
{#each conversasFiltradas() as conversa (conversa._id)}
|
? 'border border-blue-300/30 bg-linear-to-br from-blue-500/20 to-purple-500/20'
|
||||||
<button
|
: 'from-primary/20 to-secondary/20 border-primary/30 border bg-linear-to-br'}"
|
||||||
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
|
{#if conversa.tipo === 'sala_reuniao'}
|
||||||
? 'opacity-50 cursor-wait'
|
<UsersRound class="h-5 w-5 text-blue-500" strokeWidth={2} />
|
||||||
: 'cursor-pointer'}"
|
{:else}
|
||||||
onclick={() => handleClickConversa(conversa)}
|
<Users class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
disabled={processando}
|
{/if}
|
||||||
>
|
</div>
|
||||||
<!-- Ícone de grupo/sala -->
|
|
||||||
<div
|
|
||||||
class="shrink-0 w-10 h-10 rounded-xl flex items-center justify-center transition-all duration-300 hover:scale-110 {conversa.tipo ===
|
|
||||||
'sala_reuniao'
|
|
||||||
? 'bg-linear-to-br from-blue-500/20 to-purple-500/20 border border-blue-300/30'
|
|
||||||
: 'bg-linear-to-br from-primary/20 to-secondary/20 border border-primary/30'}"
|
|
||||||
>
|
|
||||||
{#if conversa.tipo === "sala_reuniao"}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="w-5 h-5 text-blue-500"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="2"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="w-5 h-5 text-primary"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M18 18.72a9.094 9.094 0 0 0 3.741-.479 3 3 0 0 0-4.682-2.72m.94 3.198.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0 1 12 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 0 1 6 18.719m12 0a5.971 5.971 0 0 0-.941-3.197m0 0A5.995 5.995 0 0 0 12 12.75a5.995 5.995 0 0 0-5.058 2.772m0 0a3 3 0 0 0-4.681 2.72 8.986 8.986 0 0 0 3.74.477m.94-3.197a5.971 5.971 0 0 0-.94 3.197M15 6.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Zm6 3a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Zm-13.5 0a2.25 2.25 0 1 1-4.5 0 2.25 2.25 0 0 1 4.5 0Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conteúdo -->
|
<!-- Conteúdo -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="mb-1 flex items-center justify-between">
|
||||||
<p class="font-semibold text-base-content truncate">
|
<p class="text-base-content truncate font-semibold">
|
||||||
{conversa.nome ||
|
{conversa.nome ||
|
||||||
(conversa.tipo === "sala_reuniao"
|
(conversa.tipo === 'sala_reuniao' ? 'Sala sem nome' : 'Grupo sem nome')}
|
||||||
? "Sala sem nome"
|
</p>
|
||||||
: "Grupo sem nome")}
|
{#if conversa.naoLidas > 0}
|
||||||
</p>
|
<span class="badge badge-primary badge-sm">{conversa.naoLidas}</span>
|
||||||
{#if conversa.naoLidas > 0}
|
{/if}
|
||||||
<span class="badge badge-primary badge-sm"
|
</div>
|
||||||
>{conversa.naoLidas}</span
|
<div class="flex items-center gap-2">
|
||||||
>
|
<span
|
||||||
{/if}
|
class="rounded-full px-2 py-0.5 text-xs {conversa.tipo === 'sala_reuniao'
|
||||||
</div>
|
? 'bg-blue-500/20 text-blue-500'
|
||||||
<div class="flex items-center gap-2">
|
: 'bg-primary/20 text-primary'}"
|
||||||
<span
|
>
|
||||||
class="text-xs px-2 py-0.5 rounded-full {conversa.tipo ===
|
{conversa.tipo === 'sala_reuniao' ? '👑 Sala de Reunião' : '👥 Grupo'}
|
||||||
'sala_reuniao'
|
</span>
|
||||||
? 'bg-blue-500/20 text-blue-500'
|
{#if conversa.participantesInfo}
|
||||||
: 'bg-primary/20 text-primary'}"
|
<span class="text-base-content/50 text-xs">
|
||||||
>
|
{conversa.participantesInfo.length} participante{conversa.participantesInfo
|
||||||
{conversa.tipo === "sala_reuniao"
|
.length !== 1
|
||||||
? "👑 Sala de Reunião"
|
? 's'
|
||||||
: "👥 Grupo"}
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
{#if conversa.participantesInfo}
|
{/if}
|
||||||
<span class="text-xs text-base-content/50">
|
</div>
|
||||||
{conversa.participantesInfo.length} participante{conversa
|
</div>
|
||||||
.participantesInfo.length !== 1
|
</button>
|
||||||
? "s"
|
{/each}
|
||||||
: ""}
|
{:else if !conversas?.data}
|
||||||
</span>
|
<!-- Loading -->
|
||||||
{/if}
|
<div class="flex h-full items-center justify-center">
|
||||||
</div>
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
{:else}
|
||||||
{/each}
|
<!-- Nenhuma conversa encontrada -->
|
||||||
{:else if !conversas?.data}
|
<div class="flex h-full flex-col items-center justify-center px-4 text-center">
|
||||||
<!-- Loading -->
|
<MessageSquare class="text-base-content/30 mb-4 h-16 w-16" strokeWidth={1.5} />
|
||||||
<div class="flex items-center justify-center h-full">
|
<p class="text-base-content/70 mb-2 font-medium">Nenhuma conversa encontrada</p>
|
||||||
<span class="loading loading-spinner loading-lg"></span>
|
<p class="text-base-content/50 text-sm">Crie um grupo ou sala de reunião para começar</p>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{/if}
|
||||||
<!-- Nenhuma conversa encontrada -->
|
{/if}
|
||||||
<div
|
</div>
|
||||||
class="flex flex-col items-center justify-center h-full text-center px-4"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke="currentColor"
|
|
||||||
class="w-16 h-16 text-base-content/30 mb-4"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<p class="text-base-content/70 font-medium mb-2">
|
|
||||||
Nenhuma conversa encontrada
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-base-content/50">
|
|
||||||
Crie um grupo ou sala de reunião para começar
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal de Nova Conversa -->
|
<!-- Modal de Nova Conversa -->
|
||||||
{#if showNewConversationModal}
|
{#if showNewConversationModal}
|
||||||
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
|
<NewConversationModal onClose={() => (showNewConversationModal = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,40 +1,57 @@
|
|||||||
<script lang="ts">
|
<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 {
|
import {
|
||||||
|
abrirChat,
|
||||||
|
abrirConversa,
|
||||||
chatAberto,
|
chatAberto,
|
||||||
chatMinimizado,
|
chatMinimizado,
|
||||||
conversaAtiva,
|
conversaAtiva,
|
||||||
fecharChat,
|
fecharChat,
|
||||||
minimizarChat,
|
|
||||||
maximizarChat,
|
maximizarChat,
|
||||||
abrirChat,
|
minimizarChat,
|
||||||
abrirConversa
|
notificacaoAtiva
|
||||||
} from '$lib/stores/chatStore';
|
} 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 ChatList from './ChatList.svelte';
|
||||||
import ChatWindow from './ChatWindow.svelte';
|
import ChatWindow from './ChatWindow.svelte';
|
||||||
|
import { MessageSquare, Minus, Maximize2, X, Bell } from 'lucide-svelte';
|
||||||
import { SvelteSet } from 'svelte/reactivity';
|
import { obterCoresDoTema, obterTemaPersistidoNoLocalStorage } from '$lib/utils/temas';
|
||||||
|
|
||||||
const count = useQuery(api.chat.contarNotificacoesNaoLidas, {});
|
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, {});
|
const meuPerfilQuery = useQuery(api.usuarios.obterPerfil, {});
|
||||||
// Usuário atual
|
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
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 isOpen = $derived(false);
|
||||||
let isMinimized = $derived(false);
|
let isMinimized = $derived(false);
|
||||||
let activeConversation = $state<string | null>(null);
|
let activeConversation = $state<string | null>(null);
|
||||||
|
|
||||||
// Função para obter a URL do avatar/foto do usuário logado
|
// Função para obter a URL do avatar/foto do usuário logado (otimizada)
|
||||||
const avatarUrlDoUsuario = $derived(() => {
|
let avatarUrlDoUsuario = $derived(() => {
|
||||||
const usuario = currentUser?.data;
|
// Priorizar perfil (tem mais informações)
|
||||||
if (!usuario) return null;
|
const perfil = meuPerfilQuery?.data;
|
||||||
|
if (perfil?.fotoPerfilUrl) {
|
||||||
|
return perfil.fotoPerfilUrl;
|
||||||
|
}
|
||||||
|
|
||||||
// Prioridade: fotoPerfilUrl > avatar > fallback com nome
|
// Fallback para currentUser
|
||||||
if (usuario.fotoPerfilUrl) {
|
const usuario = currentUser?.data;
|
||||||
|
if (usuario?.fotoPerfilUrl) {
|
||||||
return usuario.fotoPerfilUrl;
|
return usuario.fotoPerfilUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +68,13 @@
|
|||||||
let dragThreshold = $state(5); // Distância mínima em pixels para considerar arrastar
|
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 hasMoved = $state(false); // Flag para verificar se houve movimento durante o arrastar
|
||||||
let shouldPreventClick = $state(false); // Flag para prevenir clique após 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)
|
// Tamanho da janela (redimensionável)
|
||||||
const MIN_WIDTH = 300;
|
const MIN_WIDTH = 300;
|
||||||
@@ -279,7 +303,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Garantir que X também está dentro dos limites
|
// 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
|
// Aplicar novos valores apenas se necessário
|
||||||
if (newX !== position.x || newY !== position.y) {
|
if (newX !== position.x || newY !== position.y) {
|
||||||
@@ -398,47 +422,29 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle para evitar execuções muito frequentes do effect
|
||||||
|
let ultimaExecucaoNotificacao = $state(0);
|
||||||
|
const THROTTLE_NOTIFICACAO_MS = 1000; // 1 segundo entre execuções
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (todasConversas?.data && currentUser?.data?._id) {
|
const agora = Date.now();
|
||||||
|
const tempoDesdeUltimaExecucao = agora - ultimaExecucaoNotificacao;
|
||||||
|
|
||||||
|
// Throttle: só executar se passou tempo suficiente
|
||||||
|
if (tempoDesdeUltimaExecucao < THROTTLE_NOTIFICACAO_MS && ultimaExecucaoNotificacao > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (todasConversas?.data && meuId()) {
|
||||||
|
ultimaExecucaoNotificacao = agora;
|
||||||
const conversas = todasConversas.data as ConversaComTimestamp[];
|
const conversas = todasConversas.data as ConversaComTimestamp[];
|
||||||
|
const meuIdAtual = meuId();
|
||||||
|
|
||||||
// Encontrar conversas com novas mensagens
|
if (!meuIdAtual) {
|
||||||
// Obter ID do usuário logado de forma robusta
|
console.warn('⚠️ [ChatWidget] Não foi possível identificar o ID do usuário logado');
|
||||||
// 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
|
|
||||||
});
|
|
||||||
return;
|
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
conversas.forEach((conv) => {
|
conversas.forEach((conv) => {
|
||||||
if (!conv.ultimaMensagemTimestamp) return;
|
if (!conv.ultimaMensagemTimestamp) return;
|
||||||
|
|
||||||
@@ -448,21 +454,8 @@
|
|||||||
? String(conv.ultimaMensagemRemetenteId).trim()
|
? String(conv.ultimaMensagemRemetenteId).trim()
|
||||||
: null;
|
: 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
|
// 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
|
// Mensagem enviada pelo próprio usuário - não tocar beep nem mostrar notificação
|
||||||
// Marcar como notificada para evitar processamento futuro
|
// Marcar como notificada para evitar processamento futuro
|
||||||
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
const mensagemId = `${conv._id}-${conv.ultimaMensagemTimestamp}`;
|
||||||
@@ -483,14 +476,29 @@
|
|||||||
const conversaIdStr = String(conv._id).trim();
|
const conversaIdStr = String(conv._id).trim();
|
||||||
const estaConversaEstaAberta = conversaAtivaId === conversaIdStr;
|
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:
|
// Só mostrar notificação se:
|
||||||
// 1. O chat não está aberto OU
|
// 1. O chat não está aberto OU
|
||||||
// 2. O chat está aberto mas não estamos vendo essa conversa específica
|
// 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)
|
// Marcar como notificada ANTES de mostrar notificação (evita duplicação)
|
||||||
mensagensNotificadasGlobal.add(mensagemId);
|
mensagensNotificadasGlobal.add(mensagemId);
|
||||||
salvarMensagensNotificadasGlobal();
|
salvarMensagensNotificadasGlobal();
|
||||||
|
|
||||||
|
// Registrar notificação ativa no store global
|
||||||
|
notificacaoAtiva.set({
|
||||||
|
conversaId: conversaIdStr,
|
||||||
|
mensagemId,
|
||||||
|
componente: 'widget'
|
||||||
|
});
|
||||||
|
|
||||||
// Tocar som de notificação (apenas uma vez)
|
// Tocar som de notificação (apenas uma vez)
|
||||||
tocarSomNotificacaoGlobal();
|
tocarSomNotificacaoGlobal();
|
||||||
|
|
||||||
@@ -502,13 +510,17 @@
|
|||||||
};
|
};
|
||||||
showGlobalNotificationPopup = true;
|
showGlobalNotificationPopup = true;
|
||||||
|
|
||||||
// Ocultar popup após 5 segundos
|
// Ocultar popup após 5 segundos - garantir limpeza
|
||||||
if (globalNotificationTimeout) {
|
if (globalNotificationTimeout) {
|
||||||
clearTimeout(globalNotificationTimeout);
|
clearTimeout(globalNotificationTimeout);
|
||||||
|
globalNotificationTimeout = null;
|
||||||
}
|
}
|
||||||
globalNotificationTimeout = setTimeout(() => {
|
globalNotificationTimeout = setTimeout(() => {
|
||||||
showGlobalNotificationPopup = false;
|
showGlobalNotificationPopup = false;
|
||||||
globalNotificationMessage = null;
|
globalNotificationMessage = null;
|
||||||
|
globalNotificationTimeout = null;
|
||||||
|
// Limpar notificação ativa do store
|
||||||
|
notificacaoAtiva.set(null);
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
|
// Chat está aberto e estamos vendo essa conversa - marcar como visualizada
|
||||||
@@ -518,6 +530,14 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup: limpar timeout quando o effect for desmontado
|
||||||
|
return () => {
|
||||||
|
if (globalNotificationTimeout) {
|
||||||
|
clearTimeout(globalNotificationTimeout);
|
||||||
|
globalNotificationTimeout = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleToggle() {
|
function handleToggle() {
|
||||||
@@ -576,6 +596,56 @@
|
|||||||
maximizarChat();
|
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
|
// Funcionalidade de arrastar
|
||||||
function handleMouseDown(e: MouseEvent) {
|
function handleMouseDown(e: MouseEvent) {
|
||||||
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
|
if (e.button !== 0 || !position) return; // Apenas botão esquerdo
|
||||||
@@ -612,6 +682,136 @@
|
|||||||
// Não prevenir default para permitir clique funcionar se não houver movimento
|
// 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) {
|
function handleMouseMove(e: MouseEvent) {
|
||||||
if (isResizing) {
|
if (isResizing) {
|
||||||
handleResizeMove(e);
|
handleResizeMove(e);
|
||||||
@@ -746,12 +946,90 @@
|
|||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
window.addEventListener('mousemove', handleMouseMove);
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('mouseup', handleMouseUp);
|
||||||
|
window.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
window.addEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
window.removeEventListener('mousemove', handleMouseMove);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('mouseup', handleMouseUp);
|
||||||
|
window.removeEventListener('touchmove', handleTouchMove);
|
||||||
|
window.removeEventListener('touchend', handleTouchEnd);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Obter cores do tema atual (reativo)
|
||||||
|
let coresTema = $state(obterCoresDoTema());
|
||||||
|
|
||||||
|
// Atualizar cores quando o tema mudar
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const atualizarCores = () => {
|
||||||
|
coresTema = obterCoresDoTema();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Atualizar cores inicialmente
|
||||||
|
atualizarCores();
|
||||||
|
|
||||||
|
// Escutar mudanças de tema
|
||||||
|
window.addEventListener('themechange', atualizarCores);
|
||||||
|
|
||||||
|
// Observar mudanças no atributo data-theme do HTML
|
||||||
|
const observer = new MutationObserver(atualizarCores);
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
observer.observe(htmlElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('themechange', atualizarCores);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Função para obter gradiente do tema
|
||||||
|
function obterGradienteTema() {
|
||||||
|
const primary = coresTema.primary;
|
||||||
|
// Criar variações da cor primária para o gradiente
|
||||||
|
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 50%, ${primary}bb 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter rgba da cor primária
|
||||||
|
function obterPrimariaRgba(alpha: number = 1) {
|
||||||
|
const primary = coresTema.primary.trim();
|
||||||
|
// Se já for rgba, extrair os valores
|
||||||
|
if (primary.startsWith('rgba')) {
|
||||||
|
const match = primary.match(/rgba?\(([^)]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
const values = match[1].split(',').map(v => v.trim());
|
||||||
|
if (values.length >= 3) {
|
||||||
|
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Se for hex, converter
|
||||||
|
if (primary.startsWith('#')) {
|
||||||
|
const hex = primary.replace('#', '');
|
||||||
|
if (hex.length === 6) {
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Se for hsl, converter para hsla
|
||||||
|
if (primary.startsWith('hsl')) {
|
||||||
|
const match = primary.match(/hsl\(([^)]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
return `hsla(${match[1]}, ${alpha})`;
|
||||||
|
}
|
||||||
|
// Fallback: tentar adicionar alpha
|
||||||
|
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
|
||||||
|
}
|
||||||
|
// Fallback padrão
|
||||||
|
return `rgba(102, 126, 234, ${alpha})`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
|
<!-- Botão flutuante MODERNO E ARRASTÁVEL -->
|
||||||
@@ -772,10 +1050,10 @@
|
|||||||
bottom: {bottomPos};
|
bottom: {bottomPos};
|
||||||
right: {rightPos};
|
right: {rightPos};
|
||||||
position: fixed !important;
|
position: fixed !important;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
background: {obterGradienteTema()};
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 20px 60px -10px rgba(102, 126, 234, 0.5),
|
0 20px 60px -10px {obterPrimariaRgba(0.5)},
|
||||||
0 10px 30px -5px rgba(118, 75, 162, 0.4),
|
0 10px 30px -5px {obterPrimariaRgba(0.4)},
|
||||||
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: {isDragging ? 'grabbing' : 'grab'};
|
cursor: {isDragging ? 'grabbing' : 'grab'};
|
||||||
@@ -788,9 +1066,16 @@
|
|||||||
onmouseup={(e) => {
|
onmouseup={(e) => {
|
||||||
handleMouseUp(e);
|
handleMouseUp(e);
|
||||||
}}
|
}}
|
||||||
|
ontouchstart={handleTouchStart}
|
||||||
onclick={(e) => {
|
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
|
// Só executar toggle se não houve movimento durante o arrastar
|
||||||
if (!shouldPreventClick && !hasMoved) {
|
if (!shouldPreventClick && !hasMoved && !isTouching) {
|
||||||
handleToggle();
|
handleToggle();
|
||||||
} else {
|
} else {
|
||||||
// Prevenir clique se houve movimento
|
// Prevenir clique se houve movimento
|
||||||
@@ -799,49 +1084,66 @@
|
|||||||
shouldPreventClick = false; // Resetar após prevenir
|
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
|
<div
|
||||||
class="absolute inset-0 rounded-full opacity-0 transition-opacity duration-500 group-hover:opacity-100"
|
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>
|
></div>
|
||||||
|
<!-- Segunda camada para efeito de profundidade -->
|
||||||
<!-- Ondas de pulso -->
|
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 rounded-full"
|
class="absolute inset-0 cursor-pointer rounded-lg opacity-0 transition-opacity duration-700 group-hover:opacity-60"
|
||||||
style="animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;"
|
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>
|
></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 -->
|
<!-- Ícone de chat moderno com efeito 3D -->
|
||||||
<svg
|
<MessageSquare
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
class="relative z-10 h-10 w-10 text-white transition-all duration-500 group-hover:scale-110"
|
||||||
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"
|
|
||||||
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
|
style="filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));"
|
||||||
>
|
strokeWidth={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" />
|
/>
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Badge ULTRA PREMIUM com gradiente e brilho -->
|
<!-- Badge ULTRA PREMIUM com gradiente e brilho usando cores do tema -->
|
||||||
{#if count?.data && count.data > 0}
|
{#if count?.data && count.data > 0}
|
||||||
<span
|
<span
|
||||||
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
|
class="absolute -top-1 -right-1 z-20 flex h-8 w-8 items-center justify-center rounded-full text-xs font-black text-white"
|
||||||
style="
|
style="
|
||||||
background: linear-gradient(135deg, #ff416c, #ff4b2b);
|
background: {coresTema.error ? `linear-gradient(135deg, ${coresTema.error}, ${coresTema.error}dd)` : 'linear-gradient(135deg, #ff416c, #ff4b2b)'};
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 8px 24px -4px rgba(255, 65, 108, 0.6),
|
0 8px 24px -4px {coresTema.error ? obterPrimariaRgba(0.6).replace(coresTema.primary, coresTema.error) : 'rgba(255, 65, 108, 0.6)'},
|
||||||
0 4px 12px -2px rgba(255, 75, 43, 0.4),
|
0 4px 12px -2px {coresTema.error ? obterPrimariaRgba(0.4).replace(coresTema.primary, coresTema.error) : 'rgba(255, 75, 43, 0.4)'},
|
||||||
0 0 0 3px rgba(255, 255, 255, 0.3),
|
0 0 0 3px rgba(255, 255, 255, 0.3),
|
||||||
0 0 0 5px rgba(255, 65, 108, 0.2);
|
0 0 0 5px {coresTema.error ? obterPrimariaRgba(0.2).replace(coresTema.primary, coresTema.error) : 'rgba(255, 65, 108, 0.2)'};
|
||||||
animation: badge-bounce 2s ease-in-out infinite;
|
animation: badge-bounce 2s ease-in-out infinite;
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
@@ -894,8 +1196,8 @@
|
|||||||
<div
|
<div
|
||||||
class="relative flex items-center justify-between overflow-hidden px-6 py-5 text-white"
|
class="relative flex items-center justify-between overflow-hidden px-6 py-5 text-white"
|
||||||
style="
|
style="
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);
|
background: {obterGradienteTema()};
|
||||||
box-shadow: 0 8px 32px -4px rgba(102, 126, 234, 0.3);
|
box-shadow: 0 8px 32px -4px {obterPrimariaRgba(0.3)};
|
||||||
cursor: {isDragging ? 'grabbing' : 'grab'};
|
cursor: {isDragging ? 'grabbing' : 'grab'};
|
||||||
"
|
"
|
||||||
onmousedown={handleMouseDown}
|
onmousedown={handleMouseDown}
|
||||||
@@ -922,26 +1224,16 @@
|
|||||||
{#if avatarUrlDoUsuario()}
|
{#if avatarUrlDoUsuario()}
|
||||||
<img
|
<img
|
||||||
src={avatarUrlDoUsuario()}
|
src={avatarUrlDoUsuario()}
|
||||||
alt={currentUser?.data?.nome || 'Usuário'}
|
alt={meuPerfilQuery?.data?.nome || currentUser?.data?.nome || 'Usuário'}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Fallback: ícone de chat genérico -->
|
<!-- Fallback: ícone de chat genérico -->
|
||||||
<svg
|
<MessageSquare
|
||||||
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="h-5 w-5"
|
class="h-5 w-5"
|
||||||
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
|
style="filter: drop-shadow(0 2px 6px rgba(0,0,0,0.2));"
|
||||||
>
|
strokeWidth={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>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -963,19 +1255,11 @@
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
||||||
></div>
|
></div>
|
||||||
<svg
|
<Minus
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
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));"
|
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||||
>
|
strokeWidth={2.5}
|
||||||
<line x1="5" y1="12" x2="19" y2="12" />
|
/>
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Botão maximizar MODERNO -->
|
<!-- Botão maximizar MODERNO -->
|
||||||
@@ -989,21 +1273,11 @@
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
class="absolute inset-0 bg-white/0 transition-colors duration-300 group-hover:bg-white/20"
|
||||||
></div>
|
></div>
|
||||||
<svg
|
<Maximize2
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="relative z-10 h-5 w-5 transition-transform duration-300 group-hover:scale-110"
|
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));"
|
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||||
>
|
strokeWidth={2.5}
|
||||||
<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"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Botão fechar MODERNO -->
|
<!-- Botão fechar MODERNO -->
|
||||||
@@ -1017,20 +1291,11 @@
|
|||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30"
|
class="absolute inset-0 bg-red-500/0 transition-colors duration-300 group-hover:bg-red-500/30"
|
||||||
></div>
|
></div>
|
||||||
<svg
|
<X
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
class="relative z-10 h-5 w-5 transition-all duration-300 group-hover:scale-110 group-hover:rotate-90"
|
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));"
|
style="filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));"
|
||||||
>
|
strokeWidth={2.5}
|
||||||
<line x1="18" y1="6" x2="6" y2="18" />
|
/>
|
||||||
<line x1="6" y1="6" x2="18" y2="18" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1049,82 +1314,84 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pela borda superior"
|
aria-label="Redimensionar janela pela borda superior"
|
||||||
class="hover:bg-primary/20 absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
|
class="absolute top-0 right-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 24px 0 0;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'n')}
|
onmousedown={(e) => handleResizeStart(e, 'n')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'n')}
|
||||||
style="border-radius: 24px 24px 0 0;"
|
|
||||||
></div>
|
></div>
|
||||||
<!-- Bottom -->
|
<!-- Bottom -->
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pela borda inferior"
|
aria-label="Redimensionar janela pela borda inferior"
|
||||||
class="hover:bg-primary/20 absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
|
class="absolute right-0 bottom-0 left-0 z-50 h-2 cursor-ns-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 24px 24px;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 's')}
|
onmousedown={(e) => handleResizeStart(e, 's')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 's')}
|
||||||
style="border-radius: 0 0 24px 24px;"
|
|
||||||
></div>
|
></div>
|
||||||
<!-- Left -->
|
<!-- Left -->
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pela borda esquerda"
|
aria-label="Redimensionar janela pela borda esquerda"
|
||||||
class="hover:bg-primary/20 absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors"
|
class="absolute top-0 bottom-0 left-0 z-50 w-2 cursor-ew-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 0 0 24px;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'w')}
|
onmousedown={(e) => handleResizeStart(e, 'w')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'w')}
|
||||||
style="border-radius: 24px 0 0 24px;"
|
|
||||||
></div>
|
></div>
|
||||||
<!-- Right -->
|
<!-- Right -->
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pela borda direita"
|
aria-label="Redimensionar janela pela borda direita"
|
||||||
class="hover:bg-primary/20 absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors"
|
class="absolute top-0 right-0 bottom-0 z-50 w-2 cursor-ew-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 24px 24px 0;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'e')}
|
onmousedown={(e) => handleResizeStart(e, 'e')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'e')}
|
||||||
style="border-radius: 0 24px 24px 0;"
|
|
||||||
></div>
|
></div>
|
||||||
<!-- Corners -->
|
<!-- Corners -->
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pelo canto superior esquerdo"
|
aria-label="Redimensionar janela pelo canto superior esquerdo"
|
||||||
class="hover:bg-primary/20 absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
|
class="absolute top-0 left-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 24px 0 0 0;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'nw')}
|
onmousedown={(e) => handleResizeStart(e, 'nw')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'nw')}
|
||||||
style="border-radius: 24px 0 0 0;"
|
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pelo canto superior direito"
|
aria-label="Redimensionar janela pelo canto superior direito"
|
||||||
class="hover:bg-primary/20 absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
|
class="absolute top-0 right-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 24px 0 0;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'ne')}
|
onmousedown={(e) => handleResizeStart(e, 'ne')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'ne')}
|
||||||
style="border-radius: 0 24px 0 0;"
|
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pelo canto inferior esquerdo"
|
aria-label="Redimensionar janela pelo canto inferior esquerdo"
|
||||||
class="hover:bg-primary/20 absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
|
class="absolute bottom-0 left-0 z-50 h-4 w-4 cursor-nesw-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 0 24px;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'sw')}
|
onmousedown={(e) => handleResizeStart(e, 'sw')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'sw')}
|
||||||
style="border-radius: 0 0 0 24px;"
|
|
||||||
></div>
|
></div>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Redimensionar janela pelo canto inferior direito"
|
aria-label="Redimensionar janela pelo canto inferior direito"
|
||||||
class="hover:bg-primary/20 absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
|
class="absolute right-0 bottom-0 z-50 h-4 w-4 cursor-nwse-resize transition-colors"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}; border-radius: 0 0 24px 0;"
|
||||||
onmousedown={(e) => handleResizeStart(e, 'se')}
|
onmousedown={(e) => handleResizeStart(e, 'se')}
|
||||||
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')}
|
onkeydown={(e) => e.key === 'Enter' && handleResizeStart(e, 'se')}
|
||||||
style="border-radius: 0 0 24px 0;"
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Indicador de Conexão -->
|
||||||
|
|
||||||
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
|
<!-- Popup Global de Notificação de Nova Mensagem (quando chat está fechado/minimizado) -->
|
||||||
{#if showGlobalNotificationPopup && globalNotificationMessage}
|
{#if showGlobalNotificationPopup && globalNotificationMessage}
|
||||||
{@const notificationMsg = globalNotificationMessage}
|
{@const notificationMsg = globalNotificationMessage}
|
||||||
@@ -1132,8 +1399,8 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}"
|
aria-label="Abrir conversa: Nova mensagem de {notificationMsg.remetente}"
|
||||||
class="bg-base-100 border-primary/20 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl"
|
class="bg-base-100 fixed top-4 right-4 z-1000 max-w-sm cursor-pointer rounded-lg border p-4 shadow-2xl"
|
||||||
style="box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
|
style="border-color: {obterPrimariaRgba(0.2)}; box-shadow: 0 10px 40px -10px rgba(0,0,0,0.3); animation: slideInRight 0.3s ease-out;"
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
const conversaIdToOpen = notificationMsg?.conversaId;
|
const conversaIdToOpen = notificationMsg?.conversaId;
|
||||||
showGlobalNotificationPopup = false;
|
showGlobalNotificationPopup = false;
|
||||||
@@ -1164,21 +1431,8 @@
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="flex items-start gap-3">
|
<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">
|
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full" style="background-color: {obterPrimariaRgba(0.2)}">
|
||||||
<svg
|
<Bell class="h-5 w-5" style="color: {coresTema.primary}" strokeWidth={2} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-base-content mb-1 text-sm font-semibold">
|
<p class="text-base-content mb-1 text-sm font-semibold">
|
||||||
@@ -1187,7 +1441,7 @@
|
|||||||
<p class="text-base-content/70 line-clamp-2 text-xs">
|
<p class="text-base-content/70 line-clamp-2 text-xs">
|
||||||
{notificationMsg.conteudo}
|
{notificationMsg.conteudo}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-primary mt-1 text-xs">Clique para abrir</p>
|
<p class="mt-1 text-xs" style="color: {coresTema.primary}">Clique para abrir</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1202,16 +1456,7 @@
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<svg
|
<X class="h-4 w-4" strokeWidth={2} />
|
||||||
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>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1244,20 +1489,25 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Ondas de pulso para o botão flutuante */
|
/* Ondas de pulso para o botão flutuante - cores dinâmicas */
|
||||||
@keyframes pulse-ring {
|
@keyframes pulse-ring {
|
||||||
0% {
|
0% {
|
||||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0.5);
|
box-shadow: 0 0 0 0 var(--pulse-color, rgba(102, 126, 234, 0.5));
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
box-shadow: 0 0 0 15px rgba(102, 126, 234, 0);
|
box-shadow: 0 0 0 15px transparent;
|
||||||
}
|
}
|
||||||
100% {
|
100% {
|
||||||
box-shadow: 0 0 0 0 rgba(102, 126, 234, 0);
|
box-shadow: 0 0 0 0 var(--pulse-color, rgba(102, 126, 234, 0.5));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotação para anel de brilho */
|
/* Estilos para handles de redimensionamento com hover dinâmico */
|
||||||
|
[style*="--hover-bg"]:hover {
|
||||||
|
background-color: var(--hover-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotação para anel de brilho - suavizada */
|
||||||
@keyframes rotate {
|
@keyframes rotate {
|
||||||
from {
|
from {
|
||||||
transform: rotate(0deg);
|
transform: rotate(0deg);
|
||||||
@@ -1267,6 +1517,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 */
|
/* Efeito shimmer para o header */
|
||||||
@keyframes shimmer {
|
@keyframes shimmer {
|
||||||
0% {
|
0% {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { voltarParaLista } from '$lib/stores/chatStore';
|
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import MessageList from './MessageList.svelte';
|
import MessageList from './MessageList.svelte';
|
||||||
import MessageInput from './MessageInput.svelte';
|
import MessageInput from './MessageInput.svelte';
|
||||||
@@ -11,21 +9,28 @@
|
|||||||
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
|
import SalaReuniaoManager from './SalaReuniaoManager.svelte';
|
||||||
import CallWindow from '../call/CallWindow.svelte';
|
import CallWindow from '../call/CallWindow.svelte';
|
||||||
import ErrorModal from '../ErrorModal.svelte';
|
import ErrorModal from '../ErrorModal.svelte';
|
||||||
|
import E2EManagementModal from './E2EManagementModal.svelte';
|
||||||
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { traduzirErro } from '$lib/utils/erroHelpers';
|
import { traduzirErro } from '$lib/utils/erroHelpers';
|
||||||
import {
|
import {
|
||||||
Bell,
|
|
||||||
X,
|
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
LogOut,
|
Bell,
|
||||||
MoreVertical,
|
|
||||||
Users,
|
|
||||||
Clock,
|
Clock,
|
||||||
XCircle,
|
LogOut,
|
||||||
|
Users,
|
||||||
Phone,
|
Phone,
|
||||||
Video
|
Video,
|
||||||
|
Search,
|
||||||
|
Lock,
|
||||||
|
MoreVertical,
|
||||||
|
XCircle,
|
||||||
|
X
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
//import { getAvatarUrl } from '$lib/utils/avatarGenerator';
|
||||||
|
import { voltarParaLista } from '$lib/stores/chatStore';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
|
import { obterCoresDoTema } from '$lib/utils/temas';
|
||||||
|
|
||||||
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
//import { Bell, X, ArrowLeft, LogOut, MoreVertical, Users, Clock, XCircle } from 'lucide-svelte';
|
||||||
|
|
||||||
@@ -33,7 +38,7 @@
|
|||||||
conversaId: string;
|
conversaId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { conversaId }: Props = $props();
|
const { conversaId }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
@@ -43,26 +48,33 @@
|
|||||||
let showSalaManager = $state(false);
|
let showSalaManager = $state(false);
|
||||||
let showAdminMenu = $state(false);
|
let showAdminMenu = $state(false);
|
||||||
let showNotificacaoModal = $state(false);
|
let showNotificacaoModal = $state(false);
|
||||||
|
let showE2EModal = $state(false);
|
||||||
let iniciandoChamada = $state(false);
|
let iniciandoChamada = $state(false);
|
||||||
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
|
let chamadaAtiva = $state<Id<'chamadas'> | null>(null);
|
||||||
|
let showSearch = $state(false);
|
||||||
// Estados para modal de erro
|
let searchQuery = $state('');
|
||||||
|
let searchResults = $state<Array<unknown | undefined>>([]);
|
||||||
|
let searching = $state(false);
|
||||||
|
let selectedSearchResult = $state<number>(-1);
|
||||||
let showErrorModal = $state(false);
|
let showErrorModal = $state(false);
|
||||||
let errorTitle = $state('Erro');
|
let errorTitle = $state('Erro');
|
||||||
let errorMessage = $state('');
|
let errorMessage = $state('');
|
||||||
let errorInstructions = $state<string | undefined>(undefined);
|
let errorInstructions = $state<string | undefined>(undefined);
|
||||||
let errorDetails = $state<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
|
const chamadaAtivaQuery = useQuery(api.chamadas.obterChamadaAtiva, {
|
||||||
conversaId: conversaId as Id<'conversas'>
|
conversaId: conversaId as Id<'conversas'>
|
||||||
});
|
});
|
||||||
const chamadaAtual = $derived(chamadaAtivaQuery?.data);
|
let chamadaAtual = $derived(chamadaAtivaQuery?.data);
|
||||||
|
|
||||||
const conversas = useQuery(api.chat.listarConversas, {});
|
const conversas = useQuery(api.chat.listarConversas, {});
|
||||||
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
|
const isAdmin = useQuery(api.chat.verificarSeEhAdmin, {
|
||||||
conversaId: conversaId as Id<'conversas'>
|
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(() => {
|
const conversa = $derived(() => {
|
||||||
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
|
console.log('🔍 [ChatWindow] Buscando conversa ID:', conversaId);
|
||||||
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
|
console.log('📋 [ChatWindow] Conversas disponíveis:', conversas?.data);
|
||||||
@@ -145,7 +157,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Funções para chamadas
|
// Funções para chamadas
|
||||||
async function iniciarChamada(tipo: 'audio' | 'video', abrirEmNovaJanela: boolean = false): Promise<void> {
|
async function iniciarChamada(
|
||||||
|
tipo: 'audio' | 'video',
|
||||||
|
abrirEmNovaJanela: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
if (chamadaAtual) {
|
if (chamadaAtual) {
|
||||||
errorTitle = 'Chamada já em andamento';
|
errorTitle = 'Chamada já em andamento';
|
||||||
errorMessage =
|
errorMessage =
|
||||||
@@ -156,6 +171,24 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar se Jitsi está configurado
|
||||||
|
try {
|
||||||
|
const configJitsi = await client.query(api.configuracaoJitsi.obterConfigJitsi, {});
|
||||||
|
if (!configJitsi || !configJitsi.ativo) {
|
||||||
|
errorTitle = 'Jitsi não configurado';
|
||||||
|
errorMessage =
|
||||||
|
'O sistema de videochamadas não está configurado. Entre em contato com o administrador do sistema para configurar o Jitsi.';
|
||||||
|
errorInstructions =
|
||||||
|
'Um administrador precisa configurar o servidor Jitsi no painel de administração antes que as chamadas possam ser iniciadas.';
|
||||||
|
errorDetails = undefined;
|
||||||
|
showErrorModal = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
console.error('Erro ao verificar configuração Jitsi:', error);
|
||||||
|
// Continuar mesmo se houver erro na verificação (pode ser problema temporário)
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
iniciandoChamada = true;
|
iniciandoChamada = true;
|
||||||
const chamadaId = await client.mutation(api.chamadas.criarChamada, {
|
const chamadaId = await client.mutation(api.chamadas.criarChamada, {
|
||||||
@@ -167,11 +200,14 @@
|
|||||||
|
|
||||||
// Se deve abrir em nova janela
|
// Se deve abrir em nova janela
|
||||||
if (abrirEmNovaJanela && browser) {
|
if (abrirEmNovaJanela && browser) {
|
||||||
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import('$lib/utils/callWindowManager');
|
const { abrirCallWindowEmPopup, verificarSuportePopup } = await import(
|
||||||
|
'$lib/utils/callWindowManager'
|
||||||
|
);
|
||||||
|
|
||||||
if (!verificarSuportePopup()) {
|
if (!verificarSuportePopup()) {
|
||||||
errorTitle = 'Popups bloqueados';
|
errorTitle = 'Popups bloqueados';
|
||||||
errorMessage = 'Seu navegador está bloqueando popups. Por favor, permita popups para este site e tente novamente.';
|
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.';
|
errorInstructions = 'Verifique as configurações do seu navegador para permitir popups.';
|
||||||
showErrorModal = true;
|
showErrorModal = true;
|
||||||
return;
|
return;
|
||||||
@@ -240,26 +276,87 @@
|
|||||||
|
|
||||||
// Verificar se usuário é anfitrião da chamada atual
|
// Verificar se usuário é anfitrião da chamada atual
|
||||||
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
const meuPerfil = useQuery(api.auth.getCurrentUser, {});
|
||||||
const souAnfitriao = $derived(
|
let souAnfitriao = $derived(
|
||||||
chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false
|
chamadaAtual && meuPerfil?.data ? chamadaAtual.criadoPor === meuPerfil.data._id : false
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Obter cores do tema atual (reativo)
|
||||||
|
let coresTema = $state(obterCoresDoTema());
|
||||||
|
|
||||||
|
// Atualizar cores quando o tema mudar
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const atualizarCores = () => {
|
||||||
|
coresTema = obterCoresDoTema();
|
||||||
|
};
|
||||||
|
|
||||||
|
atualizarCores();
|
||||||
|
|
||||||
|
window.addEventListener('themechange', atualizarCores);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(atualizarCores);
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
observer.observe(htmlElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('themechange', atualizarCores);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Função para obter rgba da cor primária
|
||||||
|
function obterPrimariaRgba(alpha: number = 1) {
|
||||||
|
const primary = coresTema.primary.trim();
|
||||||
|
if (primary.startsWith('rgba')) {
|
||||||
|
const match = primary.match(/rgba?\(([^)]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
const values = match[1].split(',').map(v => v.trim());
|
||||||
|
if (values.length >= 3) {
|
||||||
|
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primary.startsWith('#')) {
|
||||||
|
const hex = primary.replace('#', '');
|
||||||
|
if (hex.length === 6) {
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primary.startsWith('hsl')) {
|
||||||
|
const match = primary.match(/hsl\(([^)]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
return `hsla(${match[1]}, ${alpha})`;
|
||||||
|
}
|
||||||
|
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
|
||||||
|
}
|
||||||
|
return `rgba(102, 126, 234, ${alpha})`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
|
<div class="flex h-full flex-col" onclick={() => (showAdminMenu = false)}>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div
|
<div
|
||||||
class="border-base-300 bg-base-200 flex items-center gap-3 border-b px-4 py-3"
|
class="border-base-300 flex items-center gap-3 border-b px-4 py-3"
|
||||||
|
style="background-color: {coresTema.base200}; border-color: {coresTema.base300};"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<!-- Botão Voltar -->
|
<!-- Botão Voltar -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-circle hover:bg-primary/20 transition-all duration-200 hover:scale-110"
|
class="btn btn-sm btn-circle transition-all duration-200 hover:scale-110"
|
||||||
|
style="--hover-bg: {obterPrimariaRgba(0.2)}"
|
||||||
onclick={voltarParaLista}
|
onclick={voltarParaLista}
|
||||||
aria-label="Voltar"
|
aria-label="Voltar"
|
||||||
title="Voltar para lista de conversas"
|
title="Voltar para lista de conversas"
|
||||||
>
|
>
|
||||||
<ArrowLeft class="text-primary h-6 w-6" strokeWidth={2.5} />
|
<ArrowLeft class="h-6 w-6" style="color: {coresTema.primary}" strokeWidth={2.5} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Avatar e Info -->
|
<!-- Avatar e Info -->
|
||||||
@@ -269,9 +366,10 @@
|
|||||||
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
|
fotoPerfilUrl={conversa()?.outroUsuario?.fotoPerfilUrl}
|
||||||
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
|
nome={conversa()?.outroUsuario?.nome || 'Usuário'}
|
||||||
size="md"
|
size="md"
|
||||||
|
userId={conversa()?.outroUsuario?._id}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="bg-primary/20 flex h-10 w-10 items-center justify-center rounded-full text-xl">
|
<div class="flex h-10 w-10 items-center justify-center rounded-full text-xl" style="background-color: {obterPrimariaRgba(0.2)}">
|
||||||
{getAvatarConversa()}
|
{getAvatarConversa()}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -283,9 +381,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="text-base-content truncate font-semibold">
|
<!-- Nome da conversa com indicador de criptografia E2E -->
|
||||||
{getNomeConversa()}
|
<div class="flex items-center gap-2">
|
||||||
</p>
|
<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()}
|
{#if getStatusMensagem()}
|
||||||
<p class="text-base-content/60 truncate text-xs">
|
<p class="text-base-content/60 truncate text-xs">
|
||||||
{getStatusMensagem()}
|
{getStatusMensagem()}
|
||||||
@@ -308,7 +426,7 @@
|
|||||||
{conversa()?.participantesInfo?.length || 0}
|
{conversa()?.participantesInfo?.length || 0}
|
||||||
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
|
{conversa()?.participantesInfo?.length === 1 ? 'participante' : 'participantes'}
|
||||||
</p>
|
</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 items-center gap-2">
|
||||||
<div class="flex -space-x-2">
|
<div class="flex -space-x-2">
|
||||||
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
{#each conversa()?.participantesInfo.slice(0, 5) as participante (participante._id)}
|
||||||
@@ -342,7 +460,8 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
|
{#if conversa()?.tipo === 'sala_reuniao' && isAdmin?.data}
|
||||||
<span
|
<span
|
||||||
class="text-primary ml-1 text-[10px] font-semibold whitespace-nowrap"
|
class="ml-1 text-[10px] font-semibold whitespace-nowrap"
|
||||||
|
style="color: {coresTema.primary}"
|
||||||
title="Você é administrador desta sala">• Admin</span
|
title="Você é administrador desta sala">• Admin</span
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -354,6 +473,32 @@
|
|||||||
|
|
||||||
<!-- Botões de ação -->
|
<!-- Botões de ação -->
|
||||||
<div class="flex items-center gap-1">
|
<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 -->
|
<!-- Botões de Chamada -->
|
||||||
{#if !chamadaAtual && !chamadaAtiva}
|
{#if !chamadaAtual && !chamadaAtiva}
|
||||||
<div class="dropdown dropdown-end">
|
<div class="dropdown dropdown-end">
|
||||||
@@ -368,11 +513,11 @@
|
|||||||
aria-label="Ligação de áudio"
|
aria-label="Ligação de áudio"
|
||||||
title="Iniciar ligação de áudio"
|
title="Iniciar ligação de áudio"
|
||||||
>
|
>
|
||||||
<Phone class="h-5 w-5" strokeWidth={2} />
|
<Phone class="h-5 w-5 text-white" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300"
|
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -414,11 +559,11 @@
|
|||||||
aria-label="Ligação de vídeo"
|
aria-label="Ligação de vídeo"
|
||||||
title="Iniciar ligação de vídeo"
|
title="Iniciar ligação de vídeo"
|
||||||
>
|
>
|
||||||
<Video class="h-5 w-5" strokeWidth={2} />
|
<Video class="h-5 w-5 text-white" strokeWidth={2} />
|
||||||
</button>
|
</button>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content menu bg-base-100 rounded-box z-[100] w-52 p-2 shadow-lg border border-base-300"
|
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-[100] w-52 border p-2 shadow-lg"
|
||||||
>
|
>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -569,6 +714,27 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/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 -->
|
<!-- Botão Agendar MODERNO -->
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -589,6 +755,112 @@
|
|||||||
</div>
|
</div>
|
||||||
</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"
|
||||||
|
style={index === selectedSearchResult ? `background-color: ${obterPrimariaRgba(0.1)}` : ''}
|
||||||
|
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="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-full"
|
||||||
|
style="background-color: {obterPrimariaRgba(0.2)}"
|
||||||
|
>
|
||||||
|
{#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 -->
|
<!-- Mensagens -->
|
||||||
<div class="min-h-0 flex-1 overflow-hidden">
|
<div class="min-h-0 flex-1 overflow-hidden">
|
||||||
<MessageList conversaId={conversaId as Id<'conversas'>} />
|
<MessageList conversaId={conversaId as Id<'conversas'>} />
|
||||||
@@ -606,6 +878,14 @@
|
|||||||
conversaId={conversaId as Id<'conversas'>}
|
conversaId={conversaId as Id<'conversas'>}
|
||||||
onClose={() => (showScheduleModal = false)}
|
onClose={() => (showScheduleModal = false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Modal de Gerenciamento E2E -->
|
||||||
|
{#if showE2EModal}
|
||||||
|
<E2EManagementModal
|
||||||
|
conversaId={conversaId as Id<'conversas'>}
|
||||||
|
onClose={() => (showE2EModal = false)}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Modal de Gerenciamento de Sala -->
|
<!-- Modal de Gerenciamento de Sala -->
|
||||||
@@ -640,7 +920,7 @@
|
|||||||
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
|
<div class="modal-box max-w-md" onclick={(e) => e.stopPropagation()}>
|
||||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||||
<h2 class="flex items-center gap-2 text-xl font-semibold">
|
<h2 class="flex items-center gap-2 text-xl font-semibold">
|
||||||
<Bell class="text-primary h-5 w-5" />
|
<Bell class="h-5 w-5" style="color: {coresTema.primary}" />
|
||||||
Enviar Notificação
|
Enviar Notificação
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
@@ -731,3 +1011,10 @@
|
|||||||
details={errorInstructions || errorDetails}
|
details={errorInstructions || errorDetails}
|
||||||
onClose={fecharErrorModal}
|
onClose={fecharErrorModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Estilos para hover dinâmico com cores do tema */
|
||||||
|
[style*="--hover-bg"]:hover {
|
||||||
|
background-color: var(--hover-bg) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
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>
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,26 +1,26 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { abrirConversa } from '$lib/stores/chatStore';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
|
||||||
import UserAvatar from './UserAvatar.svelte';
|
|
||||||
import {
|
import {
|
||||||
|
ChevronRight,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
User,
|
User,
|
||||||
Users,
|
Users,
|
||||||
|
UserX,
|
||||||
Video,
|
Video,
|
||||||
X,
|
X
|
||||||
Search,
|
|
||||||
ChevronRight,
|
|
||||||
Plus,
|
|
||||||
UserX
|
|
||||||
} from 'lucide-svelte';
|
} from 'lucide-svelte';
|
||||||
|
import { abrirConversa } from '$lib/stores/chatStore';
|
||||||
|
import UserAvatar from './UserAvatar.svelte';
|
||||||
|
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { onClose }: Props = $props();
|
const { onClose }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
const usuarios = useQuery(api.usuarios.listarParaChat, {});
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
let salaReuniaoName = $state('');
|
let salaReuniaoName = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
const usuariosFiltrados = $derived(() => {
|
let usuariosFiltrados = $derived(() => {
|
||||||
if (!usuarios?.data) return [];
|
if (!usuarios?.data) return [];
|
||||||
|
|
||||||
// Filtrar o próprio usuário
|
// Filtrar o próprio usuário
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +1,169 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useConvexClient } from "convex-svelte";
|
import { useConvexClient } from 'convex-svelte';
|
||||||
import { useQuery } from "convex-svelte";
|
import { useQuery } from 'convex-svelte';
|
||||||
import { api } from "@sgse-app/backend/convex/_generated/api";
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { onMount } from "svelte";
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
|
|
||||||
// Verificar se o usuário está autenticado antes de gerenciar presença
|
// Verificar se o usuário está autenticado antes de gerenciar presença
|
||||||
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
const currentUser = useQuery(api.auth.getCurrentUser, {});
|
||||||
const usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined);
|
let usuarioAutenticado = $derived(currentUser?.data !== null && currentUser?.data !== undefined);
|
||||||
|
|
||||||
// Token é passado automaticamente via interceptadores em +layout.svelte
|
// Token é passado automaticamente via interceptadores em +layout.svelte
|
||||||
|
|
||||||
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
let heartbeatInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
let inactivityTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||||
let lastActivity = Date.now();
|
let lastActivity = Date.now();
|
||||||
|
let lastStatusUpdate = 0;
|
||||||
|
let pendingStatusUpdate: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const STATUS_UPDATE_THROTTLE = 5000; // 5 segundos entre atualizações
|
||||||
|
|
||||||
// Detectar atividade do usuário
|
// Função auxiliar para atualizar status com throttle e tratamento de erro
|
||||||
function handleActivity() {
|
async function atualizarStatusPresencaSeguro(
|
||||||
if (!usuarioAutenticado) return;
|
status: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao'
|
||||||
|
) {
|
||||||
|
if (!usuarioAutenticado) return;
|
||||||
|
|
||||||
lastActivity = Date.now();
|
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 timeout de inatividade anterior
|
// Limpar atualização pendente se houver
|
||||||
if (inactivityTimeout) {
|
if (pendingStatusUpdate) {
|
||||||
clearTimeout(inactivityTimeout);
|
clearTimeout(pendingStatusUpdate);
|
||||||
}
|
pendingStatusUpdate = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Configurar novo timeout (5 minutos)
|
lastStatusUpdate = now;
|
||||||
inactivityTimeout = setTimeout(() => {
|
|
||||||
if (usuarioAutenticado) {
|
|
||||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
try {
|
||||||
// Só configurar presença se usuário estiver autenticado
|
await client.mutation(api.chat.atualizarStatusPresenca, { status });
|
||||||
if (!usuarioAutenticado) return;
|
} 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configurar como online ao montar (apenas se autenticado)
|
// Detectar atividade do usuário
|
||||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
function handleActivity() {
|
||||||
|
if (!usuarioAutenticado) return;
|
||||||
|
|
||||||
// Heartbeat a cada 30 segundos (apenas se autenticado)
|
lastActivity = Date.now();
|
||||||
heartbeatInterval = setInterval(() => {
|
|
||||||
if (!usuarioAutenticado) {
|
|
||||||
if (heartbeatInterval) {
|
|
||||||
clearInterval(heartbeatInterval);
|
|
||||||
heartbeatInterval = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeSinceLastActivity = Date.now() - lastActivity;
|
// Limpar timeout de inatividade anterior
|
||||||
|
if (inactivityTimeout) {
|
||||||
|
clearTimeout(inactivityTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
// Se houve atividade nos últimos 5 minutos, manter online
|
// Configurar novo timeout (5 minutos)
|
||||||
if (timeSinceLastActivity < 5 * 60 * 1000) {
|
inactivityTimeout = setTimeout(
|
||||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
() => {
|
||||||
}
|
if (usuarioAutenticado) {
|
||||||
}, 30 * 1000);
|
atualizarStatusPresencaSeguro('ausente');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
5 * 60 * 1000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Listeners para detectar atividade
|
onMount(() => {
|
||||||
const events = ["mousedown", "keydown", "scroll", "touchstart"];
|
// Só configurar presença se usuário estiver autenticado
|
||||||
events.forEach((event) => {
|
if (!usuarioAutenticado) return;
|
||||||
window.addEventListener(event, handleActivity);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Configurar timeout inicial de inatividade
|
// Configurar como online ao montar (apenas se autenticado)
|
||||||
if (usuarioAutenticado) {
|
atualizarStatusPresencaSeguro('online');
|
||||||
handleActivity();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detectar quando a aba fica inativa/ativa
|
// Heartbeat a cada 30 segundos (apenas se autenticado)
|
||||||
function handleVisibilityChange() {
|
heartbeatInterval = setInterval(() => {
|
||||||
if (!usuarioAutenticado) return;
|
if (!usuarioAutenticado) {
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
heartbeatInterval = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (document.hidden) {
|
const timeSinceLastActivity = Date.now() - lastActivity;
|
||||||
// Aba ficou inativa
|
|
||||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "ausente" });
|
|
||||||
} else {
|
|
||||||
// Aba ficou ativa
|
|
||||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "online" });
|
|
||||||
handleActivity();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
// Se houve atividade nos últimos 5 minutos, manter online
|
||||||
|
if (timeSinceLastActivity < 5 * 60 * 1000) {
|
||||||
|
atualizarStatusPresencaSeguro('online');
|
||||||
|
}
|
||||||
|
}, 30 * 1000);
|
||||||
|
|
||||||
// Cleanup
|
// Listeners para detectar atividade
|
||||||
return () => {
|
const events = ['mousedown', 'keydown', 'scroll', 'touchstart'];
|
||||||
// Marcar como offline ao desmontar (apenas se autenticado)
|
events.forEach((event) => {
|
||||||
if (usuarioAutenticado) {
|
window.addEventListener(event, handleActivity);
|
||||||
client.mutation(api.chat.atualizarStatusPresenca, { status: "offline" });
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (heartbeatInterval) {
|
// Configurar timeout inicial de inatividade
|
||||||
clearInterval(heartbeatInterval);
|
if (usuarioAutenticado) {
|
||||||
}
|
handleActivity();
|
||||||
|
}
|
||||||
|
|
||||||
if (inactivityTimeout) {
|
// Detectar quando a aba fica inativa/ativa
|
||||||
clearTimeout(inactivityTimeout);
|
function handleVisibilityChange() {
|
||||||
}
|
if (!usuarioAutenticado) return;
|
||||||
|
|
||||||
events.forEach((event) => {
|
if (document.hidden) {
|
||||||
window.removeEventListener(event, handleActivity);
|
// Aba ficou inativa
|
||||||
});
|
atualizarStatusPresencaSeguro('ausente');
|
||||||
|
} else {
|
||||||
|
// Aba ficou ativa
|
||||||
|
atualizarStatusPresencaSeguro('online');
|
||||||
|
handleActivity();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
|
||||||
});
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
// Limpar atualização pendente
|
||||||
|
if (pendingStatusUpdate) {
|
||||||
|
clearTimeout(pendingStatusUpdate);
|
||||||
|
pendingStatusUpdate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marcar como offline ao desmontar (apenas se autenticado)
|
||||||
|
if (usuarioAutenticado) {
|
||||||
|
atualizarStatusPresencaSeguro('offline');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (heartbeatInterval) {
|
||||||
|
clearInterval(heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inactivityTimeout) {
|
||||||
|
clearTimeout(inactivityTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
events.forEach((event) => {
|
||||||
|
window.removeEventListener(event, handleActivity);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
};
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Componente invisível - apenas lógica -->
|
<!-- Componente invisível - apenas lógica -->
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
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 UserAvatar from './UserAvatar.svelte';
|
||||||
import UserStatusBadge from './UserStatusBadge.svelte';
|
import UserStatusBadge from './UserStatusBadge.svelte';
|
||||||
import { X, Users, UserPlus, ArrowUp, ArrowDown, Trash2, Search } from 'lucide-svelte';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversaId: Id<'conversas'>;
|
conversaId: Id<'conversas'>;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { conversaId, isAdmin, onClose }: Props = $props();
|
const { conversaId, isAdmin, onClose }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const conversas = useQuery(api.chat.listarConversas, {});
|
const conversas = useQuery(api.chat.listarConversas, {});
|
||||||
@@ -23,16 +23,16 @@
|
|||||||
let loading = $state<string | null>(null);
|
let loading = $state<string | null>(null);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
const conversa = $derived(() => {
|
let conversa = $derived(() => {
|
||||||
if (!conversas?.data) return null;
|
if (!conversas?.data) return null;
|
||||||
return conversas.data.find((c: any) => c._id === conversaId);
|
return conversas.data.find((c: any) => c._id === conversaId);
|
||||||
});
|
});
|
||||||
|
|
||||||
const todosUsuarios = $derived(() => {
|
let todosUsuarios = $derived(() => {
|
||||||
return todosUsuariosQuery?.data || [];
|
return todosUsuariosQuery?.data || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const participantes = $derived(() => {
|
let participantes = $derived(() => {
|
||||||
try {
|
try {
|
||||||
const conv = conversa();
|
const conv = conversa();
|
||||||
const usuarios = todosUsuarios();
|
const usuarios = todosUsuarios();
|
||||||
@@ -76,11 +76,11 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const administradoresIds = $derived(() => {
|
let administradoresIds = $derived(() => {
|
||||||
return conversa()?.administradores || [];
|
return conversa()?.administradores || [];
|
||||||
});
|
});
|
||||||
|
|
||||||
const usuariosDisponiveis = $derived(() => {
|
let usuariosDisponiveis = $derived(() => {
|
||||||
const usuarios = todosUsuarios();
|
const usuarios = todosUsuarios();
|
||||||
if (!usuarios || usuarios.length === 0) return [];
|
if (!usuarios || usuarios.length === 0) return [];
|
||||||
const participantesIds = conversa()?.participantes || [];
|
const participantesIds = conversa()?.participantes || [];
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const usuariosFiltrados = $derived(() => {
|
let usuariosFiltrados = $derived(() => {
|
||||||
const disponiveis = usuariosDisponiveis();
|
const disponiveis = usuariosDisponiveis();
|
||||||
if (!searchQuery.trim()) return disponiveis;
|
if (!searchQuery.trim()) return disponiveis;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ptBR } from 'date-fns/locale';
|
import { ptBR } from 'date-fns/locale';
|
||||||
import { Clock, X, Trash2 } from 'lucide-svelte';
|
import { Clock, Trash2, X } from 'lucide-svelte';
|
||||||
|
import { obterCoresDoTema } from '$lib/utils/temas';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
conversaId: Id<'conversas'>;
|
conversaId: Id<'conversas'>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { conversaId, onClose }: Props = $props();
|
const { conversaId, onClose }: Props = $props();
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
const mensagensAgendadas = useQuery(api.chat.obterMensagensAgendadas, {
|
||||||
@@ -23,6 +24,63 @@
|
|||||||
let hora = $state('');
|
let hora = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
|
||||||
|
// Obter cores do tema atual (reativo)
|
||||||
|
let coresTema = $state(obterCoresDoTema());
|
||||||
|
|
||||||
|
// Atualizar cores quando o tema mudar
|
||||||
|
$effect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const atualizarCores = () => {
|
||||||
|
coresTema = obterCoresDoTema();
|
||||||
|
};
|
||||||
|
|
||||||
|
atualizarCores();
|
||||||
|
|
||||||
|
window.addEventListener('themechange', atualizarCores);
|
||||||
|
|
||||||
|
const observer = new MutationObserver(atualizarCores);
|
||||||
|
const htmlElement = document.documentElement;
|
||||||
|
observer.observe(htmlElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['data-theme']
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('themechange', atualizarCores);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Função para obter rgba da cor primária
|
||||||
|
function obterPrimariaRgba(alpha: number = 1) {
|
||||||
|
const primary = coresTema.primary;
|
||||||
|
if (primary.startsWith('rgba')) {
|
||||||
|
const match = primary.match(/rgba?\(([^)]+)\)/);
|
||||||
|
if (match) {
|
||||||
|
const values = match[1].split(',');
|
||||||
|
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${alpha})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (primary.startsWith('#')) {
|
||||||
|
const hex = primary.replace('#', '');
|
||||||
|
const r = parseInt(hex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(hex.substring(4, 6), 16);
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
}
|
||||||
|
if (primary.startsWith('hsl')) {
|
||||||
|
return primary.replace(/\)$/, `, ${alpha})`).replace('hsl', 'hsla');
|
||||||
|
}
|
||||||
|
return `rgba(102, 126, 234, ${alpha})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Função para obter gradiente do tema
|
||||||
|
function obterGradienteTema() {
|
||||||
|
const primary = coresTema.primary;
|
||||||
|
return `linear-gradient(135deg, ${primary} 0%, ${primary}dd 100%)`;
|
||||||
|
}
|
||||||
|
|
||||||
// Rastrear mudanças nas mensagens agendadas
|
// Rastrear mudanças nas mensagens agendadas
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
|
console.log('📅 [ScheduleModal] Mensagens agendadas atualizadas:', mensagensAgendadas?.data);
|
||||||
@@ -186,7 +244,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
class="group relative overflow-hidden rounded-xl px-6 py-3 font-bold text-white transition-all duration-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); box-shadow: 0 8px 24px -4px rgba(102, 126, 234, 0.4);"
|
style="background: {obterGradienteTema()}; box-shadow: 0 8px 24px -4px {obterPrimariaRgba(0.4)};"
|
||||||
onclick={handleAgendar}
|
onclick={handleAgendar}
|
||||||
disabled={loading || !mensagem.trim() || !data || !hora}
|
disabled={loading || !mensagem.trim() || !data || !hora}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,13 +1,55 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { User } from 'lucide-svelte';
|
import { User } from 'lucide-svelte';
|
||||||
|
import { getCachedAvatar } from '$lib/utils/avatarCache';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
fotoPerfilUrl?: string | null;
|
fotoPerfilUrl?: string | null;
|
||||||
nome: string;
|
nome: string;
|
||||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
userId?: string; // ID do usuário para cache
|
||||||
}
|
}
|
||||||
|
|
||||||
let { 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 = {
|
const sizeClasses = {
|
||||||
xs: 'w-8 h-8',
|
xs: 'w-8 h-8',
|
||||||
@@ -30,11 +72,25 @@
|
|||||||
<div
|
<div
|
||||||
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
|
class={`${sizeClasses[size]} bg-base-200 text-base-content/50 flex items-center justify-center overflow-hidden rounded-full`}
|
||||||
>
|
>
|
||||||
{#if fotoPerfilUrl}
|
{#if loading}
|
||||||
|
<span class="loading loading-spinner loading-xs"></span>
|
||||||
|
{:else if cachedAvatarUrl}
|
||||||
|
<img
|
||||||
|
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
|
<img
|
||||||
src={fotoPerfilUrl}
|
src={fotoPerfilUrl}
|
||||||
alt={`Foto de perfil de ${nome}`}
|
alt={`Foto de perfil de ${nome}`}
|
||||||
class="h-full w-full object-cover"
|
class="h-full w-full object-cover"
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<User size={iconSizes[size]} />
|
<User size={iconSizes[size]} />
|
||||||
|
|||||||
@@ -1,75 +1,68 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
interface Props {
|
import { CheckCircle2, XCircle, AlertCircle, Plus, Video } from 'lucide-svelte';
|
||||||
status?: "online" | "offline" | "ausente" | "externo" | "em_reuniao";
|
|
||||||
size?: "sm" | "md" | "lg";
|
|
||||||
}
|
|
||||||
|
|
||||||
let { status = "offline", size = "md" }: Props = $props();
|
interface Props {
|
||||||
|
status?: 'online' | 'offline' | 'ausente' | 'externo' | 'em_reuniao';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
const sizeClasses = {
|
let { status = 'offline', size = 'md' }: Props = $props();
|
||||||
sm: "w-3 h-3",
|
|
||||||
md: "w-4 h-4",
|
|
||||||
lg: "w-5 h-5",
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusConfig = {
|
const sizeClasses = {
|
||||||
online: {
|
sm: 'w-3 h-3',
|
||||||
color: "bg-success",
|
md: 'w-4 h-4',
|
||||||
borderColor: "border-success",
|
lg: 'w-5 h-5'
|
||||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
};
|
||||||
<circle cx="12" cy="12" r="10" fill="#10b981"/>
|
|
||||||
<path d="M9 12l2 2 4-4" stroke="white" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>`,
|
|
||||||
label: "🟢 Online",
|
|
||||||
},
|
|
||||||
offline: {
|
|
||||||
color: "bg-base-300",
|
|
||||||
borderColor: "border-base-300",
|
|
||||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#9ca3af"/>
|
|
||||||
<path d="M8 8l8 8M16 8l-8 8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
</svg>`,
|
|
||||||
label: "⚫ Offline",
|
|
||||||
},
|
|
||||||
ausente: {
|
|
||||||
color: "bg-warning",
|
|
||||||
borderColor: "border-warning",
|
|
||||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#f59e0b"/>
|
|
||||||
<circle cx="12" cy="6" r="1.5" fill="white"/>
|
|
||||||
<path d="M12 10v4" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
</svg>`,
|
|
||||||
label: "🟡 Ausente",
|
|
||||||
},
|
|
||||||
externo: {
|
|
||||||
color: "bg-info",
|
|
||||||
borderColor: "border-info",
|
|
||||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#3b82f6"/>
|
|
||||||
<path d="M8 12h8M12 8v8" stroke="white" stroke-width="2" stroke-linecap="round"/>
|
|
||||||
</svg>`,
|
|
||||||
label: "🔵 Externo",
|
|
||||||
},
|
|
||||||
em_reuniao: {
|
|
||||||
color: "bg-error",
|
|
||||||
borderColor: "border-error",
|
|
||||||
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-full h-full">
|
|
||||||
<circle cx="12" cy="12" r="10" fill="#ef4444"/>
|
|
||||||
<rect x="8" y="8" width="8" height="8" fill="white" rx="1"/>
|
|
||||||
</svg>`,
|
|
||||||
label: "🔴 Em Reunião",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const config = $derived(statusConfig[status]);
|
const iconSizes = {
|
||||||
|
sm: 8,
|
||||||
|
md: 12,
|
||||||
|
lg: 16
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
online: {
|
||||||
|
color: 'bg-success',
|
||||||
|
borderColor: 'border-success',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
label: '🟢 Online'
|
||||||
|
},
|
||||||
|
offline: {
|
||||||
|
color: 'bg-base-300',
|
||||||
|
borderColor: 'border-base-300',
|
||||||
|
icon: XCircle,
|
||||||
|
label: '⚫ Offline'
|
||||||
|
},
|
||||||
|
ausente: {
|
||||||
|
color: 'bg-warning',
|
||||||
|
borderColor: 'border-warning',
|
||||||
|
icon: AlertCircle,
|
||||||
|
label: '🟡 Ausente'
|
||||||
|
},
|
||||||
|
externo: {
|
||||||
|
color: 'bg-info',
|
||||||
|
borderColor: 'border-info',
|
||||||
|
icon: Plus,
|
||||||
|
label: '🔵 Externo'
|
||||||
|
},
|
||||||
|
em_reuniao: {
|
||||||
|
color: 'bg-error',
|
||||||
|
borderColor: 'border-error',
|
||||||
|
icon: Video,
|
||||||
|
label: '🔴 Em Reunião'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = $derived(statusConfig[status]);
|
||||||
|
const IconComponent = $derived(config.icon);
|
||||||
|
const iconSize = $derived(iconSizes[size]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={`${sizeClasses[size]} rounded-full relative flex items-center justify-center`}
|
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); border: 2px solid white;"
|
style="box-shadow: 0 2px 8px rgba(0,0,0,0.15);"
|
||||||
title={config.label}
|
title={config.label}
|
||||||
aria-label={config.label}
|
aria-label={config.label}
|
||||||
>
|
>
|
||||||
{@html config.icon}
|
<IconComponent class="text-white" size={iconSize} strokeWidth={2.5} fill="currentColor" />
|
||||||
</div>
|
</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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { Calendar } from '@fullcalendar/core';
|
import { Calendar } from '@fullcalendar/core';
|
||||||
|
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
||||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||||
import interactionPlugin from '@fullcalendar/interaction';
|
import interactionPlugin from '@fullcalendar/interaction';
|
||||||
import multiMonthPlugin from '@fullcalendar/multimonth';
|
import multiMonthPlugin from '@fullcalendar/multimonth';
|
||||||
import ptBrLocale from '@fullcalendar/core/locales/pt-br';
|
import { onMount } from 'svelte';
|
||||||
import { SvelteDate } from 'svelte/reactivity';
|
import { SvelteDate } from 'svelte/reactivity';
|
||||||
|
|
||||||
interface Props {
|
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;
|
onPeriodoAdicionado?: (periodo: { dataInicio: string; dataFim: string; dias: number }) => void;
|
||||||
onPeriodoRemovido?: (index: number) => void;
|
onPeriodoRemovido?: (index: number) => void;
|
||||||
maxPeriodos?: number;
|
maxPeriodos?: number;
|
||||||
@@ -17,7 +21,7 @@
|
|||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
const {
|
||||||
periodosExistentes = [],
|
periodosExistentes = [],
|
||||||
onPeriodoAdicionado,
|
onPeriodoAdicionado,
|
||||||
onPeriodoRemovido,
|
onPeriodoRemovido,
|
||||||
@@ -37,7 +41,7 @@
|
|||||||
{ bg: '#4facfe', border: '#00c6ff', text: '#ffffff' } // Azul
|
{ bg: '#4facfe', border: '#00c6ff', text: '#ffffff' } // Azul
|
||||||
];
|
];
|
||||||
|
|
||||||
const eventos = $derived.by(() =>
|
let eventos = $derived.by(() =>
|
||||||
periodosExistentes.map((periodo, index) => ({
|
periodosExistentes.map((periodo, index) => ({
|
||||||
id: `periodo-${index}`,
|
id: `periodo-${index}`,
|
||||||
title: `Período ${index + 1} (${periodo.dias} dias)`,
|
title: `Período ${index + 1} (${periodo.dias} dias)`,
|
||||||
@@ -99,7 +103,10 @@
|
|||||||
selectable: !readonly,
|
selectable: !readonly,
|
||||||
selectMirror: true,
|
selectMirror: true,
|
||||||
unselectAuto: false,
|
unselectAuto: false,
|
||||||
events: eventos.map((evento) => ({ ...evento, extendedProps: { ...evento.extendedProps } })),
|
events: eventos.map((evento) => ({
|
||||||
|
...evento,
|
||||||
|
extendedProps: { ...evento.extendedProps }
|
||||||
|
})),
|
||||||
|
|
||||||
// Estilo customizado
|
// Estilo customizado
|
||||||
buttonText: {
|
buttonText: {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { useQuery } from 'convex-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
funcionarioId: Id<'funcionarios'>;
|
funcionarioId: Id<'funcionarios'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionarioId }: Props = $props();
|
const { funcionarioId }: Props = $props();
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId });
|
const saldosQuery = useQuery(api.saldoFerias.listarSaldos, { funcionarioId });
|
||||||
@@ -15,20 +15,18 @@
|
|||||||
funcionarioId
|
funcionarioId
|
||||||
});
|
});
|
||||||
|
|
||||||
const saldos = $derived(saldosQuery.data || []);
|
let saldos = $derived(saldosQuery.data || []);
|
||||||
const solicitacoes = $derived(solicitacoesQuery.data || []);
|
let solicitacoes = $derived(solicitacoesQuery.data || []);
|
||||||
|
|
||||||
// Estatísticas derivadas
|
// Estatísticas derivadas
|
||||||
const saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
|
let saldoAtual = $derived(saldos.find((s) => s.anoReferencia === new Date().getFullYear()));
|
||||||
const totalSolicitacoes = $derived(solicitacoes.length);
|
let totalSolicitacoes = $derived(solicitacoes.length);
|
||||||
const aprovadas = $derived(
|
let aprovadas = $derived(
|
||||||
solicitacoes.filter((s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada')
|
solicitacoes.filter((s) => s.status === 'aprovado' || s.status === 'data_ajustada_aprovada')
|
||||||
.length
|
.length
|
||||||
);
|
);
|
||||||
const pendentes = $derived(
|
let pendentes = $derived(solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length);
|
||||||
solicitacoes.filter((s) => s.status === 'aguardando_aprovacao').length
|
let reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
|
||||||
);
|
|
||||||
const reprovadas = $derived(solicitacoes.filter((s) => s.status === 'reprovado').length);
|
|
||||||
|
|
||||||
// Canvas para gráfico de pizza
|
// Canvas para gráfico de pizza
|
||||||
let canvasSaldo = $state<HTMLCanvasElement>();
|
let canvasSaldo = $state<HTMLCanvasElement>();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery, useConvexClient } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { Check, Zap, Clock, Info, AlertTriangle, Calendar, X, Plus, ChevronLeft, ChevronRight, Trash2, CheckCircle } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
funcionarioId: Id<'funcionarios'>;
|
funcionarioId: Id<'funcionarios'>;
|
||||||
@@ -10,7 +11,7 @@
|
|||||||
onCancelar?: () => void;
|
onCancelar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
const { funcionarioId, onSucesso, onCancelar }: Props = $props();
|
||||||
|
|
||||||
// Cliente Convex
|
// Cliente Convex
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
@@ -34,18 +35,20 @@
|
|||||||
let dataFimPeriodo = $state('');
|
let dataFimPeriodo = $state('');
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const funcionarioQuery = useQuery(api.funcionarios.getById, { id: funcionarioId });
|
const funcionarioQuery = useQuery(api.funcionarios.getById, {
|
||||||
const funcionario = $derived(funcionarioQuery?.data);
|
id: funcionarioId
|
||||||
const regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
|
});
|
||||||
|
let funcionario = $derived(funcionarioQuery?.data);
|
||||||
|
let regimeTrabalho = $derived(funcionario?.regimeTrabalho || 'clt');
|
||||||
|
|
||||||
const saldoQuery = $derived(
|
let saldoQuery = $derived(
|
||||||
useQuery(api.saldoFerias.obterSaldo, {
|
useQuery(api.saldoFerias.obterSaldo, {
|
||||||
funcionarioId,
|
funcionarioId,
|
||||||
anoReferencia: anoSelecionado
|
anoReferencia: anoSelecionado
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const validacaoQuery = $derived(
|
let validacaoQuery = $derived(
|
||||||
periodosFerias.length > 0
|
periodosFerias.length > 0
|
||||||
? useQuery(api.saldoFerias.validarSolicitacao, {
|
? useQuery(api.saldoFerias.validarSolicitacao, {
|
||||||
funcionarioId,
|
funcionarioId,
|
||||||
@@ -59,18 +62,18 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Derivados
|
// Derivados
|
||||||
const saldo = $derived(saldoQuery.data);
|
let saldo = $derived(saldoQuery.data);
|
||||||
const validacao = $derived(validacaoQuery.data);
|
let validacao = $derived(validacaoQuery.data);
|
||||||
const totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0));
|
let totalDiasSelecionados = $derived(periodosFerias.reduce((acc, p) => acc + p.dias, 0));
|
||||||
|
|
||||||
// Anos disponíveis (últimos 3 anos + próximo ano)
|
// Anos disponíveis (últimos 3 anos + próximo ano)
|
||||||
const anosDisponiveis = $derived.by(() => {
|
let anosDisponiveis = $derived.by(() => {
|
||||||
const anoAtual = new Date().getFullYear();
|
const anoAtual = new Date().getFullYear();
|
||||||
return [anoAtual - 1, anoAtual, anoAtual + 1];
|
return [anoAtual - 1, anoAtual, anoAtual + 1];
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verificar se é regime estatutário PE ou Municipal
|
// Verificar se é regime estatutário PE ou Municipal
|
||||||
const ehEstatutarioPEOuMunicipal = $derived(
|
let ehEstatutarioPEOuMunicipal = $derived(
|
||||||
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
|
regimeTrabalho === 'estatutario_pe' || regimeTrabalho === 'estatutario_municipal'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -127,7 +130,9 @@
|
|||||||
// Verificar se o total não excede 30 dias
|
// Verificar se o total não excede 30 dias
|
||||||
const novoTotal = totalDiasSelecionados + dias;
|
const novoTotal = totalDiasSelecionados + dias;
|
||||||
if (novoTotal > 30) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,7 +140,9 @@
|
|||||||
// Verificar se o total não excede o saldo disponível
|
// Verificar se o total não excede o saldo disponível
|
||||||
const novoTotal = totalDiasSelecionados + dias;
|
const novoTotal = totalDiasSelecionados + dias;
|
||||||
if (saldo && novoTotal > saldo.diasDisponiveis) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +228,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calcular dias do período atual
|
// Calcular dias do período atual
|
||||||
const diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
|
let diasPeriodoAtual = $derived(calcularDias(dataInicioPeriodo, dataFimPeriodo));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="wizard-ferias-container">
|
<div class="wizard-ferias-container">
|
||||||
@@ -243,34 +250,24 @@
|
|||||||
style:box-shadow={passoAtual === i + 1 ? '0 0 20px rgba(102, 126, 234, 0.5)' : 'none'}
|
style:box-shadow={passoAtual === i + 1 ? '0 0 20px rgba(102, 126, 234, 0.5)' : 'none'}
|
||||||
>
|
>
|
||||||
{#if passoAtual > i + 1}
|
{#if passoAtual > i + 1}
|
||||||
<svg
|
<Check class="h-6 w-6" strokeWidth={3} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="3"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
{:else}
|
{:else}
|
||||||
{i + 1}
|
{i + 1}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Label do passo -->
|
<!-- Label do passo -->
|
||||||
<p class="mt-3 text-center text-sm font-semibold" class:text-primary={passoAtual === i + 1}>
|
<p
|
||||||
|
class="mt-3 text-center text-sm font-semibold"
|
||||||
|
class:text-primary={passoAtual === i + 1}
|
||||||
|
>
|
||||||
{labels[i]}
|
{labels[i]}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Linha conectora -->
|
<!-- Linha conectora -->
|
||||||
{#if i < totalPassos - 1}
|
{#if i < totalPassos - 1}
|
||||||
<div
|
<div
|
||||||
class="absolute left-1/2 top-6 z-10 h-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);"
|
style="width: calc(100% - 1.5rem); margin-left: calc(50% + 0.75rem);"
|
||||||
class:bg-primary={passoAtual > i + 1}
|
class:bg-primary={passoAtual > i + 1}
|
||||||
class:bg-base-300={passoAtual <= i + 1}
|
class:bg-base-300={passoAtual <= i + 1}
|
||||||
@@ -303,7 +300,9 @@
|
|||||||
style:border-width={anoSelecionado === ano ? '2px' : undefined}
|
style:border-width={anoSelecionado === ano ? '2px' : undefined}
|
||||||
style:color={anoSelecionado === ano ? '#000000' : undefined}
|
style:color={anoSelecionado === ano ? '#000000' : undefined}
|
||||||
style:background-color={anoSelecionado === ano ? 'transparent' : 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)}
|
onclick={() => (anoSelecionado = ano)}
|
||||||
>
|
>
|
||||||
{ano}
|
{ano}
|
||||||
@@ -326,19 +325,7 @@
|
|||||||
<div class="stats stats-vertical lg:stats-horizontal w-full shadow-lg">
|
<div class="stats stats-vertical lg:stats-horizontal w-full shadow-lg">
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-primary">
|
<div class="stat-figure text-primary">
|
||||||
<svg
|
<Zap class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="inline-block h-8 w-8 stroke-current"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title">Total Direito</div>
|
<div class="stat-title">Total Direito</div>
|
||||||
<div class="stat-value text-primary">{saldo.diasDireito}</div>
|
<div class="stat-value text-primary">{saldo.diasDireito}</div>
|
||||||
@@ -347,19 +334,7 @@
|
|||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-success">
|
<div class="stat-figure text-success">
|
||||||
<svg
|
<Check class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="inline-block h-8 w-8 stroke-current"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title">Disponível</div>
|
<div class="stat-title">Disponível</div>
|
||||||
<div class="stat-value text-success">
|
<div class="stat-value text-success">
|
||||||
@@ -370,19 +345,7 @@
|
|||||||
|
|
||||||
<div class="stat">
|
<div class="stat">
|
||||||
<div class="stat-figure text-warning">
|
<div class="stat-figure text-warning">
|
||||||
<svg
|
<Clock class="inline-block h-8 w-8 stroke-current" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="inline-block h-8 w-8 stroke-current"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-title">Usado</div>
|
<div class="stat-title">Usado</div>
|
||||||
<div class="stat-value text-warning">{saldo.diasUsados}</div>
|
<div class="stat-value text-warning">{saldo.diasUsados}</div>
|
||||||
@@ -392,19 +355,7 @@
|
|||||||
|
|
||||||
<!-- Informações do Regime -->
|
<!-- Informações do Regime -->
|
||||||
<div class="alert alert-info mt-4">
|
<div class="alert alert-info mt-4">
|
||||||
<svg
|
<Info class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
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>
|
<div>
|
||||||
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
|
<h4 class="font-bold">{saldo.regimeTrabalho}</h4>
|
||||||
<p class="text-sm">
|
<p class="text-sm">
|
||||||
@@ -413,7 +364,8 @@
|
|||||||
</p>
|
</p>
|
||||||
{#if ehEstatutarioPEOuMunicipal}
|
{#if ehEstatutarioPEOuMunicipal}
|
||||||
<p class="mt-2 text-sm font-semibold">
|
<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>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -421,19 +373,7 @@
|
|||||||
|
|
||||||
{#if saldo.diasDisponiveis === 0}
|
{#if saldo.diasDisponiveis === 0}
|
||||||
<div class="alert alert-warning mt-4">
|
<div class="alert alert-warning mt-4">
|
||||||
<svg
|
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Você não tem saldo disponível para este ano.</span>
|
<span>Você não tem saldo disponível para este ano.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -441,19 +381,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="alert alert-warning">
|
<div class="alert alert-warning">
|
||||||
<svg
|
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>Nenhum saldo encontrado para este ano.</span>
|
<span>Nenhum saldo encontrado para este ano.</span>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -471,19 +399,7 @@
|
|||||||
|
|
||||||
<!-- Resumo rápido -->
|
<!-- Resumo rápido -->
|
||||||
<div class="alert bg-base-200 mb-6">
|
<div class="alert bg-base-200 mb-6">
|
||||||
<svg
|
<Info class="stroke-info h-6 w-6 shrink-0" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
class="stroke-info h-6 w-6 shrink-0"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<p>
|
<p>
|
||||||
<strong>Saldo disponível:</strong>
|
<strong>Saldo disponível:</strong>
|
||||||
@@ -494,14 +410,15 @@
|
|||||||
</p>
|
</p>
|
||||||
{#if ehEstatutarioPEOuMunicipal}
|
{#if ehEstatutarioPEOuMunicipal}
|
||||||
<p class="mt-2 text-sm font-semibold">
|
<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>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Formulário para adicionar período -->
|
<!-- 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">
|
<div class="card-body">
|
||||||
<h3 class="card-title mb-4">Adicionar Período</h3>
|
<h3 class="card-title mb-4">Adicionar Período</h3>
|
||||||
|
|
||||||
@@ -510,11 +427,7 @@
|
|||||||
<label class="label">
|
<label class="label">
|
||||||
<span class="label-text font-semibold">Data Início</span>
|
<span class="label-text font-semibold">Data Início</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input type="date" class="input input-bordered" bind:value={dataInicioPeriodo} />
|
||||||
type="date"
|
|
||||||
class="input input-bordered"
|
|
||||||
bind:value={dataInicioPeriodo}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
@@ -534,7 +447,7 @@
|
|||||||
<span class="label-text font-semibold">Dias</span>
|
<span class="label-text font-semibold">Dias</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input input-bordered flex items-center">
|
<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>
|
<span class="ml-2 text-sm opacity-70">dias</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,20 +460,7 @@
|
|||||||
onclick={adicionarPeriodo}
|
onclick={adicionarPeriodo}
|
||||||
disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0}
|
disabled={!dataInicioPeriodo || !dataFimPeriodo || diasPeriodoAtual <= 0}
|
||||||
>
|
>
|
||||||
<svg
|
<Plus class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Adicionar Período
|
Adicionar Período
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,7 +469,7 @@
|
|||||||
|
|
||||||
<!-- Lista de períodos adicionados -->
|
<!-- Lista de períodos adicionados -->
|
||||||
{#if periodosFerias.length > 0}
|
{#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">
|
<div class="card-body">
|
||||||
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
|
<h3 class="card-title mb-4">Períodos Adicionados ({periodosFerias.length})</h3>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
@@ -595,20 +495,7 @@
|
|||||||
class="btn btn-error btn-sm gap-2"
|
class="btn btn-error btn-sm gap-2"
|
||||||
onclick={() => removerPeriodo(index)}
|
onclick={() => removerPeriodo(index)}
|
||||||
>
|
>
|
||||||
<svg
|
<Trash2 class="h-4 w-4" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-4 w-4"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M6 18L18 6M6 6l12 12"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Remover
|
Remover
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -623,36 +510,12 @@
|
|||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
{#if validacao.valido}
|
{#if validacao.valido}
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<svg
|
<CheckCircle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span>✅ Períodos válidos! Total: {validacao.totalDias} dias</span>
|
<span>✅ Períodos válidos! Total: {validacao.totalDias} dias</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="alert alert-error">
|
<div class="alert alert-error">
|
||||||
<svg
|
<X class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
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>
|
||||||
<p class="font-bold">Erros encontrados:</p>
|
<p class="font-bold">Erros encontrados:</p>
|
||||||
<ul class="list-inside list-disc">
|
<ul class="list-inside list-disc">
|
||||||
@@ -666,19 +529,7 @@
|
|||||||
|
|
||||||
{#if validacao.avisos.length > 0}
|
{#if validacao.avisos.length > 0}
|
||||||
<div class="alert alert-warning mt-4">
|
<div class="alert alert-warning mt-4">
|
||||||
<svg
|
<AlertTriangle class="h-6 w-6 shrink-0 stroke-current" />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-6 w-6 shrink-0 stroke-current"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
<div>
|
||||||
<p class="font-bold">Avisos:</p>
|
<p class="font-bold">Avisos:</p>
|
||||||
<ul class="list-inside list-disc">
|
<ul class="list-inside list-disc">
|
||||||
@@ -768,20 +619,7 @@
|
|||||||
<div>
|
<div>
|
||||||
{#if passoAtual > 1}
|
{#if passoAtual > 1}
|
||||||
<button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}>
|
<button type="button" class="btn btn-outline btn-lg gap-2" onclick={passoAnterior}>
|
||||||
<svg
|
<ChevronLeft class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M15 19l-7-7 7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Voltar
|
Voltar
|
||||||
</button>
|
</button>
|
||||||
{:else if onCancelar}
|
{:else if onCancelar}
|
||||||
@@ -798,20 +636,7 @@
|
|||||||
disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)}
|
disabled={passoAtual === 1 && (!saldo || saldo.diasDisponiveis === 0)}
|
||||||
>
|
>
|
||||||
Próximo
|
Próximo
|
||||||
<svg
|
<ChevronRight class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M9 5l7 7-7 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
@@ -824,20 +649,7 @@
|
|||||||
<span class="loading loading-spinner"></span>
|
<span class="loading loading-spinner"></span>
|
||||||
Enviando...
|
Enviando...
|
||||||
{:else}
|
{:else}
|
||||||
<svg
|
<Check class="h-5 w-5" strokeWidth={2} />
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M5 13l4 4L19 7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Enviar Solicitação
|
Enviar Solicitação
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</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
@@ -1,19 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { useQuery } from 'convex-svelte';
|
|
||||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
import jsPDF from 'jspdf';
|
|
||||||
import { Printer, X, User, Clock, CheckCircle2, XCircle, Calendar, MapPin } from 'lucide-svelte';
|
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
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 logoGovPE from '$lib/assets/logo_governo_PE.png';
|
||||||
import { onMount } from 'svelte';
|
import { formatarDataHoraCompleta, getTipoRegistroLabel } from '$lib/utils/ponto';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
registroId: Id<'registrosPonto'>;
|
registroId: Id<'registrosPonto'>;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { registroId, onClose }: Props = $props();
|
const { registroId, onClose }: Props = $props();
|
||||||
|
|
||||||
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
const registroQuery = useQuery(api.pontos.obterRegistro, { registroId });
|
||||||
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
const configQuery = useQuery(api.configuracaoPonto.obterConfiguracao, {});
|
||||||
@@ -21,25 +21,28 @@
|
|||||||
let gerando = $state(false);
|
let gerando = $state(false);
|
||||||
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
let modalPosition = $state<{ top: number; left: number } | null>(null);
|
||||||
|
|
||||||
// Função para calcular a posição baseada no relógio sincronizado
|
// Função para calcular a posição baseada no card de registro de ponto
|
||||||
function calcularPosicaoModal() {
|
function calcularPosicaoModal() {
|
||||||
// Procurar pelo elemento do relógio sincronizado
|
// Procurar pelo elemento do card de registro de ponto
|
||||||
const relogioRef = document.getElementById('relogio-sincronizado-ref');
|
const cardRef = document.getElementById('card-registro-ponto-ref');
|
||||||
|
|
||||||
if (relogioRef) {
|
if (cardRef) {
|
||||||
const rect = relogioRef.getBoundingClientRect();
|
const rect = cardRef.getBoundingClientRect();
|
||||||
const viewportWidth = window.innerWidth;
|
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
|
|
||||||
// Posicionar o modal na mesma posição do relógio sincronizado
|
// Posicionar o modal na mesma altura Y do card (top do card) - mesma posição do texto "Registrar Ponto"
|
||||||
// Centralizado horizontalmente no card do relógio
|
const top = rect.top;
|
||||||
const left = rect.left + (rect.width / 2);
|
|
||||||
// Posicionar abaixo do card do relógio com um pequeno espaçamento
|
|
||||||
const top = rect.bottom + 20;
|
|
||||||
|
|
||||||
|
// 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 {
|
return {
|
||||||
top: top,
|
top: finalTop,
|
||||||
left: left
|
left: window.innerWidth / 2
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,68 +50,55 @@
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
// Atualizar posição quando o modal for aberto (quando registroQuery tiver dados)
|
||||||
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
|
$effect(() => {
|
||||||
const updatePosition = () => {
|
if (registroQuery?.data) {
|
||||||
requestAnimationFrame(() => {
|
// Usar requestAnimationFrame para garantir que o DOM está completamente renderizado
|
||||||
const pos = calcularPosicaoModal();
|
const updatePosition = () => {
|
||||||
if (pos) {
|
requestAnimationFrame(() => {
|
||||||
modalPosition = pos;
|
const pos = calcularPosicaoModal();
|
||||||
}
|
if (pos) {
|
||||||
});
|
modalPosition = pos;
|
||||||
};
|
} else {
|
||||||
|
// Fallback para centralização
|
||||||
|
modalPosition = {
|
||||||
|
top: window.innerHeight / 2,
|
||||||
|
left: window.innerWidth / 2
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// Aguardar um pouco mais para garantir que o DOM está atualizado
|
// Aguardar um pouco para garantir que o DOM está atualizado
|
||||||
setTimeout(updatePosition, 50);
|
setTimeout(updatePosition, 50);
|
||||||
|
|
||||||
// Adicionar listener de scroll para atualizar posição
|
// Adicionar listener de scroll para atualizar posição
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
updatePosition();
|
updatePosition();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
window.addEventListener('resize', handleScroll);
|
window.addEventListener('resize', handleScroll);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('scroll', handleScroll, true);
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
window.removeEventListener('resize', handleScroll);
|
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
|
// Função para obter estilo do modal baseado na posição calculada
|
||||||
function getModalStyle() {
|
function getModalStyle() {
|
||||||
if (modalPosition) {
|
if (modalPosition) {
|
||||||
// Garantir que o modal não saia da viewport
|
// Posicionar na altura do card, centralizado horizontalmente
|
||||||
const viewportWidth = window.innerWidth;
|
// position: fixed já é relativo à viewport, então podemos usar diretamente
|
||||||
const viewportHeight = window.innerHeight;
|
return `position: fixed; top: ${modalPosition.top}px; left: 50%; transform: translateX(-50%); width: 100%; max-width: 700px;`;
|
||||||
const modalWidth = 700; // Aproximadamente max-w-2xl
|
|
||||||
const modalHeight = Math.min(viewportHeight * 0.9, 600);
|
|
||||||
|
|
||||||
let left = modalPosition.left;
|
|
||||||
let top = modalPosition.top;
|
|
||||||
|
|
||||||
// Ajustar se o modal sair da viewport à direita
|
|
||||||
if (left + (modalWidth / 2) > viewportWidth - 20) {
|
|
||||||
left = viewportWidth - (modalWidth / 2) - 20;
|
|
||||||
}
|
|
||||||
// Ajustar se o modal sair da viewport à esquerda
|
|
||||||
if (left - (modalWidth / 2) < 20) {
|
|
||||||
left = (modalWidth / 2) + 20;
|
|
||||||
}
|
|
||||||
// Ajustar se o modal sair da viewport abaixo
|
|
||||||
if (top + modalHeight > viewportHeight - 20) {
|
|
||||||
top = viewportHeight - modalHeight - 20;
|
|
||||||
}
|
|
||||||
// Ajustar se o modal sair da viewport acima
|
|
||||||
if (top < 20) {
|
|
||||||
top = 20;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usar transform para centralizar horizontalmente baseado no left calculado
|
|
||||||
return `position: fixed; top: ${top}px; left: ${left}px; transform: translateX(-50%); max-width: ${Math.min(modalWidth, viewportWidth - 40)}px;`;
|
|
||||||
}
|
}
|
||||||
// Se não houver posição calculada, centralizar na tela
|
// Se não houver posição calculada, centralizar na tela
|
||||||
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);';
|
return 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 100%; max-width: 700px;';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function gerarPDF() {
|
async function gerarPDF() {
|
||||||
@@ -120,15 +110,16 @@
|
|||||||
const registro = registroQuery.data;
|
const registro = registroQuery.data;
|
||||||
const doc = new jsPDF();
|
const doc = new jsPDF();
|
||||||
|
|
||||||
// Logo
|
// Adicionar logo no canto superior esquerdo
|
||||||
let yPosition = 20;
|
let yPosition = 20;
|
||||||
try {
|
try {
|
||||||
const logoImg = new Image();
|
const logoImg = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||||
logoImg.src = logoGovPE;
|
const img = new Image();
|
||||||
await new Promise<void>((resolve, reject) => {
|
img.crossOrigin = 'anonymous';
|
||||||
logoImg.onload = () => resolve();
|
img.onload = () => resolve(img);
|
||||||
logoImg.onerror = () => reject();
|
img.onerror = (err) => reject(err);
|
||||||
setTimeout(() => reject(), 3000);
|
setTimeout(() => reject(new Error('Timeout loading logo')), 3000);
|
||||||
|
img.src = logoGovPE;
|
||||||
});
|
});
|
||||||
|
|
||||||
const logoWidth = 25;
|
const logoWidth = 25;
|
||||||
@@ -136,87 +127,131 @@
|
|||||||
const logoHeight = logoWidth * aspectRatio;
|
const logoHeight = logoWidth * aspectRatio;
|
||||||
|
|
||||||
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
doc.addImage(logoImg, 'PNG', 15, 10, logoWidth, logoHeight);
|
||||||
yPosition = Math.max(20, 10 + logoHeight / 2);
|
yPosition = 10 + logoHeight + 10;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn('Não foi possível carregar a logo:', err);
|
console.warn('Erro ao carregar logo:', err);
|
||||||
|
yPosition = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cabeçalho
|
// 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.setFontSize(16);
|
||||||
doc.setTextColor(41, 128, 185);
|
doc.setTextColor(102, 126, 234); // Cor primária padrão do sistema
|
||||||
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, { align: 'center' });
|
doc.setFont('helvetica', 'bold');
|
||||||
|
doc.text('COMPROVANTE DE REGISTRO DE PONTO', 105, yPosition, {
|
||||||
|
align: 'center'
|
||||||
|
});
|
||||||
|
|
||||||
yPosition += 15;
|
yPosition += 15;
|
||||||
|
|
||||||
// Informações do Funcionário
|
// Informações do Funcionário em tabela
|
||||||
doc.setFontSize(12);
|
const funcionarioData: string[][] = [];
|
||||||
doc.setTextColor(0, 0, 0);
|
|
||||||
doc.setFont('helvetica', 'bold');
|
|
||||||
doc.text('DADOS DO FUNCIONÁRIO', 15, yPosition);
|
|
||||||
doc.setFont('helvetica', 'normal');
|
|
||||||
|
|
||||||
yPosition += 8;
|
|
||||||
doc.setFontSize(10);
|
|
||||||
|
|
||||||
if (registro.funcionario) {
|
if (registro.funcionario) {
|
||||||
if (registro.funcionario.matricula) {
|
if (registro.funcionario.matricula) {
|
||||||
doc.text(`Matrícula: ${registro.funcionario.matricula}`, 15, yPosition);
|
funcionarioData.push(['Matrícula', registro.funcionario.matricula]);
|
||||||
yPosition += 6;
|
|
||||||
}
|
}
|
||||||
doc.text(`Nome: ${registro.funcionario.nome}`, 15, yPosition);
|
funcionarioData.push(['Nome', registro.funcionario.nome || '-']);
|
||||||
yPosition += 6;
|
|
||||||
if (registro.funcionario.descricaoCargo) {
|
if (registro.funcionario.descricaoCargo) {
|
||||||
doc.text(`Cargo/Função: ${registro.funcionario.descricaoCargo}`, 15, yPosition);
|
funcionarioData.push(['Cargo/Função', registro.funcionario.descricaoCargo]);
|
||||||
yPosition += 6;
|
|
||||||
}
|
}
|
||||||
if (registro.funcionario.simbolo) {
|
if (registro.funcionario.simbolo) {
|
||||||
doc.text(
|
const simboloTipo =
|
||||||
`Símbolo: ${registro.funcionario.simbolo.nome} (${registro.funcionario.simbolo.tipo === 'cargo_comissionado' ? 'Cargo Comissionado' : 'Função Gratificada'})`,
|
registro.funcionario.simbolo.tipo === 'cargo_comissionado'
|
||||||
15,
|
? 'Cargo Comissionado'
|
||||||
yPosition
|
: 'Função Gratificada';
|
||||||
);
|
funcionarioData.push([
|
||||||
yPosition += 6;
|
'Símbolo',
|
||||||
|
`${registro.funcionario.simbolo.nome} (${simboloTipo})`
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
yPosition += 5;
|
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;
|
||||||
|
|
||||||
// Informações do Registro
|
autoTable(doc, {
|
||||||
doc.setFont('helvetica', 'bold');
|
startY: yPosition,
|
||||||
doc.text('DADOS DO REGISTRO', 15, yPosition);
|
head: [['Campo', 'Informação']],
|
||||||
doc.setFont('helvetica', 'normal');
|
body: funcionarioData,
|
||||||
|
theme: 'striped',
|
||||||
|
headStyles: { fillColor: [102, 126, 234] },
|
||||||
|
styles: { fontSize: 10 },
|
||||||
|
margin: { left: 15, right: 15 }
|
||||||
|
});
|
||||||
|
|
||||||
yPosition += 8;
|
type JsPDFWithAutoTable = jsPDF & {
|
||||||
doc.setFontSize(10);
|
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 config = configQuery?.data;
|
||||||
const tipoLabel = config
|
const tipoLabel = config
|
||||||
? getTipoRegistroLabel(registro.tipo, {
|
? getTipoRegistroLabel(registro.tipo, {
|
||||||
nomeEntrada: config.nomeEntrada,
|
nomeEntrada: config.nomeEntrada,
|
||||||
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
nomeSaidaAlmoco: config.nomeSaidaAlmoco,
|
||||||
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
nomeRetornoAlmoco: config.nomeRetornoAlmoco,
|
||||||
nomeSaida: config.nomeSaida,
|
nomeSaida: config.nomeSaida
|
||||||
})
|
})
|
||||||
: getTipoRegistroLabel(registro.tipo);
|
: getTipoRegistroLabel(registro.tipo);
|
||||||
doc.text(`Tipo: ${tipoLabel}`, 15, yPosition);
|
|
||||||
yPosition += 6;
|
|
||||||
|
|
||||||
const dataHora = formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo);
|
const dataHora = formatarDataHoraCompleta(
|
||||||
doc.text(`Data e Hora: ${dataHora}`, 15, yPosition);
|
registro.data,
|
||||||
yPosition += 6;
|
registro.hora,
|
||||||
|
registro.minuto,
|
||||||
doc.text(`Status: ${registro.dentroDoPrazo ? 'Dentro do Prazo' : 'Fora do Prazo'}`, 15, yPosition);
|
registro.segundo
|
||||||
yPosition += 6;
|
|
||||||
|
|
||||||
doc.text(`Tolerância: ${registro.toleranciaMinutos} minutos`, 15, yPosition);
|
|
||||||
yPosition += 6;
|
|
||||||
|
|
||||||
doc.text(
|
|
||||||
`Sincronizado: ${registro.sincronizadoComServidor ? 'Sim (Servidor)' : 'Não (PC Local)'}`,
|
|
||||||
15,
|
|
||||||
yPosition
|
|
||||||
);
|
);
|
||||||
yPosition += 10;
|
|
||||||
|
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 (Servidor interno)']
|
||||||
|
];
|
||||||
|
|
||||||
|
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)
|
// Imagem capturada (se disponível)
|
||||||
if (registro.imagemUrl) {
|
if (registro.imagemUrl) {
|
||||||
@@ -227,8 +262,10 @@
|
|||||||
yPosition = 20;
|
yPosition = 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
doc.setFontSize(12);
|
||||||
doc.setFont('helvetica', 'bold');
|
doc.setFont('helvetica', 'bold');
|
||||||
doc.text('FOTO CAPTURADA', 105, yPosition, { align: 'center' });
|
doc.setTextColor(0, 0, 0);
|
||||||
|
doc.text('FOTO CAPTURADA', 15, yPosition);
|
||||||
doc.setFont('helvetica', 'normal');
|
doc.setFont('helvetica', 'normal');
|
||||||
yPosition += 10;
|
yPosition += 10;
|
||||||
|
|
||||||
@@ -298,7 +335,9 @@
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
console.warn('Erro ao adicionar imagem ao PDF:', error);
|
||||||
doc.setFontSize(10);
|
doc.setFontSize(10);
|
||||||
doc.text('Foto não disponível para impressão', 105, yPosition, { align: 'center' });
|
doc.text('Foto não disponível para impressão', 105, yPosition, {
|
||||||
|
align: 'center'
|
||||||
|
});
|
||||||
yPosition += 6;
|
yPosition += 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,45 +369,52 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="fixed inset-0 z-50 pointer-events-none"
|
class="pointer-events-none fixed inset-0 z-50"
|
||||||
style="animation: fadeIn 0.2s ease-out;"
|
style="animation: fadeIn 0.2s ease-out;"
|
||||||
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
onkeydown={(e) => e.key === 'Escape' && onClose()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-comprovante-title"
|
aria-labelledby="modal-comprovante-title"
|
||||||
>
|
>
|
||||||
<!-- Backdrop leve -->
|
<!-- Backdrop leve -->
|
||||||
<div
|
<div
|
||||||
class="absolute inset-0 bg-black/20 transition-opacity duration-200 pointer-events-auto"
|
class="pointer-events-auto absolute inset-0 bg-black/20 transition-opacity duration-200"
|
||||||
onclick={onClose}
|
onclick={onClose}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<!-- Modal Box -->
|
<!-- Modal Box -->
|
||||||
<div
|
<div
|
||||||
class="absolute bg-gradient-to-br from-base-100 via-base-100 to-primary/5 rounded-2xl shadow-2xl border-2 border-primary/20 max-w-2xl w-full max-h-[90vh] overflow-hidden flex flex-col z-10 transform transition-all duration-300 pointer-events-auto"
|
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()}"
|
style="animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); {getModalStyle()}"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<!-- Header Premium com gradiente -->
|
<!-- Header Premium com gradiente -->
|
||||||
<div class="flex items-center justify-between px-6 py-5 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-b-2 border-primary/20 flex-shrink-0">
|
<div
|
||||||
|
class="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="flex items-center gap-3">
|
||||||
<div class="p-2.5 bg-primary/20 rounded-xl shadow-lg">
|
<div class="bg-primary/20 rounded-xl p-2.5 shadow-lg">
|
||||||
<Clock class="h-6 w-6 text-primary" strokeWidth={2.5} />
|
<Clock class="text-primary h-6 w-6" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 id="modal-comprovante-title" class="font-bold text-xl text-base-content">Comprovante de Registro de Ponto</h3>
|
<h3 id="modal-comprovante-title" class="text-base-content text-xl font-bold">
|
||||||
<p class="text-sm text-base-content/70 mt-0.5">Detalhes do registro realizado</p>
|
Comprovante de Registro de Ponto
|
||||||
|
</h3>
|
||||||
|
<p class="text-base-content/70 mt-0.5 text-sm">Detalhes do registro realizado</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all" onclick={onClose}>
|
<button
|
||||||
|
class="btn btn-sm btn-circle btn-ghost hover:bg-base-300 transition-all"
|
||||||
|
onclick={onClose}
|
||||||
|
>
|
||||||
<X class="h-5 w-5" />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Conteúdo com rolagem -->
|
<!-- Conteúdo com rolagem -->
|
||||||
<div class="flex-1 overflow-y-auto px-6 py-6 modal-scroll">
|
<div class="modal-scroll flex-1 overflow-y-auto px-6 py-6">
|
||||||
{#if registroQuery === undefined}
|
{#if registroQuery === undefined}
|
||||||
<div class="flex justify-center items-center py-12">
|
<div class="flex items-center justify-center py-12">
|
||||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||||
</div>
|
</div>
|
||||||
{:else if !registroQuery?.data}
|
{:else if !registroQuery?.data}
|
||||||
@@ -380,35 +426,58 @@
|
|||||||
{@const registro = registroQuery.data}
|
{@const registro = registroQuery.data}
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- Informações do Funcionário -->
|
<!-- Informações do Funcionário -->
|
||||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
|
<div
|
||||||
|
class="card 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="card-body p-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="mb-4 flex items-center gap-3">
|
||||||
<div class="p-2 bg-primary/10 rounded-lg">
|
<div class="bg-primary/10 rounded-lg p-2">
|
||||||
<User class="h-5 w-5 text-primary" strokeWidth={2} />
|
<User class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-bold text-lg text-base-content">Dados do Funcionário</h4>
|
<h4 class="text-base-content text-lg font-bold">Dados do Funcionário</h4>
|
||||||
</div>
|
</div>
|
||||||
{#if registro.funcionario}
|
{#if registro.funcionario}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#if registro.funcionario.matricula}
|
{#if registro.funcionario.matricula}
|
||||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
<div
|
||||||
|
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Matrícula</span>
|
<span
|
||||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.matricula}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
<div
|
||||||
|
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Nome</span>
|
<span
|
||||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.nome}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
{#if registro.funcionario.descricaoCargo}
|
{#if registro.funcionario.descricaoCargo}
|
||||||
<div class="flex items-start gap-3 p-3 bg-base-100 rounded-lg border border-base-300">
|
<div
|
||||||
|
class="bg-base-100 border-base-300 flex items-start gap-3 rounded-lg border p-3"
|
||||||
|
>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Cargo/Função</span>
|
<span
|
||||||
<p class="text-base font-semibold text-base-content mt-1">{registro.funcionario.descricaoCargo}</p>
|
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>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -418,43 +487,60 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Informações do Registro -->
|
<!-- Informações do Registro -->
|
||||||
<div class="card bg-gradient-to-br from-primary/5 to-primary/10 shadow-lg border-2 border-primary/20 hover:shadow-xl transition-all">
|
<div
|
||||||
|
class="card 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="card-body p-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="mb-4 flex items-center gap-3">
|
||||||
<div class="p-2 bg-primary/20 rounded-lg">
|
<div class="bg-primary/20 rounded-lg p-2">
|
||||||
<Clock class="h-5 w-5 text-primary" strokeWidth={2} />
|
<Clock class="text-primary h-5 w-5" strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-bold text-lg text-base-content">Dados do Registro</h4>
|
<h4 class="text-base-content text-lg font-bold">Dados do Registro</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<!-- Tipo -->
|
<!-- Tipo -->
|
||||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tipo</span>
|
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
<p class="text-lg font-bold text-primary mt-1">
|
>Tipo</span
|
||||||
|
>
|
||||||
|
<p class="text-primary mt-1 text-lg font-bold">
|
||||||
{configQuery?.data
|
{configQuery?.data
|
||||||
? getTipoRegistroLabel(registro.tipo, {
|
? getTipoRegistroLabel(registro.tipo, {
|
||||||
nomeEntrada: configQuery.data.nomeEntrada,
|
nomeEntrada: configQuery.data.nomeEntrada,
|
||||||
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
|
nomeSaidaAlmoco: configQuery.data.nomeSaidaAlmoco,
|
||||||
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
|
nomeRetornoAlmoco: configQuery.data.nomeRetornoAlmoco,
|
||||||
nomeSaida: configQuery.data.nomeSaida,
|
nomeSaida: configQuery.data.nomeSaida
|
||||||
})
|
})
|
||||||
: getTipoRegistroLabel(registro.tipo)}
|
: getTipoRegistroLabel(registro.tipo)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Data e Hora -->
|
<!-- Data e Hora -->
|
||||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Data e Hora</span>
|
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
<p class="text-lg font-bold text-base-content mt-1">
|
>Data e Hora</span
|
||||||
{formatarDataHoraCompleta(registro.data, registro.hora, registro.minuto, registro.segundo)}
|
>
|
||||||
|
<p class="text-base-content mt-1 text-lg font-bold">
|
||||||
|
{formatarDataHoraCompleta(
|
||||||
|
registro.data,
|
||||||
|
registro.hora,
|
||||||
|
registro.minuto,
|
||||||
|
registro.segundo
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status -->
|
<!-- Status -->
|
||||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Status</span>
|
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
|
>Status</span
|
||||||
|
>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="badge badge-lg gap-2 {registro.dentroDoPrazo ? 'badge-success' : 'badge-error'}">
|
<span
|
||||||
|
class="badge badge-lg gap-2 {registro.dentroDoPrazo
|
||||||
|
? 'badge-success'
|
||||||
|
: 'badge-error'}"
|
||||||
|
>
|
||||||
{#if registro.dentroDoPrazo}
|
{#if registro.dentroDoPrazo}
|
||||||
<CheckCircle2 class="h-4 w-4" />
|
<CheckCircle2 class="h-4 w-4" />
|
||||||
{:else}
|
{:else}
|
||||||
@@ -466,9 +552,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tolerância -->
|
<!-- Tolerância -->
|
||||||
<div class="bg-base-100 rounded-lg p-4 border border-base-300 shadow-sm">
|
<div class="bg-base-100 border-base-300 rounded-lg border p-4 shadow-sm">
|
||||||
<span class="text-xs font-semibold text-base-content/60 uppercase tracking-wide">Tolerância</span>
|
<span class="text-base-content/60 text-xs font-semibold tracking-wide uppercase"
|
||||||
<p class="text-lg font-bold text-base-content mt-1">{registro.toleranciaMinutos} minutos</p>
|
>Tolerância</span
|
||||||
|
>
|
||||||
|
<p class="text-base-content mt-1 text-lg font-bold">
|
||||||
|
{registro.toleranciaMinutos} minutos
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -476,13 +566,15 @@
|
|||||||
|
|
||||||
<!-- Imagem Capturada -->
|
<!-- Imagem Capturada -->
|
||||||
{#if registro.imagemUrl}
|
{#if registro.imagemUrl}
|
||||||
<div class="card bg-gradient-to-br from-base-100 to-base-200 shadow-lg border-2 border-primary/10 hover:shadow-xl transition-all">
|
<div
|
||||||
|
class="card 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="card-body p-6">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="mb-4 flex items-center gap-3">
|
||||||
<div class="p-2 bg-primary/10 rounded-lg">
|
<div class="bg-primary/10 rounded-lg p-2">
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
class="h-5 w-5 text-primary"
|
class="text-primary h-5 w-5"
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -495,13 +587,15 @@
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h4 class="font-bold text-lg text-base-content">Foto Capturada</h4>
|
<h4 class="text-base-content text-lg font-bold">Foto Capturada</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center bg-base-100 rounded-xl p-4 border-2 border-primary/20">
|
<div
|
||||||
|
class="bg-base-100 border-primary/20 flex justify-center rounded-xl border-2 p-4"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
src={registro.imagemUrl}
|
src={registro.imagemUrl}
|
||||||
alt="Foto do registro de ponto"
|
alt="Foto do registro de ponto"
|
||||||
class="max-w-full max-h-[300px] rounded-lg shadow-md object-contain"
|
class="max-h-[300px] max-w-full rounded-lg object-contain shadow-md"
|
||||||
onerror={(e) => {
|
onerror={(e) => {
|
||||||
console.error('Erro ao carregar imagem:', e);
|
console.error('Erro ao carregar imagem:', e);
|
||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
@@ -516,12 +610,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer fixo com botões -->
|
<!-- Footer fixo com botões -->
|
||||||
<div class="flex justify-end gap-3 px-6 py-4 border-t-2 border-primary/20 bg-base-100/50 backdrop-blur-sm flex-shrink-0">
|
<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}>
|
<button class="btn btn-outline gap-2" onclick={onClose}>
|
||||||
<X class="h-4 w-4" />
|
<X class="h-4 w-4" />
|
||||||
Fechar
|
Fechar
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-primary gap-2 shadow-lg hover:shadow-xl transition-all" onclick={gerarPDF} disabled={gerando}>
|
<button
|
||||||
|
class="btn btn-primary gap-2 shadow-lg transition-all hover:shadow-xl"
|
||||||
|
onclick={gerarPDF}
|
||||||
|
disabled={gerando}
|
||||||
|
>
|
||||||
{#if gerando}
|
{#if gerando}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
Gerando...
|
Gerando...
|
||||||
@@ -582,4 +682,3 @@
|
|||||||
background-color: hsl(var(--bc) / 0.5);
|
background-color: hsl(var(--bc) / 0.5);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -1,26 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MapPin, AlertCircle, HelpCircle } from 'lucide-svelte';
|
import { AlertCircle, HelpCircle, MapPin } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
dentroRaioPermitido: boolean | null | undefined;
|
dentroRaioPermitido: boolean | null | undefined;
|
||||||
showTooltip?: boolean;
|
showTooltip?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { dentroRaioPermitido, showTooltip = true }: Props = $props();
|
const { dentroRaioPermitido, showTooltip = true }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if dentroRaioPermitido === true}
|
{#if dentroRaioPermitido === true}
|
||||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
|
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Dentro do Raio' : ''}>
|
||||||
<MapPin class="h-5 w-5 text-success" strokeWidth={2.5} />
|
<MapPin class="text-success h-5 w-5" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
{:else if dentroRaioPermitido === false}
|
{:else if dentroRaioPermitido === false}
|
||||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
|
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Fora do Raio' : ''}>
|
||||||
<AlertCircle class="h-5 w-5 text-error" strokeWidth={2.5} />
|
<AlertCircle class="text-error h-5 w-5" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
|
<div class="tooltip tooltip-top" data-tip={showTooltip ? 'Não Validado' : ''}>
|
||||||
<HelpCircle class="h-5 w-5 text-base-content/40" strokeWidth={2.5} />
|
<HelpCircle class="text-base-content/40 h-5 w-5" strokeWidth={2.5} />
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { CheckCircle2, X, Printer } from 'lucide-svelte';
|
|
||||||
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { CheckCircle2, Printer, X } from 'lucide-svelte';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
funcionarioId: Id<'funcionarios'>;
|
funcionarioId: Id<'funcionarios'>;
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
}) => void;
|
}) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { funcionarioId, onClose, onGenerate }: Props = $props();
|
const { funcionarioId, onClose, onGenerate }: Props = $props();
|
||||||
|
|
||||||
let modalRef: HTMLDialogElement;
|
let modalRef: HTMLDialogElement;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
saldoDiario: true,
|
saldoDiario: true,
|
||||||
bancoHoras: true,
|
bancoHoras: true,
|
||||||
alteracoesGestor: true,
|
alteracoesGestor: true,
|
||||||
dispensasRegistro: true,
|
dispensasRegistro: true
|
||||||
});
|
});
|
||||||
|
|
||||||
function selectAll() {
|
function selectAll() {
|
||||||
@@ -43,7 +43,8 @@
|
|||||||
|
|
||||||
function handleGenerate() {
|
function handleGenerate() {
|
||||||
onGenerate(sections);
|
onGenerate(sections);
|
||||||
onClose();
|
// 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() {
|
function handleClose() {
|
||||||
@@ -62,14 +63,14 @@
|
|||||||
|
|
||||||
<dialog bind:this={modalRef} class="modal modal-open">
|
<dialog bind:this={modalRef} class="modal modal-open">
|
||||||
<div class="modal-box max-w-4xl">
|
<div class="modal-box max-w-4xl">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="mb-6 flex items-center justify-between">
|
||||||
<h3 class="font-bold text-2xl">Selecionar Campos para Impressão</h3>
|
<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">
|
<button class="btn btn-sm btn-circle btn-ghost" onclick={handleClose} aria-label="Fechar">
|
||||||
<X class="h-5 w-5" />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
<div class="mb-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<!-- Seção 1: Dados do Funcionário -->
|
<!-- Seção 1: Dados do Funcionário -->
|
||||||
<div class="card bg-base-200">
|
<div class="card bg-base-200">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
@@ -81,7 +82,7 @@
|
|||||||
bind:checked={sections.dadosFuncionario}
|
bind:checked={sections.dadosFuncionario}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
Nome, matrícula, cargo e informações básicas
|
Nome, matrícula, cargo e informações básicas
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -98,7 +99,7 @@
|
|||||||
bind:checked={sections.registrosPonto}
|
bind:checked={sections.registrosPonto}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
Data, tipo, horário e status de cada registro
|
Data, tipo, horário e status de cada registro
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +116,7 @@
|
|||||||
bind:checked={sections.saldoDiario}
|
bind:checked={sections.saldoDiario}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
Saldo em horas e minutos de cada dia (positivo/negativo)
|
Saldo em horas e minutos de cada dia (positivo/negativo)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -132,9 +133,7 @@
|
|||||||
bind:checked={sections.bancoHoras}
|
bind:checked={sections.bancoHoras}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
<p class="text-base-content/70 mt-2 text-sm">Saldo acumulado do banco de horas</p>
|
||||||
Saldo acumulado do banco de horas
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -149,7 +148,7 @@
|
|||||||
bind:checked={sections.alteracoesGestor}
|
bind:checked={sections.alteracoesGestor}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
Edições e ajustes realizados pelo gestor (se houver)
|
Edições e ajustes realizados pelo gestor (se houver)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -166,7 +165,7 @@
|
|||||||
bind:checked={sections.dispensasRegistro}
|
bind:checked={sections.dispensasRegistro}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<p class="text-sm text-base-content/70 mt-2">
|
<p class="text-base-content/70 mt-2 text-sm">
|
||||||
Períodos onde o funcionário esteve dispensado de registrar ponto
|
Períodos onde o funcionário esteve dispensado de registrar ponto
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,18 +174,12 @@
|
|||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-sm btn-outline" onclick={selectAll}>
|
<button class="btn btn-sm btn-outline" onclick={selectAll}> Selecionar Todos </button>
|
||||||
Selecionar Todos
|
<button class="btn btn-sm btn-outline" onclick={deselectAll}> Desmarcar Todos </button>
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline" onclick={deselectAll}>
|
|
||||||
Desmarcar Todos
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button class="btn btn-ghost" onclick={handleClose}>
|
<button class="btn btn-ghost" onclick={handleClose}> Cancelar </button>
|
||||||
Cancelar
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
|
<button class="btn btn-primary gap-2" onclick={handleGenerate}>
|
||||||
<Printer class="h-4 w-4" />
|
<Printer class="h-4 w-4" />
|
||||||
Gerar PDF
|
Gerar PDF
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user