Compare commits

...

24 Commits

Author SHA1 Message Date
5e7de6c943 chore: Remove Jitsi and theme documentation files and refine backend gitignore rules. 2025-11-28 09:20:23 -03:00
af21a35f05 feat: Add @convex-dev/better-auth dependency and refactor Dockerfile to support monorepo workspace builds, updating Turbo build output path. 2025-11-27 12:01:36 -03:00
277dc616b3 refactor: remove Jitsi Meet related configurations and server action definitions, and eliminate redundant Dockerfile copy. 2025-11-27 09:05:20 -03:00
0c0c7a29c0 fix: update Dockerfile path in deploy workflow
- Changed the Dockerfile path in the deploy workflow from './Dockerfile' to './apps/web/Dockerfile' to reflect the new directory structure.
2025-11-26 15:45:29 -03:00
be959eb230 feat: update Dockerfile and workflow for environment variable support
- Modified the Dockerfile to include ARG and ENV for PUBLIC_CONVEX_URL and PUBLIC_CONVEX_SITE_URL, enhancing configuration flexibility.
- Updated the deploy workflow to pass these environment variables during the build process.
- Adjusted package.json to use bun for script commands and added svelte-adapter-bun for improved Svelte integration.
2025-11-26 15:42:22 -03:00
86ae2a1084 modify docker file 2025-11-26 11:40:33 -03:00
e1bd6fa61a config docker pre mod 2025-11-26 11:08:36 -03:00
75989b0546 refactor: update Dockerfile for improved workspace structure and build process
- Adjusted the Dockerfile to copy package.json files from workspace packages, ensuring proper dependency resolution.
- Modified the build context in the deploy workflow to streamline the Docker image build process.
- Enhanced the build steps to navigate to the web app directory before building, ensuring correct application setup.
2025-11-26 10:48:01 -03:00
08869fe5da feat: add Bun setup step to deploy workflow
- Introduced a new step to set up Bun in the GitHub Actions deploy workflow, enhancing the build process for JavaScript applications.
2025-11-26 10:42:41 -03:00
71959f6553 fix: update branch name in deploy workflow configuration
- Changed the branch name from 'main' to 'master' in the GitHub Actions deploy workflow to align with repository conventions.
2025-11-26 10:28:11 -03:00
de694ed665 fix: update Docker image context and tags in deploy workflow
- Changed the Docker build context to './apps/web' for better organization.
- Updated the image tag from 'namespace/example:latest' to 'killercf/sgc:latest' to reflect the correct repository.
2025-11-26 10:25:30 -03:00
daee99191c feat: extend getInstanceWithSteps query to include notes metadata
- Added new fields for tracking who updated notes, their names, and the timestamp of the update.
- Refactored the retrieval of the updater's name to improve code clarity and efficiency.
- Enhanced the data structure returned by the query to support additional notes-related information.
2025-11-26 10:21:13 -03:00
6128c20da0 feat: implement sub-steps management in workflow editor
- Added functionality for creating, updating, and deleting sub-steps within the workflow editor.
- Introduced a modal for adding new sub-steps, including fields for name and description.
- Enhanced the UI to display sub-steps with status indicators and options for updating their status.
- Updated navigation links to reflect changes in the workflow structure, ensuring consistency across the application.
- Refactored related components to accommodate the new sub-steps feature, improving overall workflow management.
2025-11-25 14:14:43 -03:00
f8d9c17f63 feat: add Svelte DnD action and enhance flow management features
- Added "svelte-dnd-action" dependency to facilitate drag-and-drop functionality.
- Introduced new "Fluxos de Trabalho" section in the dashboard for managing workflow templates and instances.
- Updated permission handling for sectors and flow templates in the backend.
- Enhanced schema definitions to support flow templates, instances, and associated documents.
- Improved UI components to include new workflow management features across various dashboard pages.
2025-11-25 00:21:35 -03:00
409872352c docs: Create guidelines for integrating Convex with Svelte/SvelteKit. 2025-11-24 14:48:04 -03:00
d4a3214451 Merge remote-tracking branch 'origin' into refinament-1 2025-11-24 09:01:00 -03:00
649b9b145c Merge pull request #42 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-23 16:29:55 -03:00
ae4fc1c4d5 Merge pull request #41 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-23 15:11:29 -03:00
2d7761ee94 Merge pull request #40 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-22 22:41:20 -03:00
9dc816977d Merge pull request #39 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-22 18:40:54 -03:00
3cc35d3a1e feat: Add explicit TypeScript types and improve error handling for employee reports. 2025-11-22 16:52:31 -03:00
Kilder Costa
7871b87bb9 Merge pull request #38 from killer-cf/refinament-1
Refinament 1
2025-11-22 10:26:24 -03:00
b8a2e67f3a refactor: Update email configuration page to load data once and improve error handling, and add Svelte agent rules documentation. 2025-11-22 10:25:43 -03:00
ce94eb53b3 generated file 2025-11-21 14:02:22 -03:00
40 changed files with 9428 additions and 2716 deletions

View File

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

View File

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

1
.dockerignore Normal file
View File

@@ -0,0 +1 @@
node_modules

37
.github/workflows/deploy.yml vendored Normal file
View File

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

2
.gitignore vendored
View File

@@ -49,3 +49,5 @@ coverage
tmp tmp
temp temp
.eslintcache .eslintcache
out

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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`

72
apps/web/Dockerfile Normal file
View File

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

View File

@@ -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.

View File

@@ -4,9 +4,9 @@
"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"
@@ -22,7 +22,9 @@
"esbuild": "^0.25.11", "esbuild": "^0.25.11",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"svelte": "^5.38.1", "svelte": "^5.38.1",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.3.1", "svelte-check": "^4.3.1",
"svelte-dnd-action": "^0.9.67",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.12",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^7.1.2" "vite": "^7.1.2"

View File

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

View File

@@ -0,0 +1,397 @@
<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 ActionGuard from '$lib/components/ActionGuard.svelte';
const client = useConvexClient();
// Queries
const setoresQuery = useQuery(api.setores.list, {});
// Estado do modal
let showModal = $state(false);
let editingSetor = $state<{
_id: Id<'setores'>;
nome: string;
sigla: string;
} | null>(null);
// Estado do formulário
let nome = $state('');
let sigla = $state('');
let isSubmitting = $state(false);
let error = $state<string | null>(null);
// Modal de confirmação de exclusão
let showDeleteModal = $state(false);
let setorToDelete = $state<{ _id: Id<'setores'>; nome: string } | null>(null);
function openCreateModal() {
editingSetor = null;
nome = '';
sigla = '';
error = null;
showModal = true;
}
function openEditModal(setor: { _id: Id<'setores'>; nome: string; sigla: string }) {
editingSetor = setor;
nome = setor.nome;
sigla = setor.sigla;
error = null;
showModal = true;
}
function closeModal() {
showModal = false;
editingSetor = null;
nome = '';
sigla = '';
error = null;
}
function openDeleteModal(setor: { _id: Id<'setores'>; nome: string }) {
setorToDelete = setor;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
setorToDelete = null;
}
async function handleSubmit() {
if (!nome.trim() || !sigla.trim()) {
error = 'Nome e sigla são obrigatórios';
return;
}
isSubmitting = true;
error = null;
try {
if (editingSetor) {
await client.mutation(api.setores.update, {
id: editingSetor._id,
nome: nome.trim(),
sigla: sigla.trim().toUpperCase()
});
} else {
await client.mutation(api.setores.create, {
nome: nome.trim(),
sigla: sigla.trim().toUpperCase()
});
}
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erro ao salvar setor';
} finally {
isSubmitting = false;
}
}
async function handleDelete() {
if (!setorToDelete) return;
isSubmitting = true;
error = null;
try {
await client.mutation(api.setores.remove, { id: setorToDelete._id });
closeDeleteModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erro ao excluir setor';
} finally {
isSubmitting = false;
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
<!-- Header -->
<section
class="border-primary/25 from-primary/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
>
<div class="bg-primary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl space-y-4">
<span
class="border-primary/40 bg-primary/10 text-primary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
>
Configurações
</span>
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Gestão de Setores
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Gerencie os setores da organização. Setores são utilizados para organizar funcionários e
definir responsabilidades em fluxos de trabalho.
</p>
</div>
<div class="flex items-center gap-4">
<ActionGuard recurso="setores" acao="criar">
<button class="btn btn-primary shadow-lg" onclick={openCreateModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Novo Setor
</button>
</ActionGuard>
</div>
</div>
</section>
<!-- Lista de Setores -->
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
{#if setoresQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if !setoresQuery.data || setoresQuery.data.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4"
/>
</svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum setor cadastrado</h3>
<p class="text-base-content/50 mt-2">Clique em "Novo Setor" para criar o primeiro setor.</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Sigla</th>
<th>Nome</th>
<th>Criado em</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#each setoresQuery.data as setor (setor._id)}
<tr class="hover">
<td>
<span class="badge badge-primary badge-lg font-mono font-bold">
{setor.sigla}
</span>
</td>
<td class="font-medium">{setor.nome}</td>
<td class="text-base-content/60 text-sm">{formatDate(setor.createdAt)}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<ActionGuard recurso="setores" acao="editar">
<button
class="btn btn-ghost btn-sm"
onclick={() => openEditModal(setor)}
aria-label="Editar setor {setor.nome}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
</ActionGuard>
<ActionGuard recurso="setores" acao="excluir">
<button
class="btn btn-ghost btn-sm text-error"
onclick={() => openDeleteModal(setor)}
aria-label="Excluir setor {setor.nome}"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</ActionGuard>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</main>
<!-- Modal de Criação/Edição -->
{#if showModal}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">
{editingSetor ? 'Editar Setor' : 'Novo Setor'}
</h3>
{#if error}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }} class="mt-4 space-y-4">
<div class="form-control">
<label class="label" for="nome">
<span class="label-text">Nome do Setor</span>
</label>
<input
type="text"
id="nome"
bind:value={nome}
class="input input-bordered w-full"
placeholder="Ex: Tecnologia da Informação"
required
/>
</div>
<div class="form-control">
<label class="label" for="sigla">
<span class="label-text">Sigla</span>
</label>
<input
type="text"
id="sigla"
bind:value={sigla}
class="input input-bordered w-full uppercase"
placeholder="Ex: TI"
maxlength="10"
required
/>
<p class="label">
<span class="label-text-alt text-base-content/60">Máximo 10 caracteres</span>
</p>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeModal} disabled={isSubmitting}>
Cancelar
</button>
<button type="submit" class="btn btn-primary" disabled={isSubmitting}>
{#if isSubmitting}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{editingSetor ? 'Salvar' : 'Criar'}
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" onclick={closeModal} aria-label="Fechar modal"></button>
</div>
{/if}
<!-- Modal de Confirmação de Exclusão -->
{#if showDeleteModal && setorToDelete}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">Confirmar Exclusão</h3>
{#if error}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{error}</span>
</div>
{/if}
<p class="py-4">
Tem certeza que deseja excluir o setor <strong>{setorToDelete.nome}</strong>?
</p>
<p class="text-base-content/60 text-sm">
Esta ação não pode ser desfeita. Setores com funcionários ou passos de fluxo vinculados não
podem ser excluídos.
</p>
<div class="modal-action">
<button class="btn" onclick={closeDeleteModal} disabled={isSubmitting}>
Cancelar
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={isSubmitting}>
{#if isSubmitting}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Excluir
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeDeleteModal} aria-label="Fechar modal"></button>
</div>
{/if}

View File

@@ -0,0 +1,433 @@
<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 ActionGuard from '$lib/components/ActionGuard.svelte';
import { goto } from '$app/navigation';
const client = useConvexClient();
// Estado do filtro
let statusFilter = $state<'draft' | 'published' | 'archived' | undefined>(undefined);
// Query de templates
const templatesQuery = useQuery(
api.flows.listTemplates,
() => (statusFilter ? { status: statusFilter } : {})
);
// Modal de criação
let showCreateModal = $state(false);
let newTemplateName = $state('');
let newTemplateDescription = $state('');
let isCreating = $state(false);
let createError = $state<string | null>(null);
// Modal de confirmação de exclusão
let showDeleteModal = $state(false);
let templateToDelete = $state<{ _id: Id<'flowTemplates'>; name: string } | null>(null);
let isDeleting = $state(false);
let deleteError = $state<string | null>(null);
function openCreateModal() {
newTemplateName = '';
newTemplateDescription = '';
createError = null;
showCreateModal = true;
}
function closeCreateModal() {
showCreateModal = false;
newTemplateName = '';
newTemplateDescription = '';
createError = null;
}
async function handleCreate() {
if (!newTemplateName.trim()) {
createError = 'O nome é obrigatório';
return;
}
isCreating = true;
createError = null;
try {
const templateId = await client.mutation(api.flows.createTemplate, {
name: newTemplateName.trim(),
description: newTemplateDescription.trim() || undefined
});
closeCreateModal();
// Navegar para o editor
goto(`/fluxos/${templateId}/editor`);
} catch (e) {
createError = e instanceof Error ? e.message : 'Erro ao criar template';
} finally {
isCreating = false;
}
}
function openDeleteModal(template: { _id: Id<'flowTemplates'>; name: string }) {
templateToDelete = template;
deleteError = null;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
templateToDelete = null;
deleteError = null;
}
async function handleDelete() {
if (!templateToDelete) return;
isDeleting = true;
deleteError = null;
try {
await client.mutation(api.flows.deleteTemplate, { id: templateToDelete._id });
closeDeleteModal();
} catch (e) {
deleteError = e instanceof Error ? e.message : 'Erro ao excluir template';
} finally {
isDeleting = false;
}
}
async function handleStatusChange(templateId: Id<'flowTemplates'>, newStatus: 'draft' | 'published' | 'archived') {
try {
await client.mutation(api.flows.updateTemplate, {
id: templateId,
status: newStatus
});
} catch (e) {
console.error('Erro ao atualizar status:', e);
}
}
function getStatusBadge(status: 'draft' | 'published' | 'archived') {
switch (status) {
case 'draft':
return { class: 'badge-warning', label: 'Rascunho' };
case 'published':
return { class: 'badge-success', label: 'Publicado' };
case 'archived':
return { class: 'badge-neutral', label: 'Arquivado' };
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
<!-- Header -->
<section
class="border-secondary/25 from-secondary/10 via-base-100 to-primary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
>
<div class="bg-secondary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-primary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl space-y-4">
<span
class="border-secondary/40 bg-secondary/10 text-secondary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
>
Gestão de Fluxos
</span>
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Templates de Fluxo
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Crie e gerencie templates de fluxo de trabalho. Templates definem os passos e
responsabilidades que serão instanciados para projetos ou contratos.
</p>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<!-- Filtro de status -->
<select
class="select select-bordered"
bind:value={statusFilter}
>
<option value={undefined}>Todos os status</option>
<option value="draft">Rascunho</option>
<option value="published">Publicado</option>
<option value="archived">Arquivado</option>
</select>
<ActionGuard recurso="fluxos_templates" acao="criar">
<button class="btn btn-secondary shadow-lg" onclick={openCreateModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Novo Template
</button>
</ActionGuard>
</div>
</div>
</section>
<!-- Lista de Templates -->
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
{#if templatesQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-secondary"></span>
</div>
{:else if !templatesQuery.data || templatesQuery.data.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum template encontrado</h3>
<p class="text-base-content/50 mt-2">
{statusFilter ? 'Não há templates com este status.' : 'Clique em "Novo Template" para criar o primeiro.'}
</p>
</div>
{:else}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each templatesQuery.data as template (template._id)}
{@const statusBadge = getStatusBadge(template.status)}
<article
class="card bg-base-200/50 hover:bg-base-200 border transition-all duration-200 hover:shadow-md"
>
<div class="card-body">
<div class="flex items-start justify-between gap-2">
<h2 class="card-title text-lg">{template.name}</h2>
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
</div>
{#if template.description}
<p class="text-base-content/60 text-sm line-clamp-2">
{template.description}
</p>
{/if}
<div class="text-base-content/50 mt-2 flex flex-wrap gap-4 text-xs">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
{template.stepsCount} passos
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
{formatDate(template.createdAt)}
</span>
</div>
<div class="card-actions mt-4 justify-between">
<div class="dropdown">
<button class="btn btn-ghost btn-sm" aria-label="Alterar status">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" />
</svg>
</button>
<ul class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow" role="menu">
{#if template.status !== 'draft'}
<li>
<button onclick={() => handleStatusChange(template._id, 'draft')}>
Voltar para Rascunho
</button>
</li>
{/if}
{#if template.status !== 'published'}
<li>
<button onclick={() => handleStatusChange(template._id, 'published')}>
Publicar
</button>
</li>
{/if}
{#if template.status !== 'archived'}
<li>
<button onclick={() => handleStatusChange(template._id, 'archived')}>
Arquivar
</button>
</li>
{/if}
<li class="mt-2 border-t pt-2">
<button class="text-error" onclick={() => openDeleteModal(template)}>
Excluir
</button>
</li>
</ul>
</div>
<a
href="/fluxos/{template._id}/editor"
class="btn btn-secondary btn-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
Editar
</a>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
<!-- Link para Instâncias -->
<section class="flex justify-center">
<a href="/licitacoes/fluxos" class="btn btn-outline btn-lg">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
Ver Fluxos de Trabalho
</a>
</section>
</main>
<!-- Modal de Criação -->
{#if showCreateModal}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Novo Template de Fluxo</h3>
{#if createError}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>{createError}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
<div class="form-control">
<label class="label" for="template-name">
<span class="label-text">Nome do Template</span>
</label>
<input
type="text"
id="template-name"
bind:value={newTemplateName}
class="input input-bordered w-full"
placeholder="Ex: Fluxo de Aprovação de Contrato"
required
/>
</div>
<div class="form-control">
<label class="label" for="template-description">
<span class="label-text">Descrição (opcional)</span>
</label>
<textarea
id="template-description"
bind:value={newTemplateDescription}
class="textarea textarea-bordered w-full"
placeholder="Descreva o propósito deste fluxo..."
rows="3"
></textarea>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
Cancelar
</button>
<button type="submit" class="btn btn-secondary" disabled={isCreating}>
{#if isCreating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Criar e Editar
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" onclick={closeCreateModal} aria-label="Fechar modal"></button>
</div>
{/if}
<!-- Modal de Confirmação de Exclusão -->
{#if showDeleteModal && templateToDelete}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">Confirmar Exclusão</h3>
{#if deleteError}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>{deleteError}</span>
</div>
{/if}
<p class="py-4">
Tem certeza que deseja excluir o template <strong>{templateToDelete.name}</strong>?
</p>
<p class="text-base-content/60 text-sm">
Esta ação não pode ser desfeita. Templates com instâncias vinculadas não podem ser excluídos.
</p>
<div class="modal-action">
<button class="btn" onclick={closeDeleteModal} disabled={isDeleting}>
Cancelar
</button>
<button class="btn btn-error" onclick={handleDelete} disabled={isDeleting}>
{#if isDeleting}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Excluir
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeDeleteModal} aria-label="Fechar modal"></button>
</div>
{/if}

View File

@@ -0,0 +1,722 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolve } from '$app/paths';
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 ActionGuard from '$lib/components/ActionGuard.svelte';
const client = useConvexClient();
const instanceId = $derived($page.params.id as Id<'flowInstances'>);
// Query da instância com passos
const instanceQuery = useQuery(api.flows.getInstanceWithSteps, () => ({ id: instanceId }));
// Query de usuários (para reatribuição) - será filtrado por setor no modal
const usuariosQuery = useQuery(api.usuarios.listar, {});
// Query de usuários por setor para atribuição
let usuariosPorSetorQuery = $state<ReturnType<typeof useQuery<typeof api.flows.getUsuariosBySetorForAssignment>> | null>(null);
// Estado de operações
let isProcessing = $state(false);
let processingError = $state<string | null>(null);
// Modal de reatribuição
let showReassignModal = $state(false);
let stepToReassign = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string } | null>(null);
let newAssigneeId = $state<Id<'usuarios'> | ''>('');
// Modal de notas
let showNotesModal = $state(false);
let stepForNotes = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string; notes: string } | null>(null);
let editedNotes = $state('');
// Modal de upload
let showUploadModal = $state(false);
let stepForUpload = $state<{ _id: Id<'flowInstanceSteps'>; stepName: string } | null>(null);
let uploadFile = $state<File | null>(null);
let isUploading = $state(false);
// Modal de confirmação de cancelamento
let showCancelModal = $state(false);
async function handleStartStep(stepId: Id<'flowInstanceSteps'>) {
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.updateStepStatus, {
instanceStepId: stepId,
status: 'in_progress'
});
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao iniciar passo';
} finally {
isProcessing = false;
}
}
async function handleCompleteStep(stepId: Id<'flowInstanceSteps'>) {
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.completeStep, {
instanceStepId: stepId
});
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao completar passo';
} finally {
isProcessing = false;
}
}
async function handleBlockStep(stepId: Id<'flowInstanceSteps'>) {
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.updateStepStatus, {
instanceStepId: stepId,
status: 'blocked'
});
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao bloquear passo';
} finally {
isProcessing = false;
}
}
function openReassignModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string; assignedToId?: Id<'usuarios'> }) {
stepToReassign = step;
newAssigneeId = step.assignedToId ?? '';
showReassignModal = true;
}
function closeReassignModal() {
showReassignModal = false;
stepToReassign = null;
newAssigneeId = '';
}
async function handleReassign() {
if (!stepToReassign || !newAssigneeId) return;
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.reassignStep, {
instanceStepId: stepToReassign._id,
assignedToId: newAssigneeId as Id<'usuarios'>
});
closeReassignModal();
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao reatribuir passo';
} finally {
isProcessing = false;
}
}
function openNotesModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string; notes?: string }) {
stepForNotes = { ...step, notes: step.notes ?? '' };
editedNotes = step.notes ?? '';
showNotesModal = true;
}
function closeNotesModal() {
showNotesModal = false;
stepForNotes = null;
editedNotes = '';
}
async function handleSaveNotes() {
if (!stepForNotes) return;
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.updateStepNotes, {
instanceStepId: stepForNotes._id,
notes: editedNotes
});
closeNotesModal();
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao salvar notas';
} finally {
isProcessing = false;
}
}
function openUploadModal(step: { _id: Id<'flowInstanceSteps'>; stepName: string }) {
stepForUpload = step;
uploadFile = null;
showUploadModal = true;
}
function closeUploadModal() {
showUploadModal = false;
stepForUpload = null;
uploadFile = null;
}
function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
uploadFile = input.files[0];
}
}
async function handleUpload() {
if (!stepForUpload || !uploadFile) return;
isUploading = true;
processingError = null;
try {
// Gerar URL de upload
const uploadUrl = await client.mutation(api.flows.generateUploadUrl, {});
// Fazer upload do arquivo
const response = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': uploadFile.type },
body: uploadFile
});
if (!response.ok) {
throw new Error('Falha no upload do arquivo');
}
const { storageId } = await response.json();
// Registrar o documento
await client.mutation(api.flows.registerDocument, {
flowInstanceStepId: stepForUpload._id,
storageId,
name: uploadFile.name
});
closeUploadModal();
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao fazer upload';
} finally {
isUploading = false;
}
}
async function handleDeleteDocument(documentId: Id<'flowInstanceDocuments'>) {
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.deleteDocument, { id: documentId });
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao excluir documento';
} finally {
isProcessing = false;
}
}
async function handleCancelInstance() {
isProcessing = true;
processingError = null;
try {
await client.mutation(api.flows.cancelInstance, { id: instanceId });
showCancelModal = false;
} catch (e) {
processingError = e instanceof Error ? e.message : 'Erro ao cancelar instância';
} finally {
isProcessing = false;
}
}
function getStatusBadge(status: 'pending' | 'in_progress' | 'completed' | 'blocked') {
switch (status) {
case 'pending':
return { class: 'badge-ghost', label: 'Pendente', icon: 'clock' };
case 'in_progress':
return { class: 'badge-info', label: 'Em Progresso', icon: 'play' };
case 'completed':
return { class: 'badge-success', label: 'Concluído', icon: 'check' };
case 'blocked':
return { class: 'badge-error', label: 'Bloqueado', icon: 'x' };
}
}
function getInstanceStatusBadge(status: 'active' | 'completed' | 'cancelled') {
switch (status) {
case 'active':
return { class: 'badge-info', label: 'Em Andamento' };
case 'completed':
return { class: 'badge-success', label: 'Concluído' };
case 'cancelled':
return { class: 'badge-error', label: 'Cancelado' };
}
}
function formatDate(timestamp: number | undefined): string {
if (!timestamp) return '-';
return new Date(timestamp).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function isStepCurrent(stepId: Id<'flowInstanceSteps'>): boolean {
return instanceQuery.data?.instance.currentStepId === stepId;
}
function isOverdue(dueDate: number | undefined): boolean {
if (!dueDate) return false;
return Date.now() > dueDate;
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
{#if instanceQuery.isLoading}
<div class="flex items-center justify-center py-24">
<span class="loading loading-spinner loading-lg text-info"></span>
</div>
{:else if !instanceQuery.data}
<div class="flex flex-col items-center justify-center py-24 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-base-content/30 h-16 w-16" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Fluxo não encontrado</h3>
<a href={resolve('/(dashboard)/licitacoes/fluxos')} class="btn btn-ghost mt-4">Voltar para lista</a>
</div>
{:else}
{@const instance = instanceQuery.data.instance}
{@const steps = instanceQuery.data.steps}
{@const statusBadge = getInstanceStatusBadge(instance.status)}
<!-- Header -->
<section
class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
>
<div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10">
<div class="flex items-center gap-4 mb-6">
<a href={resolve('/(dashboard)/licitacoes/fluxos')} class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</a>
<span class="badge {statusBadge.class} badge-lg">{statusBadge.label}</span>
</div>
<div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
<div class="max-w-3xl space-y-4">
<h1 class="text-base-content text-3xl leading-tight font-black sm:text-4xl">
{instance.templateName ?? 'Fluxo'}
</h1>
<div class="flex flex-wrap gap-4">
{#if instance.contratoId}
<div class="flex items-center gap-2">
<span class="badge badge-outline">Contrato</span>
<span class="text-base-content/70 font-medium">{instance.contratoId}</span>
</div>
{/if}
<div class="flex items-center gap-2 text-base-content/60">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
Gerente: {instance.managerName ?? '-'}
</div>
<div class="flex items-center gap-2 text-base-content/60">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
Iniciado: {formatDate(instance.startedAt)}
</div>
</div>
</div>
{#if instance.status === 'active'}
<ActionGuard recurso="fluxos_instancias" acao="cancelar">
<button class="btn btn-error btn-outline" onclick={() => showCancelModal = true}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Cancelar Fluxo
</button>
</ActionGuard>
{/if}
</div>
</div>
</section>
<!-- Erro global -->
{#if processingError}
<div class="alert alert-error">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<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>{processingError}</span>
<button class="btn btn-ghost btn-sm" onclick={() => processingError = null}>Fechar</button>
</div>
{/if}
<!-- Timeline de Passos -->
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
<h2 class="mb-6 text-xl font-bold">Timeline do Fluxo</h2>
<div class="space-y-6">
{#each steps as step, index (step._id)}
{@const stepStatus = getStatusBadge(step.status)}
{@const isCurrent = isStepCurrent(step._id)}
{@const overdue = step.status !== 'completed' && isOverdue(step.dueDate)}
<div class="relative flex gap-6 {index < steps.length - 1 ? 'pb-6' : ''}">
<!-- Linha conectora -->
{#if index < steps.length - 1}
<div class="absolute left-5 top-10 bottom-0 w-0.5 {step.status === 'completed' ? 'bg-success' : 'bg-base-300'}"></div>
{/if}
<!-- Indicador de status -->
<div class="z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full {step.status === 'completed' ? 'bg-success text-success-content' : isCurrent ? 'bg-info text-info-content' : step.status === 'blocked' ? 'bg-error text-error-content' : 'bg-base-300 text-base-content'}">
{#if step.status === 'completed'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
{:else if step.status === 'blocked'}
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
{:else}
<span class="text-sm font-bold">{index + 1}</span>
{/if}
</div>
<!-- Conteúdo do passo -->
<div class="flex-1 rounded-xl border {isCurrent ? 'border-info bg-info/5' : 'bg-base-200/50'} p-4">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<div class="flex items-center gap-2">
<h3 class="font-semibold">{step.stepName}</h3>
<span class="badge {stepStatus.class} badge-sm">{stepStatus.label}</span>
{#if overdue}
<span class="badge badge-warning badge-sm">Atrasado</span>
{/if}
</div>
{#if step.stepDescription}
<p class="text-base-content/60 mt-1 text-sm">{step.stepDescription}</p>
{/if}
<div class="text-base-content/50 mt-2 flex flex-wrap gap-4 text-xs">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5" />
</svg>
{step.setorNome ?? 'Setor não definido'}
</span>
{#if step.assignedToName}
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
{step.assignedToName}
</span>
{/if}
{#if step.dueDate}
<span class="flex items-center gap-1 {overdue ? 'text-warning' : ''}">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
Prazo: {formatDate(step.dueDate)}
</span>
{/if}
</div>
</div>
<!-- Ações do passo -->
{#if instance.status === 'active'}
<div class="flex flex-wrap gap-2">
{#if step.status === 'pending'}
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
<button
class="btn btn-info btn-sm"
onclick={() => handleStartStep(step._id)}
disabled={isProcessing}
>
Iniciar
</button>
</ActionGuard>
{:else if step.status === 'in_progress'}
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
<button
class="btn btn-success btn-sm"
onclick={() => handleCompleteStep(step._id)}
disabled={isProcessing}
>
Concluir
</button>
<button
class="btn btn-warning btn-sm"
onclick={() => handleBlockStep(step._id)}
disabled={isProcessing}
>
Bloquear
</button>
</ActionGuard>
{:else if step.status === 'blocked'}
<ActionGuard recurso="fluxos_instancias" acao="avancar_passo">
<button
class="btn btn-info btn-sm"
onclick={() => handleStartStep(step._id)}
disabled={isProcessing}
>
Desbloquear
</button>
</ActionGuard>
{/if}
<ActionGuard recurso="fluxos_instancias" acao="atribuir_usuario">
<button
class="btn btn-ghost btn-sm"
onclick={() => openReassignModal(step)}
aria-label="Reatribuir responsável"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
</button>
</ActionGuard>
<button
class="btn btn-ghost btn-sm"
onclick={() => openNotesModal(step)}
aria-label="Editar notas"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<ActionGuard recurso="fluxos_documentos" acao="upload">
<button
class="btn btn-ghost btn-sm"
onclick={() => openUploadModal(step)}
aria-label="Upload de documento"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</button>
</ActionGuard>
</div>
{/if}
</div>
<!-- Notas -->
{#if step.notes}
<div class="bg-base-300/50 mt-4 rounded-lg p-3">
<p class="text-base-content/70 text-sm whitespace-pre-wrap">{step.notes}</p>
</div>
{/if}
<!-- Documentos -->
{#if step.documents && step.documents.length > 0}
<div class="mt-4">
<h4 class="text-base-content/70 mb-2 text-xs font-semibold uppercase">Documentos</h4>
<div class="flex flex-wrap gap-2">
{#each step.documents as doc (doc._id)}
<div class="badge badge-outline gap-2 py-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
{doc.name}
<ActionGuard recurso="fluxos_documentos" acao="excluir">
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => handleDeleteDocument(doc._id)}
aria-label="Excluir documento {doc.name}"
>
×
</button>
</ActionGuard>
</div>
{/each}
</div>
</div>
{/if}
<!-- Datas de início/fim -->
{#if step.startedAt || step.finishedAt}
<div class="text-base-content/40 mt-4 flex gap-4 text-xs">
{#if step.startedAt}
<span>Iniciado: {formatDate(step.startedAt)}</span>
{/if}
{#if step.finishedAt}
<span>Concluído: {formatDate(step.finishedAt)}</span>
{/if}
</div>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}
</main>
<!-- Modal de Reatribuição -->
{#if showReassignModal && stepToReassign}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Reatribuir Responsável</h3>
<p class="text-base-content/60 mt-2">
Selecione o novo responsável pelo passo <strong>{stepToReassign.stepName}</strong>
</p>
<div class="form-control mt-4">
<label class="label" for="assignee-select">
<span class="label-text">Responsável</span>
</label>
<select
id="assignee-select"
bind:value={newAssigneeId}
class="select select-bordered w-full"
>
<option value="">Selecione um usuário</option>
{#if usuariosQuery.data}
{#each usuariosQuery.data as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
{/if}
</select>
</div>
<div class="modal-action">
<button class="btn" onclick={closeReassignModal} disabled={isProcessing}>
Cancelar
</button>
<button class="btn btn-primary" onclick={handleReassign} disabled={isProcessing || !newAssigneeId}>
{#if isProcessing}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Reatribuir
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeReassignModal} aria-label="Fechar modal"></button>
</div>
{/if}
<!-- Modal de Notas -->
{#if showNotesModal && stepForNotes}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Notas do Passo</h3>
<p class="text-base-content/60 mt-2">
Adicione ou edite notas para o passo <strong>{stepForNotes.stepName}</strong>
</p>
<div class="form-control mt-4">
<label class="label" for="notes-textarea">
<span class="label-text">Notas</span>
</label>
<textarea
id="notes-textarea"
bind:value={editedNotes}
class="textarea textarea-bordered w-full"
rows="5"
placeholder="Adicione observações, comentários ou informações relevantes..."
></textarea>
</div>
<div class="modal-action">
<button class="btn" onclick={closeNotesModal} disabled={isProcessing}>
Cancelar
</button>
<button class="btn btn-primary" onclick={handleSaveNotes} disabled={isProcessing}>
{#if isProcessing}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Salvar
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeNotesModal} aria-label="Fechar modal"></button>
</div>
{/if}
<!-- Modal de Upload -->
{#if showUploadModal && stepForUpload}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Upload de Documento</h3>
<p class="text-base-content/60 mt-2">
Anexe um documento ao passo <strong>{stepForUpload.stepName}</strong>
</p>
<div class="form-control mt-4">
<label class="label" for="file-input">
<span class="label-text">Arquivo</span>
</label>
<input
type="file"
id="file-input"
class="file-input file-input-bordered w-full"
onchange={handleFileSelect}
/>
</div>
{#if uploadFile}
<p class="text-base-content/60 mt-2 text-sm">
Arquivo selecionado: <strong>{uploadFile.name}</strong>
</p>
{/if}
<div class="modal-action">
<button class="btn" onclick={closeUploadModal} disabled={isUploading}>
Cancelar
</button>
<button class="btn btn-primary" onclick={handleUpload} disabled={isUploading || !uploadFile}>
{#if isUploading}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Enviar
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeUploadModal} aria-label="Fechar modal"></button>
</div>
{/if}
<!-- Modal de Cancelamento -->
{#if showCancelModal}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold text-error">Cancelar Fluxo</h3>
<p class="py-4">
Tem certeza que deseja cancelar este fluxo?
</p>
<p class="text-base-content/60 text-sm">
Esta ação não pode ser desfeita. Todos os passos pendentes serão marcados como cancelados.
</p>
<div class="modal-action">
<button class="btn" onclick={() => showCancelModal = false} disabled={isProcessing}>
Voltar
</button>
<button class="btn btn-error" onclick={handleCancelInstance} disabled={isProcessing}>
{#if isProcessing}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Cancelar Fluxo
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={() => showCancelModal = false} aria-label="Fechar modal"></button>
</div>
{/if}

View File

@@ -0,0 +1,800 @@
<script lang="ts">
import { page } from '$app/stores';
import { resolve } from '$app/paths';
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 { flip } from 'svelte/animate';
const client = useConvexClient();
const templateId = $derived($page.params.id as Id<'flowTemplates'>);
// Queries
const templateQuery = useQuery(api.flows.getTemplate, () => ({ id: templateId }));
const stepsQuery = useQuery(api.flows.listStepsByTemplate, () => ({ flowTemplateId: templateId }));
const setoresQuery = useQuery(api.setores.list, {});
// Query de sub-etapas (reativa baseada no step selecionado)
const subEtapasQuery = useQuery(
api.flows.listarSubEtapas,
() => selectedStepId ? { flowStepId: selectedStepId } : 'skip'
);
// Estado local para drag and drop
let localSteps = $state<NonNullable<typeof stepsQuery.data>>([]);
let isDragging = $state(false);
// Sincronizar com query
$effect(() => {
if (stepsQuery.data && !isDragging) {
localSteps = [...stepsQuery.data];
}
});
// Estado do passo selecionado
let selectedStepId = $state<Id<'flowSteps'> | null>(null);
const selectedStep = $derived(localSteps?.find((s) => s._id === selectedStepId));
// Modal de novo passo
let showNewStepModal = $state(false);
let newStepName = $state('');
let newStepDescription = $state('');
let newStepDuration = $state(1);
let newStepSetorId = $state<Id<'setores'> | ''>('');
let isCreatingStep = $state(false);
let stepError = $state<string | null>(null);
// Estado de edição
let editingStep = $state<{
name: string;
description: string;
expectedDuration: number;
setorId: Id<'setores'>;
requiredDocuments: string[];
} | null>(null);
let isSavingStep = $state(false);
// Estado de sub-etapas
let showSubEtapaModal = $state(false);
let subEtapaNome = $state('');
let subEtapaDescricao = $state('');
let isCriandoSubEtapa = $state(false);
let subEtapaError = $state<string | null>(null);
// Inicializar edição quando selecionar passo
$effect(() => {
if (selectedStep) {
editingStep = {
name: selectedStep.name,
description: selectedStep.description ?? '',
expectedDuration: selectedStep.expectedDuration,
setorId: selectedStep.setorId,
requiredDocuments: selectedStep.requiredDocuments ?? []
};
} else {
editingStep = null;
}
});
function openNewStepModal() {
newStepName = '';
newStepDescription = '';
newStepDuration = 1;
newStepSetorId = setoresQuery.data?.[0]?._id ?? '';
stepError = null;
showNewStepModal = true;
}
function closeNewStepModal() {
showNewStepModal = false;
}
async function handleCreateStep() {
if (!newStepName.trim()) {
stepError = 'O nome é obrigatório';
return;
}
if (!newStepSetorId) {
stepError = 'Selecione um setor';
return;
}
isCreatingStep = true;
stepError = null;
try {
await client.mutation(api.flows.createStep, {
flowTemplateId: templateId,
name: newStepName.trim(),
description: newStepDescription.trim() || undefined,
expectedDuration: newStepDuration,
setorId: newStepSetorId as Id<'setores'>
});
closeNewStepModal();
} catch (e) {
stepError = e instanceof Error ? e.message : 'Erro ao criar passo';
} finally {
isCreatingStep = false;
}
}
async function handleSaveStep() {
if (!selectedStepId || !editingStep) return;
isSavingStep = true;
try {
await client.mutation(api.flows.updateStep, {
id: selectedStepId,
name: editingStep.name,
description: editingStep.description || undefined,
expectedDuration: editingStep.expectedDuration,
setorId: editingStep.setorId,
requiredDocuments: editingStep.requiredDocuments.length > 0 ? editingStep.requiredDocuments : undefined
});
} catch (e) {
console.error('Erro ao salvar passo:', e);
} finally {
isSavingStep = false;
}
}
async function handleDeleteStep() {
if (!selectedStepId) return;
try {
await client.mutation(api.flows.deleteStep, { id: selectedStepId });
selectedStepId = null;
} catch (e) {
console.error('Erro ao excluir passo:', e);
}
}
async function moveStepUp(index: number) {
if (index === 0 || !localSteps) return;
const previousSteps = [...localSteps];
const newSteps = [...localSteps];
[newSteps[index - 1], newSteps[index]] = [newSteps[index], newSteps[index - 1]];
localSteps = newSteps;
isDragging = true;
const stepIds = newSteps.map((s) => s._id);
try {
await client.mutation(api.flows.reorderSteps, {
flowTemplateId: templateId,
stepIds
});
} catch (err) {
console.error('Erro ao reordenar passos:', err);
// Reverter em caso de erro
localSteps = previousSteps;
} finally {
isDragging = false;
}
}
async function moveStepDown(index: number) {
if (!localSteps || index === localSteps.length - 1) return;
const previousSteps = [...localSteps];
const newSteps = [...localSteps];
[newSteps[index], newSteps[index + 1]] = [newSteps[index + 1], newSteps[index]];
localSteps = newSteps;
isDragging = true;
const stepIds = newSteps.map((s) => s._id);
try {
await client.mutation(api.flows.reorderSteps, {
flowTemplateId: templateId,
stepIds
});
} catch (err) {
console.error('Erro ao reordenar passos:', err);
// Reverter em caso de erro
localSteps = previousSteps;
} finally {
isDragging = false;
}
}
async function handlePublish() {
try {
await client.mutation(api.flows.updateTemplate, {
id: templateId,
status: 'published'
});
} catch (e) {
console.error('Erro ao publicar:', e);
}
}
function addRequiredDocument() {
if (editingStep) {
editingStep.requiredDocuments = [...editingStep.requiredDocuments, ''];
}
}
function removeRequiredDocument(index: number) {
if (editingStep) {
editingStep.requiredDocuments = editingStep.requiredDocuments.filter((_, i) => i !== index);
}
}
function updateRequiredDocument(index: number, value: string) {
if (editingStep) {
editingStep.requiredDocuments = editingStep.requiredDocuments.map((doc, i) =>
i === index ? value : doc
);
}
}
// Funções de sub-etapas
function openSubEtapaModal() {
subEtapaNome = '';
subEtapaDescricao = '';
subEtapaError = null;
showSubEtapaModal = true;
}
function closeSubEtapaModal() {
showSubEtapaModal = false;
subEtapaNome = '';
subEtapaDescricao = '';
subEtapaError = null;
}
async function handleCriarSubEtapa() {
if (!selectedStepId || !subEtapaNome.trim()) {
subEtapaError = 'O nome é obrigatório';
return;
}
isCriandoSubEtapa = true;
subEtapaError = null;
try {
await client.mutation(api.flows.criarSubEtapa, {
flowStepId: selectedStepId,
name: subEtapaNome.trim(),
description: subEtapaDescricao.trim() || undefined
});
closeSubEtapaModal();
} catch (e) {
subEtapaError = e instanceof Error ? e.message : 'Erro ao criar sub-etapa';
} finally {
isCriandoSubEtapa = false;
}
}
async function handleDeletarSubEtapa(subEtapaId: Id<'flowSubSteps'>) {
if (!confirm('Tem certeza que deseja excluir esta sub-etapa?')) {
return;
}
try {
await client.mutation(api.flows.deletarSubEtapa, { subEtapaId });
} catch (e) {
alert(e instanceof Error ? e.message : 'Erro ao deletar sub-etapa');
}
}
async function handleAtualizarStatusSubEtapa(subEtapaId: Id<'flowSubSteps'>, novoStatus: 'pending' | 'in_progress' | 'completed' | 'blocked') {
try {
await client.mutation(api.flows.atualizarSubEtapa, {
subEtapaId,
status: novoStatus
});
} catch (e) {
alert(e instanceof Error ? e.message : 'Erro ao atualizar status');
}
}
</script>
<main class="flex h-[calc(100vh-4rem)] flex-col">
<!-- Header -->
<header class="bg-base-100 border-b px-6 py-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<a href={resolve('/(dashboard)/fluxos')} class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Voltar
</a>
<div>
{#if templateQuery.isLoading}
<div class="h-6 w-48 animate-pulse rounded bg-base-300"></div>
{:else if templateQuery.data}
<h1 class="text-xl font-bold">{templateQuery.data.name}</h1>
<p class="text-base-content/60 text-sm">
{templateQuery.data.description ?? 'Sem descrição'}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
{#if templateQuery.data?.status === 'draft'}
<button
class="btn btn-success btn-sm"
onclick={handlePublish}
disabled={!localSteps || localSteps.length === 0}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
Publicar
</button>
{:else if templateQuery.data?.status === 'published'}
<span class="badge badge-success">Publicado</span>
{:else if templateQuery.data?.status === 'archived'}
<span class="badge badge-neutral">Arquivado</span>
{/if}
</div>
</div>
</header>
<!-- Conteúdo Principal -->
<div class="flex flex-1 overflow-hidden">
<!-- Lista de Passos (Kanban) -->
<div class="flex-1 overflow-auto p-6">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">Passos do Fluxo</h2>
<button class="btn btn-secondary btn-sm" onclick={openNewStepModal}>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Novo Passo
</button>
</div>
{#if stepsQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-secondary"></span>
</div>
{:else if !localSteps || localSteps.length === 0}
<div class="flex flex-col items-center justify-center rounded-xl border-2 border-dashed py-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-base-content/30 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
<p class="text-base-content/60 mt-4">Nenhum passo definido</p>
<p class="text-base-content/40 text-sm">Clique em "Novo Passo" para adicionar o primeiro passo</p>
</div>
{:else if localSteps && localSteps.length > 0}
<div class="space-y-3">
{#each localSteps as step, index (step._id)}
<div
class="card w-full border text-left transition-all duration-200 {selectedStepId === step._id ? 'border-secondary bg-secondary/10 ring-2 ring-secondary' : 'bg-base-100 hover:bg-base-200'}"
animate:flip={{ duration: 200 }}
>
<div class="card-body p-4">
<div class="flex items-start gap-3">
<div class="bg-secondary/20 text-secondary flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-sm font-bold">
{index + 1}
</div>
<div
class="min-w-0 flex-1 cursor-pointer"
onclick={() => selectedStepId = step._id}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
selectedStepId = step._id;
}
}}
role="button"
tabindex="0"
>
<h3 class="font-semibold">{step.name}</h3>
{#if step.description}
<p class="text-base-content/60 mt-1 truncate text-sm">{step.description}</p>
{/if}
<div class="text-base-content/50 mt-2 flex flex-wrap gap-3 text-xs">
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5" />
</svg>
{step.setorNome ?? 'Setor não definido'}
</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" 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>
{step.expectedDuration} dia{step.expectedDuration > 1 ? 's' : ''}
</span>
</div>
</div>
<div class="flex flex-col gap-1">
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={() => moveStepUp(index)}
disabled={index === 0 || isDragging}
aria-label="Mover passo para cima"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={() => moveStepDown(index)}
disabled={index === localSteps.length - 1 || isDragging}
aria-label="Mover passo para baixo"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Sidebar de Edição -->
<aside class="bg-base-200 w-96 shrink-0 overflow-auto border-l p-6">
{#if selectedStep && editingStep}
<div class="space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Editar Passo</h3>
<button
class="btn btn-ghost btn-sm"
onclick={() => selectedStepId = null}
aria-label="Fechar edição"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="form-control">
<label class="label" for="step-name">
<span class="label-text">Nome</span>
</label>
<input
type="text"
id="step-name"
bind:value={editingStep.name}
class="input input-bordered w-full"
/>
</div>
<div class="form-control">
<label class="label" for="step-description">
<span class="label-text">Descrição</span>
</label>
<textarea
id="step-description"
bind:value={editingStep.description}
class="textarea textarea-bordered w-full"
rows="3"
></textarea>
</div>
<div class="form-control">
<label class="label" for="step-duration">
<span class="label-text">Duração Esperada (dias)</span>
</label>
<input
type="number"
id="step-duration"
bind:value={editingStep.expectedDuration}
class="input input-bordered w-full"
min="1"
/>
</div>
<div class="form-control">
<label class="label" for="step-setor">
<span class="label-text">Setor Responsável</span>
</label>
<select
id="step-setor"
bind:value={editingStep.setorId}
class="select select-bordered w-full"
>
{#if setoresQuery.data}
{#each setoresQuery.data as setor (setor._id)}
<option value={setor._id}>{setor.nome} ({setor.sigla})</option>
{/each}
{/if}
</select>
</div>
<div class="form-control">
<span class="label">
<span class="label-text">Documentos Obrigatórios</span>
</span>
<div class="space-y-2">
{#each editingStep.requiredDocuments as doc, index (index)}
<div class="flex gap-2">
<input
type="text"
value={doc}
oninput={(e) => updateRequiredDocument(index, e.currentTarget.value)}
class="input input-bordered input-sm flex-1"
placeholder="Nome do documento"
/>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
onclick={() => removeRequiredDocument(index)}
aria-label="Remover documento"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/each}
<button
type="button"
class="btn btn-ghost btn-sm w-full"
onclick={addRequiredDocument}
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Adicionar Documento
</button>
</div>
</div>
<!-- Sub-etapas -->
<div class="form-control">
<div class="label">
<span class="label-text font-semibold">Sub-etapas</span>
<button
type="button"
class="btn btn-ghost btn-xs"
onclick={openSubEtapaModal}
aria-label="Adicionar sub-etapa"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Adicionar
</button>
</div>
<div class="space-y-2">
{#if subEtapasQuery.isLoading}
<div class="flex items-center justify-center py-4">
<span class="loading loading-spinner loading-sm"></span>
</div>
{:else if subEtapasQuery.data && subEtapasQuery.data.length > 0}
{#each subEtapasQuery.data as subEtapa (subEtapa._id)}
<div class="flex items-center gap-2 rounded-lg border border-base-300 bg-base-100 p-2">
<div class="flex-1">
<div class="font-medium text-sm">{subEtapa.name}</div>
{#if subEtapa.description}
<div class="text-base-content/60 text-xs">{subEtapa.description}</div>
{/if}
<div class="mt-1">
<span class="badge badge-xs {subEtapa.status === 'completed' ? 'badge-success' : subEtapa.status === 'in_progress' ? 'badge-info' : subEtapa.status === 'blocked' ? 'badge-error' : 'badge-ghost'}">
{subEtapa.status === 'completed' ? 'Concluída' : subEtapa.status === 'in_progress' ? 'Em Andamento' : subEtapa.status === 'blocked' ? 'Bloqueada' : 'Pendente'}
</span>
</div>
</div>
<div class="flex gap-1">
<select
class="select select-xs select-bordered"
value={subEtapa.status}
onchange={(e) => handleAtualizarStatusSubEtapa(subEtapa._id, e.currentTarget.value as 'pending' | 'in_progress' | 'completed' | 'blocked')}
>
<option value="pending">Pendente</option>
<option value="in_progress">Em Andamento</option>
<option value="completed">Concluída</option>
<option value="blocked">Bloqueada</option>
</select>
<button
type="button"
class="btn btn-ghost btn-xs text-error"
onclick={() => handleDeletarSubEtapa(subEtapa._id)}
aria-label="Deletar sub-etapa"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/each}
{:else}
<div class="text-base-content/40 rounded-lg border border-dashed border-base-300 bg-base-200/50 p-4 text-center text-sm">
Nenhuma sub-etapa adicionada
</div>
{/if}
</div>
</div>
<div class="flex gap-2 pt-4">
<button
class="btn btn-error btn-outline flex-1"
onclick={handleDeleteStep}
>
Excluir
</button>
<button
class="btn btn-secondary flex-1"
onclick={handleSaveStep}
disabled={isSavingStep}
>
{#if isSavingStep}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Salvar
</button>
</div>
</div>
{:else}
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="text-base-content/30 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
<p class="text-base-content/60 mt-4">Selecione um passo</p>
<p class="text-base-content/40 text-sm">Clique em um passo para editar seus detalhes</p>
</div>
{/if}
</aside>
</div>
</main>
<!-- Modal de Novo Passo -->
{#if showNewStepModal}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Novo Passo</h3>
{#if stepError}
<div class="alert alert-error mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<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>{stepError}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCreateStep(); }} class="mt-4 space-y-4">
<div class="form-control">
<label class="label" for="new-step-name">
<span class="label-text">Nome do Passo</span>
</label>
<input
type="text"
id="new-step-name"
bind:value={newStepName}
class="input input-bordered w-full"
placeholder="Ex: Análise Jurídica"
required
/>
</div>
<div class="form-control">
<label class="label" for="new-step-description">
<span class="label-text">Descrição (opcional)</span>
</label>
<textarea
id="new-step-description"
bind:value={newStepDescription}
class="textarea textarea-bordered w-full"
placeholder="Descreva o que deve ser feito neste passo..."
rows="2"
></textarea>
</div>
<div class="form-control">
<label class="label" for="new-step-duration">
<span class="label-text">Duração Esperada (dias)</span>
</label>
<input
type="number"
id="new-step-duration"
bind:value={newStepDuration}
class="input input-bordered w-full"
min="1"
required
/>
</div>
<div class="form-control">
<label class="label" for="new-step-setor">
<span class="label-text">Setor Responsável</span>
</label>
<select
id="new-step-setor"
bind:value={newStepSetorId}
class="select select-bordered w-full"
required
>
<option value="">Selecione um setor</option>
{#if setoresQuery.data}
{#each setoresQuery.data as setor (setor._id)}
<option value={setor._id}>{setor.nome} ({setor.sigla})</option>
{/each}
{/if}
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeNewStepModal} disabled={isCreatingStep}>
Cancelar
</button>
<button type="submit" class="btn btn-secondary" disabled={isCreatingStep}>
{#if isCreatingStep}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Criar Passo
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" onclick={closeNewStepModal} aria-label="Fechar modal"></button>
</div>
{/if}
<!-- Modal de Nova Sub-etapa -->
{#if showSubEtapaModal}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-lg font-bold">Nova Sub-etapa</h3>
{#if subEtapaError}
<div class="alert alert-error mt-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<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>{subEtapaError}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCriarSubEtapa(); }} class="mt-4 space-y-4">
<div class="form-control">
<label class="label" for="sub-etapa-nome">
<span class="label-text">Nome da Sub-etapa</span>
</label>
<input
type="text"
id="sub-etapa-nome"
bind:value={subEtapaNome}
class="input input-bordered w-full"
placeholder="Ex: Revisar documentação"
required
/>
</div>
<div class="form-control">
<label class="label" for="sub-etapa-descricao">
<span class="label-text">Descrição (opcional)</span>
</label>
<textarea
id="sub-etapa-descricao"
bind:value={subEtapaDescricao}
class="textarea textarea-bordered w-full"
placeholder="Descreva a sub-etapa..."
rows="2"
></textarea>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeSubEtapaModal} disabled={isCriandoSubEtapa}>
Cancelar
</button>
<button type="submit" class="btn btn-secondary" disabled={isCriandoSubEtapa}>
{#if isCriandoSubEtapa}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Criar Sub-etapa
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" onclick={closeSubEtapaModal} aria-label="Fechar modal"></button>
</div>
{/if}

View File

@@ -0,0 +1,373 @@
<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 ActionGuard from '$lib/components/ActionGuard.svelte';
import { goto } from '$app/navigation';
const client = useConvexClient();
// Estado dos filtros
let statusFilter = $state<'active' | 'completed' | 'cancelled' | undefined>(undefined);
// Query de instâncias
const instancesQuery = useQuery(
api.flows.listInstances,
() => (statusFilter ? { status: statusFilter } : {})
);
// Query de templates publicados (para o modal de criação)
const publishedTemplatesQuery = useQuery(api.flows.listTemplates, { status: 'published' });
// Modal de criação
let showCreateModal = $state(false);
let selectedTemplateId = $state<Id<'flowTemplates'> | ''>('');
let targetType = $state('');
let targetId = $state('');
let managerId = $state<Id<'usuarios'> | ''>('');
let isCreating = $state(false);
let createError = $state<string | null>(null);
// Query de usuários (para seleção de gerente)
const usuariosQuery = useQuery(api.usuarios.listar, {});
function openCreateModal() {
selectedTemplateId = '';
targetType = '';
targetId = '';
managerId = '';
createError = null;
showCreateModal = true;
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate() {
if (!selectedTemplateId || !targetType.trim() || !targetId.trim() || !managerId) {
createError = 'Todos os campos são obrigatórios';
return;
}
isCreating = true;
createError = null;
try {
const instanceId = await client.mutation(api.flows.instantiateFlow, {
flowTemplateId: selectedTemplateId as Id<'flowTemplates'>,
targetType: targetType.trim(),
targetId: targetId.trim(),
managerId: managerId as Id<'usuarios'>
});
closeCreateModal();
goto(`/licitacoes/fluxos/${instanceId}`);
} catch (e) {
createError = e instanceof Error ? e.message : 'Erro ao criar instância';
} finally {
isCreating = false;
}
}
function getStatusBadge(status: 'active' | 'completed' | 'cancelled') {
switch (status) {
case 'active':
return { class: 'badge-info', label: 'Em Andamento' };
case 'completed':
return { class: 'badge-success', label: 'Concluído' };
case 'cancelled':
return { class: 'badge-error', label: 'Cancelado' };
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getProgressPercentage(completed: number, total: number): number {
if (total === 0) return 0;
return Math.round((completed / total) * 100);
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
<!-- Header -->
<section
class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
>
<div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl space-y-4">
<div class="flex items-center gap-4">
<a href="/fluxos" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Templates
</a>
<span
class="border-info/40 bg-info/10 text-info inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
>
Execução
</span>
</div>
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Instâncias de Fluxo
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Acompanhe e gerencie as execuções de fluxos de trabalho. Visualize o progresso,
documentos e responsáveis de cada etapa.
</p>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<!-- Filtro de status -->
<select
class="select select-bordered"
bind:value={statusFilter}
>
<option value={undefined}>Todos os status</option>
<option value="active">Em Andamento</option>
<option value="completed">Concluído</option>
<option value="cancelled">Cancelado</option>
</select>
<ActionGuard recurso="fluxos_instancias" acao="criar">
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Nova Instância
</button>
</ActionGuard>
</div>
</div>
</section>
<!-- Lista de Instâncias -->
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
{#if instancesQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-info"></span>
</div>
{:else if !instancesQuery.data || instancesQuery.data.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhuma instância encontrada</h3>
<p class="text-base-content/50 mt-2">
{statusFilter ? 'Não há instâncias com este status.' : 'Clique em "Nova Instância" para iniciar um fluxo.'}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Template</th>
<th>Alvo</th>
<th>Gerente</th>
<th>Progresso</th>
<th>Status</th>
<th>Iniciado em</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#each instancesQuery.data as instance (instance._id)}
{@const statusBadge = getStatusBadge(instance.status)}
{@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
<tr class="hover">
<td>
<div class="font-medium">{instance.templateName ?? 'Template desconhecido'}</div>
</td>
<td>
<div class="text-sm">
<span class="badge badge-outline badge-sm">{instance.targetType}</span>
<span class="text-base-content/60 ml-1">{instance.targetId}</span>
</div>
</td>
<td class="text-base-content/70">{instance.managerName ?? '-'}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-info w-20"
value={progressPercent}
max="100"
></progress>
<span class="text-xs text-base-content/60">
{instance.progress.completed}/{instance.progress.total}
</span>
</div>
</td>
<td>
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
</td>
<td class="text-base-content/60 text-sm">{formatDate(instance.startedAt)}</td>
<td class="text-right">
<a
href="/licitacoes/fluxos/{instance._id}"
class="btn btn-ghost btn-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Ver
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</main>
<!-- Modal de Criação -->
{#if showCreateModal}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">Nova Instância de Fluxo</h3>
{#if createError}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>{createError}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
<div class="form-control">
<label class="label" for="template-select">
<span class="label-text">Template de Fluxo</span>
</label>
<select
id="template-select"
bind:value={selectedTemplateId}
class="select select-bordered w-full"
required
>
<option value="">Selecione um template</option>
{#if publishedTemplatesQuery.data}
{#each publishedTemplatesQuery.data as template (template._id)}
<option value={template._id}>{template.name}</option>
{/each}
{/if}
</select>
<p class="label">
<span class="label-text-alt text-base-content/60">Apenas templates publicados podem ser instanciados</span>
</p>
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div class="form-control">
<label class="label" for="target-type">
<span class="label-text">Tipo do Alvo</span>
</label>
<input
type="text"
id="target-type"
bind:value={targetType}
class="input input-bordered w-full"
placeholder="Ex: contrato, projeto"
required
/>
</div>
<div class="form-control">
<label class="label" for="target-id">
<span class="label-text">Identificador do Alvo</span>
</label>
<input
type="text"
id="target-id"
bind:value={targetId}
class="input input-bordered w-full"
placeholder="Ex: CT-2024-001"
required
/>
</div>
</div>
<div class="form-control">
<label class="label" for="manager-select">
<span class="label-text">Gerente Responsável</span>
</label>
<select
id="manager-select"
bind:value={managerId}
class="select select-bordered w-full"
required
>
<option value="">Selecione um gerente</option>
{#if usuariosQuery.data}
{#each usuariosQuery.data as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
{/if}
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
Cancelar
</button>
<button type="submit" class="btn btn-info" disabled={isCreating}>
{#if isCreating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Iniciar Fluxo
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" onclick={closeCreateModal} aria-label="Fechar modal"></button>
</div>
{/if}

View File

@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { FileText, ClipboardCopy, Building2 } from 'lucide-svelte'; import { FileText, ClipboardCopy, Building2, Workflow, ChevronRight } from 'lucide-svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte'; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
import type { Component } from 'svelte';
</script> </script>
<ProtectedRoute> <ProtectedRoute>
@@ -16,7 +17,16 @@
</ul> </ul>
</div> </div>
<div class="grid gap-4 md:grid-cols-3"> <!-- Cabeçalho -->
<div class="mb-8">
<h1 class="text-4xl font-bold text-primary mb-2">Licitações</h1>
<p class="text-lg text-base-content/70">
Gerencie empresas, contratos e processos licitatórios
</p>
</div>
<!-- Cards Principais -->
<div class="grid gap-4 md:grid-cols-3 mb-8">
<a <a
href={resolve('/licitacoes/empresas')} href={resolve('/licitacoes/empresas')}
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg" class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
@@ -75,5 +85,86 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Seção Fluxos -->
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-all duration-300">
<div class="card-body">
<!-- Cabeçalho da Categoria -->
<div class="flex items-start gap-6 mb-6">
<div class="p-4 bg-secondary/20 rounded-2xl">
<Workflow class="h-12 w-12 text-secondary" strokeWidth={2} />
</div>
<div class="flex-1">
<h2 class="card-title text-2xl mb-2 text-secondary">
Fluxos de Trabalho
</h2>
<p class="text-base-content/70">Gerencie templates e fluxos de trabalho para contratos e processos</p>
</div>
</div>
<!-- Grid de Opções -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<a
href={resolve('/licitacoes/fluxos')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-secondary/10 to-secondary/20 p-6 hover:border-secondary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-secondary group-hover:text-white transition-colors duration-300"
>
<Workflow
class="h-5 w-5 text-secondary group-hover:text-white transition-colors duration-300"
strokeWidth={2}
/>
</div>
<ChevronRight
class="h-5 w-5 text-base-content/30 group-hover:text-secondary transition-colors duration-300"
strokeWidth={2}
/>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-secondary transition-colors duration-300"
>
Meus Fluxos
</h3>
<p class="text-sm text-base-content/70 flex-1">
Visualize e gerencie os fluxos de trabalho em execução
</p>
</div>
</a>
<a
href={resolve('/fluxos')}
class="group relative overflow-hidden rounded-xl border-2 border-base-300 bg-linear-to-br from-secondary/10 to-secondary/20 p-6 hover:border-secondary hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
>
<div class="flex flex-col h-full">
<div class="flex items-start justify-between mb-4">
<div
class="p-3 bg-base-100 rounded-lg group-hover:bg-secondary group-hover:text-white transition-colors duration-300"
>
<FileText
class="h-5 w-5 text-secondary group-hover:text-white transition-colors duration-300"
strokeWidth={2}
/>
</div>
<ChevronRight
class="h-5 w-5 text-base-content/30 group-hover:text-secondary transition-colors duration-300"
strokeWidth={2}
/>
</div>
<h3
class="text-lg font-bold text-base-content mb-2 group-hover:text-secondary transition-colors duration-300"
>
Templates
</h3>
<p class="text-sm text-base-content/70 flex-1">
Crie e edite templates de fluxos de trabalho
</p>
</div>
</a>
</div>
</div>
</div>
</main> </main>
</ProtectedRoute> </ProtectedRoute>

View File

@@ -0,0 +1,365 @@
<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 ActionGuard from '$lib/components/ActionGuard.svelte';
import { goto } from '$app/navigation';
const client = useConvexClient();
// Estado dos filtros
let statusFilter = $state<'active' | 'completed' | 'cancelled' | undefined>(undefined);
// Query de instâncias
const instancesQuery = useQuery(
api.flows.listInstances,
() => (statusFilter ? { status: statusFilter } : {})
);
// Query de templates publicados (para o modal de criação)
const publishedTemplatesQuery = useQuery(api.flows.listTemplates, { status: 'published' });
// Modal de criação
let showCreateModal = $state(false);
let selectedTemplateId = $state<Id<'flowTemplates'> | ''>('');
let contratoId = $state<Id<'contratos'> | ''>('');
let managerId = $state<Id<'usuarios'> | ''>('');
let isCreating = $state(false);
let createError = $state<string | null>(null);
// Query de usuários (para seleção de gerente)
const usuariosQuery = useQuery(api.usuarios.listar, {});
// Query de contratos (para seleção)
const contratosQuery = useQuery(api.contratos.listar, {});
function openCreateModal() {
selectedTemplateId = '';
contratoId = '';
managerId = '';
createError = null;
showCreateModal = true;
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate() {
if (!selectedTemplateId || !managerId) {
createError = 'Template e gerente são obrigatórios';
return;
}
isCreating = true;
createError = null;
try {
const instanceId = await client.mutation(api.flows.instantiateFlow, {
flowTemplateId: selectedTemplateId as Id<'flowTemplates'>,
contratoId: contratoId ? (contratoId as Id<'contratos'>) : undefined,
managerId: managerId as Id<'usuarios'>
});
closeCreateModal();
goto(`/licitacoes/fluxos/${instanceId}`);
} catch (e) {
createError = e instanceof Error ? e.message : 'Erro ao criar fluxo';
} finally {
isCreating = false;
}
}
function getStatusBadge(status: 'active' | 'completed' | 'cancelled') {
switch (status) {
case 'active':
return { class: 'badge-info', label: 'Em Andamento' };
case 'completed':
return { class: 'badge-success', label: 'Concluído' };
case 'cancelled':
return { class: 'badge-error', label: 'Cancelado' };
}
}
function formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function getProgressPercentage(completed: number, total: number): number {
if (total === 0) return 0;
return Math.round((completed / total) * 100);
}
</script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
<!-- Header -->
<section
class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
>
<div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl space-y-4">
<div class="flex items-center gap-4">
<a href="/fluxos" class="btn btn-ghost btn-sm">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Templates
</a>
<span
class="border-info/40 bg-info/10 text-info inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
>
Execução
</span>
</div>
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Fluxos de Trabalho
</h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Acompanhe e gerencie os fluxos de trabalho. Visualize o progresso,
documentos e responsáveis de cada etapa.
</p>
</div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<!-- Filtro de status -->
<select
class="select select-bordered"
bind:value={statusFilter}
>
<option value={undefined}>Todos os status</option>
<option value="active">Em Andamento</option>
<option value="completed">Concluído</option>
<option value="cancelled">Cancelado</option>
</select>
<ActionGuard recurso="fluxos_instancias" acao="criar">
<button class="btn btn-info shadow-lg" onclick={openCreateModal}>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
Novo Fluxo
</button>
</ActionGuard>
</div>
</div>
</section>
<!-- Lista de Instâncias -->
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
{#if instancesQuery.isLoading}
<div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-info"></span>
</div>
{:else if !instancesQuery.data || instancesQuery.data.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center">
<svg
xmlns="http://www.w3.org/2000/svg"
class="text-base-content/30 h-16 w-16"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum fluxo encontrado</h3>
<p class="text-base-content/50 mt-2">
{statusFilter ? 'Não há fluxos com este status.' : 'Clique em "Novo Fluxo" para iniciar um fluxo.'}
</p>
</div>
{:else}
<div class="overflow-x-auto">
<table class="table w-full">
<thead>
<tr>
<th>Template</th>
<th>Contrato</th>
<th>Gerente</th>
<th>Progresso</th>
<th>Status</th>
<th>Iniciado em</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#each instancesQuery.data as instance (instance._id)}
{@const statusBadge = getStatusBadge(instance.status)}
{@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
<tr class="hover">
<td>
<div class="font-medium">{instance.templateName ?? 'Template desconhecido'}</div>
</td>
<td>
{#if instance.contratoId}
<span class="badge badge-outline badge-sm">{instance.contratoId}</span>
{:else}
<span class="text-base-content/40 text-sm">-</span>
{/if}
</td>
<td class="text-base-content/70">{instance.managerName ?? '-'}</td>
<td>
<div class="flex items-center gap-2">
<progress
class="progress progress-info w-20"
value={progressPercent}
max="100"
></progress>
<span class="text-xs text-base-content/60">
{instance.progress.completed}/{instance.progress.total}
</span>
</div>
</td>
<td>
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
</td>
<td class="text-base-content/60 text-sm">{formatDate(instance.startedAt)}</td>
<td class="text-right">
<a
href="/licitacoes/fluxos/{instance._id}"
class="btn btn-ghost btn-sm"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Ver
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
</main>
<!-- Modal de Criação -->
{#if showCreateModal}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">Novo Fluxo de Trabalho</h3>
{#if createError}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>{createError}</span>
</div>
{/if}
<form onsubmit={(e) => { e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
<div class="form-control">
<label class="label" for="template-select">
<span class="label-text">Template de Fluxo</span>
</label>
<select
id="template-select"
bind:value={selectedTemplateId}
class="select select-bordered w-full"
required
>
<option value="">Selecione um template</option>
{#if publishedTemplatesQuery.data}
{#each publishedTemplatesQuery.data as template (template._id)}
<option value={template._id}>{template.name}</option>
{/each}
{/if}
</select>
<p class="label">
<span class="label-text-alt text-base-content/60">Apenas templates publicados podem ser instanciados</span>
</p>
</div>
<div class="form-control">
<label class="label" for="contrato-select">
<span class="label-text">Contrato (Opcional)</span>
</label>
<select
id="contrato-select"
bind:value={contratoId}
class="select select-bordered w-full"
>
<option value="">Nenhum contrato</option>
{#if contratosQuery.data}
{#each contratosQuery.data as contrato (contrato._id)}
<option value={contrato._id}>{contrato.numero ?? contrato._id}</option>
{/each}
{/if}
</select>
<p class="label">
<span class="label-text-alt text-base-content/60">Opcional: vincule este fluxo a um contrato específico</span>
</p>
</div>
<div class="form-control">
<label class="label" for="manager-select">
<span class="label-text">Gerente Responsável</span>
</label>
<select
id="manager-select"
bind:value={managerId}
class="select select-bordered w-full"
required
>
<option value="">Selecione um gerente</option>
{#if usuariosQuery.data}
{#each usuariosQuery.data as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
{/if}
</select>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
Cancelar
</button>
<button type="submit" class="btn btn-info" disabled={isCreating}>
{#if isCreating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Iniciar Fluxo
</button>
</div>
</form>
</div>
<button type="button" class="modal-backdrop" onclick={closeCreateModal} aria-label="Fechar modal"></button>
</div>
{/if}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Trophy, Award, Building2 } from "lucide-svelte"; import { Trophy, Award, Building2, Workflow } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
@@ -56,6 +56,23 @@
</p> </p>
</div> </div>
</div> </div>
<a
href={resolve('/fluxos')}
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-secondary"
>
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-secondary/10 rounded-lg">
<Workflow class="h-6 w-6 text-secondary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Fluxos de Trabalho</h4>
</div>
<p class="text-sm text-base-content/70">
Gerencie templates e instâncias de fluxos de trabalho para programas e projetos esportivos.
</p>
</div>
</a>
</div> </div>
</main> </main>
</ProtectedRoute> </ProtectedRoute>

View File

@@ -1,23 +1,42 @@
<script lang="ts"> <script lang="ts">
import { useConvexClient } from 'convex-svelte'; 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 { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import type { SimboloTipo } from '@sgse-app/backend/convex/schema'; import type { SimboloTipo } from '@sgse-app/backend/convex/schema';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import PrintModal from '$lib/components/PrintModal.svelte'; import PrintModal from '$lib/components/PrintModal.svelte';
const client = useConvexClient(); const client = useConvexClient();
let list: Array<any> = []; // Estado reativo
let filtered: Array<any> = []; let list = $state<Array<{
let selectedId: string | null = null; _id: Id<'funcionarios'>;
let openMenuId: string | null = null; nome: string;
let funcionarioParaImprimir: any = null; matricula?: string;
cpf: string;
cidade?: string;
uf?: string;
simboloTipo?: SimboloTipo;
}>>([]);
let filtered = $state<typeof list>([]);
let openMenuId = $state<string | null>(null);
let funcionarioParaImprimir = $state<unknown>(null);
let filtroNome = ''; // Estado do modal de setores
let filtroCPF = ''; let showSetoresModal = $state(false);
let filtroMatricula = ''; let funcionarioParaSetores = $state<{ _id: Id<'funcionarios'>; nome: string } | null>(null);
let filtroTipo: SimboloTipo | '' = ''; let setoresSelecionados = $state<Id<'setores'>[]>([]);
let isSavingSetores = $state(false);
let setoresError = $state<string | null>(null);
// Queries
const todosSetoresQuery = useQuery(api.setores.list, {});
let filtroNome = $state('');
let filtroCPF = $state('');
let filtroMatricula = $state('');
let filtroTipo = $state<SimboloTipo | ''>('');
function applyFilters() { function applyFilters() {
const nome = filtroNome.toLowerCase(); const nome = filtroNome.toLowerCase();
@@ -33,18 +52,15 @@
} }
async function load() { async function load() {
list = await client.query(api.funcionarios.getAll, {} as any); const data = await client.query(api.funcionarios.getAll, {});
list = data ?? [];
applyFilters(); applyFilters();
} }
function editSelected() {
if (selectedId) goto(resolve(`/recursos-humanos/funcionarios/${selectedId}/editar`));
}
async function openPrintModal(funcionarioId: string) { async function openPrintModal(funcionarioId: string) {
try { try {
const data = await client.query(api.funcionarios.getFichaCompleta, { const data = await client.query(api.funcionarios.getFichaCompleta, {
id: funcionarioId as any id: funcionarioId as Id<'funcionarios'>
}); });
funcionarioParaImprimir = data; funcionarioParaImprimir = data;
} catch (err) { } catch (err) {
@@ -62,12 +78,64 @@
function toggleMenu(id: string) { function toggleMenu(id: string) {
openMenuId = openMenuId === id ? null : id; openMenuId = openMenuId === id ? null : id;
} }
$: needsScroll = filtered.length > 8;
async function openSetoresModal(funcionarioId: Id<'funcionarios'>, nome: string) {
funcionarioParaSetores = { _id: funcionarioId, nome };
setoresSelecionados = [];
setoresError = null;
showSetoresModal = true;
openMenuId = null;
// Carregar setores do funcionário
try {
const setores = await client.query(api.setores.getSetoresByFuncionario, {
funcionarioId
});
setoresSelecionados = setores.map((s) => s._id);
} catch (err) {
console.error('Erro ao carregar setores do funcionário:', err);
setoresError = 'Erro ao carregar setores do funcionário';
}
}
function closeSetoresModal() {
showSetoresModal = false;
funcionarioParaSetores = null;
setoresSelecionados = [];
setoresError = null;
}
function toggleSetor(setorId: Id<'setores'>) {
if (setoresSelecionados.includes(setorId)) {
setoresSelecionados = setoresSelecionados.filter((id) => id !== setorId);
} else {
setoresSelecionados = [...setoresSelecionados, setorId];
}
}
async function salvarSetores() {
if (!funcionarioParaSetores) return;
isSavingSetores = true;
setoresError = null;
try {
await client.mutation(api.setores.atualizarSetoresFuncionario, {
funcionarioId: funcionarioParaSetores._id,
setorIds: setoresSelecionados
});
closeSetoresModal();
} catch (err) {
setoresError = err instanceof Error ? err.message : 'Erro ao salvar setores';
} finally {
isSavingSetores = false;
}
}
</script> </script>
<main class="container mx-auto px-4 py-4 max-w-7xl flex flex-col" style="height: calc(100vh - 8rem); min-height: 600px;"> <main class="container mx-auto px-4 py-4 max-w-7xl flex flex-col" style="height: calc(100vh - 8rem); min-height: 600px;">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm flex-shrink-0"> <div class="breadcrumbs mb-4 text-sm shrink-0">
<ul> <ul>
<li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li> <li><a href={resolve('/recursos-humanos')} class="text-primary hover:underline">Recursos Humanos</a></li>
<li>Funcionários</li> <li>Funcionários</li>
@@ -75,7 +143,7 @@
</div> </div>
<!-- Cabeçalho --> <!-- Cabeçalho -->
<div class="mb-6 flex-shrink-0"> <div class="mb-6 shrink-0">
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center"> <div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="rounded-xl bg-blue-500/20 p-3"> <div class="rounded-xl bg-blue-500/20 p-3">
@@ -118,7 +186,7 @@
</div> </div>
<!-- Filtros --> <!-- Filtros -->
<div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl flex-shrink-0"> <div class="card bg-base-100/90 backdrop-blur-sm border border-base-300 mb-4 shadow-xl shrink-0">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-4 text-lg"> <h2 class="card-title mb-4 text-lg">
<svg <svg
@@ -232,7 +300,7 @@
<div class="flex-1 overflow-hidden flex flex-col"> <div class="flex-1 overflow-hidden flex flex-col">
<div class="overflow-x-auto flex-1 overflow-y-auto"> <div class="overflow-x-auto flex-1 overflow-y-auto">
<table class="table table-zebra w-full"> <table class="table table-zebra w-full">
<thead class="sticky top-0 z-10 shadow-md bg-gradient-to-r from-base-300 to-base-200"> <thead class="sticky top-0 z-10 shadow-md bg-linear-to-r from-base-300 to-base-200">
<tr> <tr>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">Nome</th>
<th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th> <th class="whitespace-nowrap font-bold text-base-content border-b border-base-400">CPF</th>
@@ -277,7 +345,7 @@
</td> </td>
</tr> </tr>
{:else} {:else}
{#each filtered as f} {#each filtered as f (f._id)}
<tr class="hover:bg-base-200/50 transition-colors"> <tr class="hover:bg-base-200/50 transition-colors">
<td class="whitespace-nowrap font-medium">{f.nome}</td> <td class="whitespace-nowrap font-medium">{f.nome}</td>
<td class="whitespace-nowrap">{f.cpf}</td> <td class="whitespace-nowrap">{f.cpf}</td>
@@ -314,20 +382,28 @@
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl" class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl"
> >
<li> <li>
<a href={`/recursos-humanos/funcionarios/${f._id}`} class="hover:bg-primary/10"> <a href={resolve(`/recursos-humanos/funcionarios/${f._id}`)} class="hover:bg-primary/10">
Ver Detalhes Ver Detalhes
</a> </a>
</li> </li>
<li> <li>
<a href={`/recursos-humanos/funcionarios/${f._id}/editar`} class="hover:bg-primary/10"> <a href={resolve(`/recursos-humanos/funcionarios/${f._id}/editar`)} class="hover:bg-primary/10">
Editar Editar
</a> </a>
</li> </li>
<li> <li>
<a href={`/recursos-humanos/funcionarios/${f._id}/documentos`} class="hover:bg-primary/10"> <a href={resolve(`/recursos-humanos/funcionarios/${f._id}/documentos`)} class="hover:bg-primary/10">
Ver Documentos Ver Documentos
</a> </a>
</li> </li>
<li>
<button
onclick={() => openSetoresModal(f._id, f.nome)}
class="hover:bg-primary/10"
>
Atribuir Setores
</button>
</li>
<li> <li>
<button onclick={() => openPrintModal(f._id)} class="hover:bg-primary/10"> <button onclick={() => openPrintModal(f._id)} class="hover:bg-primary/10">
Imprimir Ficha Imprimir Ficha
@@ -347,7 +423,7 @@
</div> </div>
<!-- Informação sobre resultados --> <!-- Informação sobre resultados -->
<div class="text-base-content/70 mt-3 text-center text-sm flex-shrink-0 border-t border-base-300 pt-3 bg-base-100/50"> <div class="text-base-content/70 mt-3 text-center text-sm shrink-0 border-t border-base-300 pt-3 bg-base-100/50">
Exibindo <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s) Exibindo <span class="font-semibold">{filtered.length}</span> de <span class="font-semibold">{list.length}</span> funcionário(s)
</div> </div>
</div> </div>
@@ -359,4 +435,85 @@
onClose={() => (funcionarioParaImprimir = null)} onClose={() => (funcionarioParaImprimir = null)}
/> />
{/if} {/if}
<!-- Modal de Atribuição de Setores -->
{#if showSetoresModal && funcionarioParaSetores}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">Atribuir Setores</h3>
<p class="text-base-content/60 mt-2">
Selecione os setores para <strong>{funcionarioParaSetores.nome}</strong>
</p>
{#if setoresError}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<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>{setoresError}</span>
</div>
{/if}
<div class="mt-4 max-h-96 overflow-y-auto">
{#if todosSetoresQuery.isLoading}
<div class="flex items-center justify-center py-8">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if todosSetoresQuery.data && todosSetoresQuery.data.length > 0}
<div class="space-y-2">
{#each todosSetoresQuery.data as setor (setor._id)}
{@const isSelected = setoresSelecionados.includes(setor._id)}
<label class="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-base-200 {isSelected ? 'border-primary bg-primary/5' : 'border-base-300'}">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={isSelected}
onchange={() => toggleSetor(setor._id)}
aria-label="Selecionar setor {setor.nome}"
/>
<div class="flex-1">
<div class="font-medium">{setor.nome}</div>
<div class="text-base-content/60 text-sm">Sigla: {setor.sigla}</div>
</div>
</label>
{/each}
</div>
{:else}
<div class="text-base-content/60 py-8 text-center">
<p>Nenhum setor cadastrado</p>
</div>
{/if}
</div>
<div class="modal-action">
<button class="btn" onclick={closeSetoresModal} disabled={isSavingSetores}>
Cancelar
</button>
<button class="btn btn-primary" onclick={salvarSetores} disabled={isSavingSetores}>
{#if isSavingSetores}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Salvar
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeSetoresModal}
aria-label="Fechar modal"
></button>
</div>
{/if}
</main> </main>

View File

@@ -1,31 +1,40 @@
<script lang="ts"> <script lang="ts">
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 { onMount } from "svelte"; import { onMount } from 'svelte';
import { resolve } from "$app/paths"; import { resolve } from '$app/paths';
const client = useConvexClient(); const client = useConvexClient();
type Row = { _id: string; nome: string; valor: number; count: number }; type Row = { _id: string; nome: string; valor: number; count: number };
let rows: Array<Row> = $state<Array<Row>>([]); let rows: Array<Row> = $state<Array<Row>>([]);
let isLoading = $state(true); let isLoading = $state(true);
let notice = $state<{ kind: "error" | "success"; text: string } | null>(null); let notice = $state<{ kind: 'error' | 'success'; text: string } | null>(null);
let containerWidth = $state(1200); let containerWidth = $state(1200);
onMount(async () => { onMount(async () => {
try { try {
const simbolos = await client.query(api.simbolos.getAll, {} as any); const simbolos = await client.query(api.simbolos.getAll, {});
const funcionarios = await client.query(api.funcionarios.getAll, {} as any); const funcionarios = await client.query(api.funcionarios.getAll, {});
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const f of funcionarios) counts[f.simboloId] = (counts[f.simboloId] ?? 0) + 1; for (const f of funcionarios) {
rows = simbolos.map((s: any) => ({ const sId = String(f.simboloId);
counts[sId] = (counts[sId] ?? 0) + 1;
}
rows = simbolos.map((s) => ({
_id: String(s._id), _id: String(s._id),
nome: s.nome as string, nome: s.nome,
valor: Number(s.valor || 0), valor: Number(s.valor || 0),
count: counts[String(s._id)] ?? 0, count: counts[String(s._id)] ?? 0
})); }));
} catch (e) { } catch (e) {
notice = { kind: "error", text: "Falha ao carregar dados de relatórios." }; if (e instanceof Error) {
notice = { kind: 'error', text: e.message };
} else {
notice = { kind: 'error', text: 'Falha ao carregar dados de relatórios.' };
}
} finally { } finally {
isLoading = false; isLoading = false;
} }
@@ -69,7 +78,7 @@
} }
function createAreaPath(data: Array<Row>, getValue: (r: Row) => number, max: number): string { function createAreaPath(data: Array<Row>, getValue: (r: Row) => number, max: number): string {
if (data.length === 0) return ""; if (data.length === 0) return '';
const n = data.length; const n = data.length;
let path = `M ${getX(0, n)} ${chartHeight - padding.bottom}`; let path = `M ${getX(0, n)} ${chartHeight - padding.bottom}`;
@@ -80,82 +89,166 @@
} }
path += ` L ${getX(n - 1, n)} ${chartHeight - padding.bottom}`; path += ` L ${getX(n - 1, n)} ${chartHeight - padding.bottom}`;
path += " Z"; path += ' Z';
return path; return path;
} }
</script> </script>
<div class="container mx-auto px-4 py-6 max-w-7xl"> <div class="container mx-auto max-w-7xl px-4 py-6">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="breadcrumbs mb-4 text-sm">
<ul> <ul>
<li><a href={resolve('/')} class="hover:text-primary">Dashboard</a></li> <li><a href={resolve('/')} class="hover:text-primary">Dashboard</a></li>
<li><a href={resolve('/recursos-humanos')} class="hover:text-primary">Recursos Humanos</a></li> <li>
<li><a href={resolve('/recursos-humanos/funcionarios')} class="hover:text-primary">Funcionários</a></li> <a href={resolve('/recursos-humanos')} class="hover:text-primary">Recursos Humanos</a>
<li class="font-semibold text-primary">Relatórios</li> </li>
<li>
<a href={resolve('/recursos-humanos/funcionarios')} class="hover:text-primary"
>Funcionários</a
>
</li>
<li class="text-primary font-semibold">Relatórios</li>
</ul> </ul>
</div> </div>
<!-- Header --> <!-- Header -->
<div class="flex items-center gap-4 mb-8"> <div class="mb-8 flex items-center gap-4">
<div class="p-3 bg-primary/10 rounded-xl"> <div class="bg-primary/10 rounded-xl p-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" /> xmlns="http://www.w3.org/2000/svg"
class="text-primary h-8 w-8"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg> </svg>
</div> </div>
<div> <div>
<h1 class="text-3xl font-bold text-base-content">Relatórios de Funcionários</h1> <h1 class="text-base-content text-3xl font-bold">Relatórios de Funcionários</h1>
<p class="text-base-content/60 mt-1">Análise de distribuição de salários e funcionários por símbolo</p> <p class="text-base-content/60 mt-1">
Análise de distribuição de salários e funcionários por símbolo
</p>
</div> </div>
</div> </div>
{#if notice} {#if notice}
<div class="alert mb-6" class:alert-error={notice.kind === "error"} class:alert-success={notice.kind === "success"}> <div
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6"> class="alert mb-6"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path> class:alert-error={notice.kind === 'error'}
class:alert-success={notice.kind === 'success'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="h-6 w-6 shrink-0 stroke-current"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg> </svg>
<span>{notice.text}</span> <span>{notice.text}</span>
</div> </div>
{/if} {/if}
{#if isLoading} {#if isLoading}
<div class="flex justify-center items-center py-20"> <div class="flex items-center justify-center py-20">
<span class="loading loading-spinner loading-lg text-primary"></span> <span class="loading loading-spinner loading-lg text-primary"></span>
</div> </div>
{:else} {:else}
<div class="space-y-6 chart-container"> <div class="chart-container space-y-6">
<!-- Gráfico 1: Símbolo x Salário (Valor) - Layer Chart --> <!-- Gráfico 1: Símbolo x Salário (Valor) - Layer Chart -->
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow border border-base-300"> <div
class="card bg-base-100 border-base-300 border shadow-lg transition-shadow hover:shadow-xl"
>
<div class="card-body p-6"> <div class="card-body p-6">
<div class="flex items-center gap-3 mb-6"> <div class="mb-6 flex items-center gap-3">
<div class="p-2.5 bg-primary/10 rounded-lg"> <div class="bg-primary/10 rounded-lg p-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-bold text-base-content">Distribuição de Salários por Símbolo</h3> <h3 class="text-base-content text-lg font-bold">
<p class="text-sm text-base-content/60 mt-0.5">Valores dos símbolos cadastrados no sistema</p> Distribuição de Salários por Símbolo
</h3>
<p class="text-base-content/60 mt-0.5 text-sm">
Valores dos símbolos cadastrados no sistema
</p>
</div> </div>
</div> </div>
<div class="w-full overflow-x-auto bg-base-200/30 rounded-xl p-4"> <div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de área: salário por símbolo"> <svg
width={chartWidth}
height={chartHeight}
role="img"
aria-label="Gráfico de área: salário por símbolo"
>
{#if rows.length === 0} {#if rows.length === 0}
<text x="16" y="32" class="opacity-60">Sem dados</text> <text x="16" y="32" class="opacity-60">Sem dados</text>
{:else} {:else}
{@const max = getMax(rows, (r) => r.valor)} {@const max = getMax(rows, (r) => r.valor)}
<!-- Grid lines --> <!-- Grid lines -->
{#each [0,1,2,3,4,5] as t} {#each [0, 1, 2, 3, 4, 5] as t (t)}
{@const val = Math.round((max/5) * t)} {@const val = Math.round((max / 5) * t)}
{@const y = chartHeight - padding.bottom - scaleY(val, max)} {@const y = chartHeight - padding.bottom - scaleY(val, max)}
<line x1={padding.left} y1={y} x2={chartWidth - padding.right} y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4" /> <line
<text x={padding.left - 8} y={y + 4} text-anchor="end" class="text-[10px] opacity-70">{`R$ ${val.toLocaleString('pt-BR')}`}</text> x1={padding.left}
y1={y}
x2={chartWidth - padding.right}
y2={y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4,4"
/>
<text
x={padding.left - 8}
y={y + 4}
text-anchor="end"
class="text-[10px] opacity-70">{`R$ ${val.toLocaleString('pt-BR')}`}</text
>
{/each} {/each}
<!-- Eixos --> <!-- Eixos -->
<line x1={padding.left} y1={chartHeight - padding.bottom} x2={chartWidth - padding.right} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" /> <line
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" /> x1={padding.left}
y1={chartHeight - padding.bottom}
x2={chartWidth - padding.right}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<!-- Area fill (camada) --> <!-- Area fill (camada) -->
<path <path
@@ -166,32 +259,54 @@
<!-- Line --> <!-- Line -->
<polyline <polyline
points={rows.map((r, i) => { points={rows
.map((r, i) => {
const x = getX(i, rows.length); const x = getX(i, rows.length);
const y = chartHeight - padding.bottom - scaleY(r.valor, max); const y = chartHeight - padding.bottom - scaleY(r.valor, max);
return `${x},${y}`; return `${x},${y}`;
}).join(' ')} })
.join(' ')}
fill="none" fill="none"
stroke="rgb(59, 130, 246)" stroke="rgb(59, 130, 246)"
stroke-width="3" stroke-width="3"
/> />
<!-- Data points --> <!-- Data points -->
{#each rows as r, i} {#each rows as r, i (r._id)}
{@const x = getX(i, rows.length)} {@const x = getX(i, rows.length)}
{@const y = chartHeight - padding.bottom - scaleY(r.valor, max)} {@const y = chartHeight - padding.bottom - scaleY(r.valor, max)}
<circle cx={x} cy={y} r="5" fill="rgb(59, 130, 246)" stroke="white" stroke-width="2" /> <circle
<text x={x} y={y - 12} text-anchor="middle" class="text-[10px] font-semibold fill-primary"> cx={x}
cy={y}
r="5"
fill="rgb(59, 130, 246)"
stroke="white"
stroke-width="2"
/>
<text
{x}
y={y - 12}
text-anchor="middle"
class="fill-primary text-[10px] font-semibold"
>
{`R$ ${r.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`} {`R$ ${r.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`}
</text> </text>
{/each} {/each}
<!-- Eixo X labels --> <!-- Eixo X labels -->
{#each rows as r, i} {#each rows as r, i (r._id)}
{@const x = getX(i, rows.length)} {@const x = getX(i, rows.length)}
<foreignObject x={x - 40} y={chartHeight - padding.bottom + 15} width="80" height="70"> <foreignObject
x={x - 40}
y={chartHeight - padding.bottom + 15}
width="80"
height="70"
>
<div class="flex items-center justify-center text-center"> <div class="flex items-center justify-center text-center">
<span class="text-[11px] font-medium text-base-content/80 leading-tight" style="word-wrap: break-word; hyphens: auto;"> <span
class="text-base-content/80 text-[11px] leading-tight font-medium"
style="word-wrap: break-word; hyphens: auto;"
>
{r.nome} {r.nome}
</span> </span>
</div> </div>
@@ -212,37 +327,88 @@
</div> </div>
<!-- Gráfico 2: Quantidade de Funcionários por Símbolo - Layer Chart --> <!-- Gráfico 2: Quantidade de Funcionários por Símbolo - Layer Chart -->
<div class="card bg-base-100 shadow-lg hover:shadow-xl transition-shadow border border-base-300"> <div
class="card bg-base-100 border-base-300 border shadow-lg transition-shadow hover:shadow-xl"
>
<div class="card-body p-6"> <div class="card-body p-6">
<div class="flex items-center gap-3 mb-6"> <div class="mb-6 flex items-center gap-3">
<div class="p-2.5 bg-secondary/10 rounded-lg"> <div class="bg-secondary/10 rounded-lg p-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-secondary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" /> xmlns="http://www.w3.org/2000/svg"
class="text-secondary h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-bold text-base-content">Distribuição de Funcionários por Símbolo</h3> <h3 class="text-base-content text-lg font-bold">
<p class="text-sm text-base-content/60 mt-0.5">Quantidade de funcionários alocados em cada símbolo</p> Distribuição de Funcionários por Símbolo
</h3>
<p class="text-base-content/60 mt-0.5 text-sm">
Quantidade de funcionários alocados em cada símbolo
</p>
</div> </div>
</div> </div>
<div class="w-full overflow-x-auto bg-base-200/30 rounded-xl p-4"> <div class="bg-base-200/30 w-full overflow-x-auto rounded-xl p-4">
<svg width={chartWidth} height={chartHeight} role="img" aria-label="Gráfico de área: quantidade por símbolo"> <svg
width={chartWidth}
height={chartHeight}
role="img"
aria-label="Gráfico de área: quantidade por símbolo"
>
{#if rows.length === 0} {#if rows.length === 0}
<text x="16" y="32" class="opacity-60">Sem dados</text> <text x="16" y="32" class="opacity-60">Sem dados</text>
{:else} {:else}
{@const maxC = getMax(rows, (r) => r.count)} {@const maxC = getMax(rows, (r) => r.count)}
<!-- Grid lines --> <!-- Grid lines -->
{#each [0,1,2,3,4,5] as t} {#each [0, 1, 2, 3, 4, 5] as t (t)}
{@const val = Math.round((maxC/5) * t)} {@const val = Math.round((maxC / 5) * t)}
{@const y = chartHeight - padding.bottom - scaleY(val, Math.max(1, maxC))} {@const y = chartHeight - padding.bottom - scaleY(val, Math.max(1, maxC))}
<line x1={padding.left} y1={y} x2={chartWidth - padding.right} y2={y} stroke="currentColor" stroke-opacity="0.1" stroke-dasharray="4,4" /> <line
<text x={padding.left - 6} y={y + 4} text-anchor="end" class="text-[10px] opacity-70">{val}</text> x1={padding.left}
y1={y}
x2={chartWidth - padding.right}
y2={y}
stroke="currentColor"
stroke-opacity="0.1"
stroke-dasharray="4,4"
/>
<text
x={padding.left - 6}
y={y + 4}
text-anchor="end"
class="text-[10px] opacity-70">{val}</text
>
{/each} {/each}
<!-- Eixos --> <!-- Eixos -->
<line x1={padding.left} y1={chartHeight - padding.bottom} x2={chartWidth - padding.right} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" /> <line
<line x1={padding.left} y1={padding.top} x2={padding.left} y2={chartHeight - padding.bottom} stroke="currentColor" stroke-opacity="0.3" stroke-width="2" /> x1={padding.left}
y1={chartHeight - padding.bottom}
x2={chartWidth - padding.right}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<line
x1={padding.left}
y1={padding.top}
x2={padding.left}
y2={chartHeight - padding.bottom}
stroke="currentColor"
stroke-opacity="0.3"
stroke-width="2"
/>
<!-- Area fill (camada) --> <!-- Area fill (camada) -->
<path <path
@@ -253,32 +419,54 @@
<!-- Line --> <!-- Line -->
<polyline <polyline
points={rows.map((r, i) => { points={rows
.map((r, i) => {
const x = getX(i, rows.length); const x = getX(i, rows.length);
const y = chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC)); const y = chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC));
return `${x},${y}`; return `${x},${y}`;
}).join(' ')} })
.join(' ')}
fill="none" fill="none"
stroke="rgb(236, 72, 153)" stroke="rgb(236, 72, 153)"
stroke-width="3" stroke-width="3"
/> />
<!-- Data points --> <!-- Data points -->
{#each rows as r, i} {#each rows as r, i (r._id)}
{@const x = getX(i, rows.length)} {@const x = getX(i, rows.length)}
{@const y = chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC))} {@const y = chartHeight - padding.bottom - scaleY(r.count, Math.max(1, maxC))}
<circle cx={x} cy={y} r="5" fill="rgb(236, 72, 153)" stroke="white" stroke-width="2" /> <circle
<text x={x} y={y - 12} text-anchor="middle" class="text-[10px] font-semibold fill-secondary"> cx={x}
cy={y}
r="5"
fill="rgb(236, 72, 153)"
stroke="white"
stroke-width="2"
/>
<text
{x}
y={y - 12}
text-anchor="middle"
class="fill-secondary text-[10px] font-semibold"
>
{r.count} {r.count}
</text> </text>
{/each} {/each}
<!-- Eixo X labels --> <!-- Eixo X labels -->
{#each rows as r, i} {#each rows as r, i (r._id)}
{@const x = getX(i, rows.length)} {@const x = getX(i, rows.length)}
<foreignObject x={x - 40} y={chartHeight - padding.bottom + 15} width="80" height="70"> <foreignObject
x={x - 40}
y={chartHeight - padding.bottom + 15}
width="80"
height="70"
>
<div class="flex items-center justify-center text-center"> <div class="flex items-center justify-center text-center">
<span class="text-[11px] font-medium text-base-content/80 leading-tight" style="word-wrap: break-word; hyphens: auto;"> <span
class="text-base-content/80 text-[11px] leading-tight font-medium"
style="word-wrap: break-word; hyphens: auto;"
>
{r.nome} {r.nome}
</span> </span>
</div> </div>
@@ -299,22 +487,37 @@
</div> </div>
<!-- Tabela Resumo --> <!-- Tabela Resumo -->
<div class="card bg-base-100 shadow-lg border border-base-300"> <div class="card bg-base-100 border-base-300 border shadow-lg">
<div class="card-body p-6"> <div class="card-body p-6">
<div class="flex items-center gap-3 mb-6"> <div class="mb-6 flex items-center gap-3">
<div class="p-2.5 bg-accent/10 rounded-lg"> <div class="bg-accent/10 rounded-lg p-2.5">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-accent" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" /> xmlns="http://www.w3.org/2000/svg"
class="text-accent 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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"
/>
</svg> </svg>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-bold text-base-content">Tabela Resumo - Símbolos e Funcionários</h3> <h3 class="text-base-content text-lg font-bold">
<p class="text-sm text-base-content/60 mt-0.5">Visão detalhada dos dados apresentados nos gráficos</p> Tabela Resumo - Símbolos e Funcionários
</h3>
<p class="text-base-content/60 mt-0.5 text-sm">
Visão detalhada dos dados apresentados nos gráficos
</p>
</div> </div>
</div> </div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-zebra"> <table class="table-zebra table">
<thead> <thead>
<tr> <tr>
<th class="bg-base-200">Símbolo</th> <th class="bg-base-200">Símbolo</th>
@@ -326,34 +529,54 @@
<tbody> <tbody>
{#if rows.length === 0} {#if rows.length === 0}
<tr> <tr>
<td colspan="4" class="text-center text-base-content/60 py-8">Nenhum dado disponível</td> <td colspan="4" class="text-base-content/60 py-8 text-center"
>Nenhum dado disponível</td
>
</tr> </tr>
{:else} {:else}
{#each rows as row} {#each rows as row (row._id)}
<tr class="hover"> <tr class="hover">
<td class="font-semibold">{row.nome}</td> <td class="font-semibold">{row.nome}</td>
<td class="text-right font-mono"> <td class="text-right font-mono">
{row.valor.toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {row.valor.toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</td> </td>
<td class="text-right"> <td class="text-right">
<span class="badge badge-primary badge-outline">{row.count}</span> <span class="badge badge-primary badge-outline">{row.count}</span>
</td> </td>
<td class="text-right font-mono font-semibold text-primary"> <td class="text-primary text-right font-mono font-semibold">
{(row.valor * row.count).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {(row.valor * row.count).toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</td> </td>
</tr> </tr>
{/each} {/each}
<!-- Total Geral --> <!-- Total Geral -->
<tr class="font-bold bg-base-200 border-t-2 border-base-300"> <tr class="bg-base-200 border-base-300 border-t-2 font-bold">
<td>TOTAL GERAL</td> <td>TOTAL GERAL</td>
<td class="text-right font-mono"> <td class="text-right font-mono">
{rows.reduce((sum, r) => sum + r.valor, 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {rows
.reduce((sum, r) => sum + r.valor, 0)
.toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</td> </td>
<td class="text-right"> <td class="text-right">
<span class="badge badge-primary">{rows.reduce((sum, r) => sum + r.count, 0)}</span> <span class="badge badge-primary"
>{rows.reduce((sum, r) => sum + r.count, 0)}</span
>
</td> </td>
<td class="text-right font-mono text-primary text-lg"> <td class="text-primary text-right font-mono text-lg">
{rows.reduce((sum, r) => sum + (r.valor * r.count), 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {rows
.reduce((sum, r) => sum + r.valor * r.count, 0)
.toLocaleString('pt-BR', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
})}
</td> </td>
</tr> </tr>
{/if} {/if}

View File

@@ -13,7 +13,8 @@
| 'teams' | 'teams'
| 'userPlus' | 'userPlus'
| 'clock' | 'clock'
| 'video'; | 'video'
| 'building';
type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning'; type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning';
type TiRouteId = type TiRouteId =
@@ -30,7 +31,8 @@
| '/(dashboard)/ti/monitoramento' | '/(dashboard)/ti/monitoramento'
| '/(dashboard)/ti/configuracoes-ponto' | '/(dashboard)/ti/configuracoes-ponto'
| '/(dashboard)/ti/configuracoes-relogio' | '/(dashboard)/ti/configuracoes-relogio'
| '/(dashboard)/ti/configuracoes-jitsi'; | '/(dashboard)/ti/configuracoes-jitsi'
| '/(dashboard)/configuracoes/setores';
type FeatureCard = { type FeatureCard = {
title: string; title: string;
@@ -211,6 +213,13 @@
strokeLinecap: 'round', strokeLinecap: 'round',
strokeLinejoin: 'round' strokeLinejoin: 'round'
} }
],
building: [
{
d: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
strokeLinecap: 'round',
strokeLinejoin: 'round'
}
] ]
}; };
@@ -349,6 +358,15 @@
{ label: 'Relatórios', variant: 'outline' } { label: 'Relatórios', variant: 'outline' }
] ]
}, },
{
title: 'Gestão de Setores',
description:
'Gerencie os setores da organização. Setores são utilizados para organizar funcionários e definir responsabilidades em fluxos de trabalho.',
ctaLabel: 'Gerenciar Setores',
href: '/(dashboard)/configuracoes/setores',
palette: 'accent',
icon: 'building'
},
{ {
title: 'Documentação', title: 'Documentação',
description: description:

View File

@@ -1,51 +1,45 @@
<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 type { Id } from "@sgse-app/backend/convex/_generated/dataModel"; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import type { FunctionReference } from 'convex/server';
const client = useConvexClient(); const client = useConvexClient();
const currentUser = useQuery(api.auth.getCurrentUser, {}); const currentUser = useQuery(api.auth.getCurrentUser as FunctionReference<'query'>);
const configAtual = useQuery(api.configuracaoEmail.obterConfigEmail, {}); const configAtual = useQuery(api.configuracaoEmail.obterConfigEmail);
let servidor = $state(""); let servidor = $state('');
let porta = $state(587); let porta = $state(587);
let usuario = $state(""); let usuario = $state('');
let senha = $state(""); let senha = $state('');
let emailRemetente = $state(""); let emailRemetente = $state('');
let nomeRemetente = $state(""); let nomeRemetente = $state('');
let usarSSL = $state(false); let usarSSL = $state(false);
let usarTLS = $state(true); let usarTLS = $state(true);
let processando = $state(false); let processando = $state(false);
let testando = $state(false); let testando = $state(false);
let mensagem = $state<{ tipo: "success" | "error"; texto: string } | null>( let mensagem = $state<{ tipo: 'success' | 'error'; texto: string } | null>(null);
null,
);
function mostrarMensagem(tipo: "success" | "error", texto: string) { function mostrarMensagem(tipo: 'success' | 'error', texto: string) {
mensagem = { tipo, texto }; mensagem = { tipo, texto };
setTimeout(() => { setTimeout(() => {
mensagem = null; mensagem = null;
}, 5000); }, 5000);
} }
// Carregar config existente let dataLoaded = $state(false);
// Carregar config existente apenas uma vez
$effect(() => { $effect(() => {
if (configAtual?.data) { if (configAtual?.data && !dataLoaded) {
servidor = configAtual.data.servidor || ""; servidor = configAtual.data.servidor || '';
porta = configAtual.data.porta || 587; porta = configAtual.data.porta || 587;
usuario = configAtual.data.usuario || ""; usuario = configAtual.data.usuario || '';
emailRemetente = configAtual.data.emailRemetente || ""; emailRemetente = configAtual.data.emailRemetente || '';
nomeRemetente = configAtual.data.nomeRemetente || ""; nomeRemetente = configAtual.data.nomeRemetente || '';
usarSSL = configAtual.data.usarSSL || false; usarSSL = configAtual.data.usarSSL || false;
usarTLS = configAtual.data.usarTLS || true; usarTLS = configAtual.data.usarTLS || true;
} dataLoaded = true;
});
// Tornar SSL e TLS mutuamente exclusivos
$effect(() => {
if (usarSSL && usarTLS) {
// Se ambos estão marcados, priorizar TLS por padrão
usarSSL = false;
} }
}); });
@@ -72,62 +66,61 @@
!emailRemetente?.trim() || !emailRemetente?.trim() ||
!nomeRemetente?.trim() !nomeRemetente?.trim()
) { ) {
mostrarMensagem("error", "Preencha todos os campos obrigatórios"); mostrarMensagem('error', 'Preencha todos os campos obrigatórios');
return; return;
} }
// Validação de porta (1-65535) // Validação de porta (1-65535)
const portaNum = Number(porta); const portaNum = Number(porta);
if (isNaN(portaNum) || portaNum < 1 || portaNum > 65535) { if (isNaN(portaNum) || portaNum < 1 || portaNum > 65535) {
mostrarMensagem("error", "Porta deve ser um número entre 1 e 65535"); mostrarMensagem('error', 'Porta deve ser um número entre 1 e 65535');
return; return;
} }
// Validação de formato de email // Validação de formato de email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailRemetente.trim())) { if (!emailRegex.test(emailRemetente.trim())) {
mostrarMensagem("error", "Email remetente inválido"); mostrarMensagem('error', 'Email remetente inválido');
return; return;
} }
// Validação de senha: obrigatória apenas se não houver configuração existente // Validação de senha: obrigatória apenas se não houver configuração existente
const temConfigExistente = configAtual?.data?.ativo; const temConfigExistente = configAtual?.data?.ativo;
if (!temConfigExistente && !senha) { if (!temConfigExistente && !senha) {
mostrarMensagem("error", "Senha é obrigatória para nova configuração"); mostrarMensagem('error', 'Senha é obrigatória para nova configuração');
return; return;
} }
if (!currentUser?.data) { if (!currentUser?.data) {
mostrarMensagem("error", "Usuário não autenticado"); mostrarMensagem('error', 'Usuário não autenticado');
return; return;
} }
processando = true; processando = true;
try { try {
const resultado = await client.mutation( const resultado = await client.mutation(api.configuracaoEmail.salvarConfigEmail, {
api.configuracaoEmail.salvarConfigEmail,
{
servidor: servidor.trim(), servidor: servidor.trim(),
porta: portaNum, porta: portaNum,
usuario: usuario.trim(), usuario: usuario.trim(),
senha: senha || "", // Senha vazia será tratada no backend senha: senha || '', // Senha vazia será tratada no backend
emailRemetente: emailRemetente.trim(), emailRemetente: emailRemetente.trim(),
nomeRemetente: nomeRemetente.trim(), nomeRemetente: nomeRemetente.trim(),
usarSSL, usarSSL,
usarTLS, usarTLS,
configuradoPorId: currentUser.data._id as Id<"usuarios">, configuradoPorId: currentUser.data._id as Id<'usuarios'>
}, });
);
if (resultado.sucesso) { if (resultado.sucesso) {
mostrarMensagem("success", "Configuração salva com sucesso!"); mostrarMensagem('success', 'Configuração salva com sucesso!');
senha = ""; // Limpar senha senha = ''; // Limpar senha
} else { } else {
mostrarMensagem("error", resultado.erro); mostrarMensagem('error', resultado.erro);
}
} catch (error) {
if (error instanceof Error) {
console.error('Erro ao salvar configuração:', error);
mostrarMensagem('error', error.message || 'Erro ao salvar configuração');
} }
} catch (error: any) {
console.error("Erro ao salvar configuração:", error);
mostrarMensagem("error", error.message || "Erro ao salvar configuração");
} finally { } finally {
processando = false; processando = false;
} }
@@ -135,66 +128,56 @@
async function testarConexao() { async function testarConexao() {
if (!servidor?.trim() || !porta || !usuario?.trim() || !senha) { if (!servidor?.trim() || !porta || !usuario?.trim() || !senha) {
mostrarMensagem("error", "Preencha os dados de conexão antes de testar"); mostrarMensagem('error', 'Preencha os dados de conexão antes de testar');
return; return;
} }
// Validação de porta // Validação de porta
const portaNum = Number(porta); const portaNum = Number(porta);
if (isNaN(portaNum) || portaNum < 1 || portaNum > 65535) { if (isNaN(portaNum) || portaNum < 1 || portaNum > 65535) {
mostrarMensagem("error", "Porta deve ser um número entre 1 e 65535"); mostrarMensagem('error', 'Porta deve ser um número entre 1 e 65535');
return; return;
} }
testando = true; testando = true;
try { try {
const resultado = await client.action( const resultado = await client.action(api.configuracaoEmail.testarConexaoSMTP, {
api.configuracaoEmail.testarConexaoSMTP,
{
servidor: servidor.trim(), servidor: servidor.trim(),
porta: portaNum, porta: portaNum,
usuario: usuario.trim(), usuario: usuario.trim(),
senha: senha, senha: senha,
usarSSL, usarSSL,
usarTLS, usarTLS
}, });
);
if (resultado.sucesso) { if (resultado.sucesso) {
mostrarMensagem( mostrarMensagem('success', 'Conexão testada com sucesso! Servidor SMTP está respondendo.');
"success",
"Conexão testada com sucesso! Servidor SMTP está respondendo.",
);
} else { } else {
mostrarMensagem("error", `Erro ao testar conexão: ${resultado.erro}`); mostrarMensagem('error', `Erro ao testar conexão: ${resultado.erro}`);
}
} catch (error) {
if (error instanceof Error) {
console.error('Erro ao testar conexão:', error);
mostrarMensagem('error', error.message || 'Erro ao conectar com o servidor SMTP');
} }
} catch (error: any) {
console.error("Erro ao testar conexão:", error);
mostrarMensagem(
"error",
error.message || "Erro ao conectar com o servidor SMTP",
);
} finally { } finally {
testando = false; testando = false;
} }
} }
const statusConfig = $derived( const statusConfig = $derived(configAtual?.data?.ativo ? 'Configurado' : 'Não configurado');
configAtual?.data?.ativo ? "Configurado" : "Não configurado",
);
const isLoading = $derived(configAtual === undefined); const isLoading = $derived(configAtual === undefined);
const hasError = $derived(configAtual === null && !isLoading);
</script> </script>
<div class="container mx-auto px-4 py-6 max-w-4xl"> <div class="container mx-auto max-w-4xl px-4 py-6">
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between mb-6"> <div class="mb-6 flex items-center justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="p-3 bg-secondary/10 rounded-xl"> <div class="bg-secondary/10 rounded-xl p-3">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-secondary" class="text-secondary h-8 w-8"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -208,9 +191,7 @@
</svg> </svg>
</div> </div>
<div> <div>
<h1 class="text-3xl font-bold text-base-content"> <h1 class="text-base-content text-3xl font-bold">Configurações de Email (SMTP)</h1>
Configurações de Email (SMTP)
</h1>
<p class="text-base-content/60 mt-1"> <p class="text-base-content/60 mt-1">
Configurar servidor de email para envio de notificações Configurar servidor de email para envio de notificações
</p> </p>
@@ -222,16 +203,16 @@
{#if mensagem} {#if mensagem}
<div <div
class="alert mb-6" class="alert mb-6"
class:alert-success={mensagem.tipo === "success"} class:alert-success={mensagem.tipo === 'success'}
class:alert-error={mensagem.tipo === "error"} class:alert-error={mensagem.tipo === 'error'}
> >
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="stroke-current shrink-0 h-6 w-6" class="h-6 w-6 shrink-0 stroke-current"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
> >
{#if mensagem.tipo === "success"} {#if mensagem.tipo === 'success'}
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
@@ -261,16 +242,12 @@
<!-- Status --> <!-- Status -->
{#if !isLoading} {#if !isLoading}
<div <div class="alert {configAtual?.data?.ativo ? 'alert-success' : 'alert-warning'} mb-6">
class="alert {configAtual?.data?.ativo
? 'alert-success'
: 'alert-warning'} mb-6"
>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6" class="h-6 w-6 shrink-0 stroke-current"
> >
{#if configAtual?.data?.ativo} {#if configAtual?.data?.ativo}
<path <path
@@ -292,9 +269,7 @@
<strong>Status:</strong> <strong>Status:</strong>
{statusConfig} {statusConfig}
{#if configAtual?.data?.testadoEm} {#if configAtual?.data?.testadoEm}
- Última conexão testada em {new Date( - Última conexão testada em {new Date(configAtual.data.testadoEm).toLocaleString('pt-BR')}
configAtual.data.testadoEm,
).toLocaleString("pt-BR")}
{/if} {/if}
</span> </span>
</div> </div>
@@ -306,7 +281,7 @@
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-4">Dados do Servidor SMTP</h2> <h2 class="card-title mb-4">Dados do Servidor SMTP</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<!-- Servidor --> <!-- Servidor -->
<div class="form-control md:col-span-1"> <div class="form-control md:col-span-1">
<label class="label" for="smtp-servidor"> <label class="label" for="smtp-servidor">
@@ -320,9 +295,7 @@
class="input input-bordered" class="input input-bordered"
/> />
<div class="label"> <div class="label">
<span class="label-text-alt" <span class="label-text-alt">Ex: smtp.gmail.com, smtp.office365.com</span>
>Ex: smtp.gmail.com, smtp.office365.com</span
>
</div> </div>
</div> </div>
@@ -339,8 +312,7 @@
class="input input-bordered" class="input input-bordered"
/> />
<div class="label"> <div class="label">
<span class="label-text-alt">Comum: 587 (TLS), 465 (SSL), 25</span <span class="label-text-alt">Comum: 587 (TLS), 465 (SSL), 25</span>
>
</div> </div>
</div> </div>
@@ -412,7 +384,7 @@
<!-- Opções de Segurança --> <!-- Opções de Segurança -->
<div class="divider"></div> <div class="divider"></div>
<h3 class="font-bold mb-2">Configurações de Segurança</h3> <h3 class="mb-2 font-bold">Configurações de Segurança</h3>
<div class="flex flex-wrap gap-6"> <div class="flex flex-wrap gap-6">
<div class="form-control"> <div class="form-control">
@@ -441,7 +413,7 @@
</div> </div>
<!-- Ações --> <!-- Ações -->
<div class="card-actions justify-end mt-6 gap-3"> <div class="card-actions mt-6 justify-end gap-3">
<button <button
class="btn btn-outline btn-info" class="btn btn-outline btn-info"
onclick={testarConexao} onclick={testarConexao}
@@ -499,12 +471,12 @@
{/if} {/if}
<!-- Exemplos Comuns --> <!-- Exemplos Comuns -->
<div class="card bg-base-100 shadow-xl mt-6"> <div class="card bg-base-100 mt-6 shadow-xl">
<div class="card-body"> <div class="card-body">
<h2 class="card-title mb-4">Exemplos de Configuração</h2> <h2 class="card-title mb-4">Exemplos de Configuração</h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table table-sm"> <table class="table-sm table">
<thead> <thead>
<tr> <tr>
<th>Provedor</th> <th>Provedor</th>
@@ -550,7 +522,7 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="stroke-current shrink-0 w-6 h-6" class="h-6 w-6 shrink-0 stroke-current"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@@ -561,13 +533,11 @@
</svg> </svg>
<div> <div>
<p> <p>
<strong>Dica de Segurança:</strong> Para Gmail e outros provedores, você <strong>Dica de Segurança:</strong> Para Gmail e outros provedores, você pode precisar gerar
pode precisar gerar uma "senha de app" específica em vez de usar sua senha uma "senha de app" específica em vez de usar sua senha principal.
principal.
</p> </p>
<p class="text-sm mt-1"> <p class="mt-1 text-sm">
Gmail: Conta Google → Segurança → Verificação em duas etapas → Senhas de Gmail: Conta Google → Segurança → Verificação em duas etapas → Senhas de app
app
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import adapter from "@sveltejs/adapter-auto"; import adapter from "svelte-adapter-bun";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */

View File

@@ -1,16 +1,10 @@
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from "@sveltejs/kit/vite"; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from "vite"; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
resolve: { resolve: {
dedupe: ["lucide-svelte"], dedupe: ['lucide-svelte']
}, }
optimizeDeps: {
exclude: ["lib-jitsi-meet"], // Excluir para permitir carregamento dinâmico no browser
},
ssr: {
noExternal: [], // lib-jitsi-meet não funciona no SSR, deve ser carregada apenas no browser
},
}); });

View File

@@ -1,9 +1,11 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "sgse-app", "name": "sgse-app",
"dependencies": { "dependencies": {
"@convex-dev/better-auth": "^0.9.7",
"@tanstack/svelte-form": "^1.23.8", "@tanstack/svelte-form": "^1.23.8",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"lucide-svelte": "^0.552.0", "lucide-svelte": "^0.552.0",
@@ -18,6 +20,7 @@
"jiti": "^2.6.1", "jiti": "^2.6.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"svelte-dnd-action": "^0.9.67",
"turbo": "^2.5.8", "turbo": "^2.5.8",
"typescript-eslint": "^8.46.3", "typescript-eslint": "^8.46.3",
}, },
@@ -65,7 +68,9 @@
"esbuild": "^0.25.11", "esbuild": "^0.25.11",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"svelte": "^5.38.1", "svelte": "^5.38.1",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.3.1", "svelte-check": "^4.3.1",
"svelte-dnd-action": "^0.9.67",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.12",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^7.1.2", "vite": "^7.1.2",
@@ -82,7 +87,6 @@
"better-auth": "catalog:", "better-auth": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"nodemailer": "^7.0.10", "nodemailer": "^7.0.10",
"ssh2": "^1.17.0",
}, },
"devDependencies": { "devDependencies": {
"@sgse-app/eslint-config": "*", "@sgse-app/eslint-config": "*",
@@ -268,6 +272,12 @@
"@dicebear/thumbs": ["@dicebear/thumbs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg=="], "@dicebear/thumbs": ["@dicebear/thumbs@9.2.4", "", { "peerDependencies": { "@dicebear/core": "^9.0.0" } }, "sha512-EL4sMqv9p2+1Xy3d8e8UxyeKZV2+cgt3X2x2RTRzEOIIhobtkL8u6lJxmJbiGbpVtVALmrt5e7gjmwqpryYDpg=="],
"@emnapi/core": ["@emnapi/core@1.6.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.6.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.11", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.25.11", "", { "os": "android", "cpu": "arm" }, "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg=="],
@@ -376,6 +386,8 @@
"@mmailaender/convex-better-auth-svelte": ["@mmailaender/convex-better-auth-svelte@0.2.0", "", { "dependencies": { "is-network-error": "^1.1.0" }, "peerDependencies": { "@convex-dev/better-auth": "^0.9.0", "better-auth": "^1.3.27", "convex": "^1.27.0", "convex-svelte": "^0.0.11", "svelte": "^5.0.0" } }, "sha512-qzahOJg30xErb4ZW+aeszQw4ydhCmKFXn8CeRSA77YxR/dDMgZl+vdWLE4EKsDN0Jd748ecWMnk1fDNNUdgDcg=="], "@mmailaender/convex-better-auth-svelte": ["@mmailaender/convex-better-auth-svelte@0.2.0", "", { "dependencies": { "is-network-error": "^1.1.0" }, "peerDependencies": { "@convex-dev/better-auth": "^0.9.0", "better-auth": "^1.3.27", "convex": "^1.27.0", "convex-svelte": "^0.0.11", "svelte": "^5.0.0" } }, "sha512-qzahOJg30xErb4ZW+aeszQw4ydhCmKFXn8CeRSA77YxR/dDMgZl+vdWLE4EKsDN0Jd748ecWMnk1fDNNUdgDcg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.6", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ=="], "@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.5.6", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-YxDvsT2fwy1j5gMqk3ppXlsgDopHnkM4BoxSVASbvvgh5zgsK8lvWerDzPip8k3WVzsTZ1O7A7si1KNfN4OZfQ=="],
"@noble/ciphers": ["@noble/ciphers@2.0.1", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="], "@noble/ciphers": ["@noble/ciphers@2.0.1", "", {}, "sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g=="],
@@ -388,6 +400,10 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="],
"@oxc-project/types": ["@oxc-project/types@0.71.0", "", {}, "sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A=="], "@peculiar/asn1-android": ["@peculiar/asn1-android@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A=="],
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A=="], "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A=="],
@@ -414,6 +430,32 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "x64" }, "sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8BDM939bbMariZupiHp3OmP5N+LXPT4mULA0hZjDaq970PCxv4krZOSMG+HkWUUwmuQROtV+/00xw39EO0P+8g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm" }, "sha512-sntsPaPgrECpBB/+2xrQzVUt0r493TMPI+4kWRMhvMsmrxOqH1Ep5lM0Wua/ZdbfZNwm1aVa5pcESQfNfM4Fhw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5clBW/I+er9F2uM1OFjJFWX86y7Lcy0M+NqsN4s3o07W+8467Zk8oQa4B45vdaXoNUF/yqIAgKkA/OEdQDxZqA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm64" }, "sha512-wv+rnAfQDk9p/CheX8/Kmqk2o1WaFa4xhWI9gOyDMk/ljvOX0u0ubeM8nI1Qfox7Tnh71eV5AjzSePXUhFOyOg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "x64" }, "sha512-gxD0/xhU4Py47IH3bKZbWtvB99tMkUPGPJFRfSc5UB9Osoje0l0j1PPbxpUtXIELurYCqwLBKXIMTQGifox1BQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "x64" }, "sha512-HotuVe3XUjDwqqEMbm3o3IRkP9gdm8raY/btd/6KE3JGLF/cv4+3ff1l6nOhAZI8wulWDPEXPtE7v+HQEaTXnA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.4" }, "cpu": "none" }, "sha512-8Cx+ucbd8n2dIr21FqBh6rUvTVL0uTgEtKR7l+MUZ5BgY4dFh1e4mPVX8oqmoYwOxBiXrsD2JIOCz4AyKLKxWA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Vhq5vikrVDxAa75fxsyqj0c0Y/uti/TwshXI71Xb8IeUQJOBnmLUsn5dgYf5ljpYYkNa0z9BPAvUDIDMmyDi+w=="],
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "ia32" }, "sha512-lN7RIg9Iugn08zP2aZN9y/MIdG8iOOCE93M1UrFlrxMTqPf8X+fDzmR/OKhTSd1A2pYNipZHjyTcb5H8kyQSow=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "x64" }, "sha512-7/7cLIn48Y+EpQ4CePvf8reFl63F15yPUlg4ZAhl+RXJIfydkdak1WD8Ir3AwAO+bJBXzrfNL+XQbxm0mcQZmw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5", "", {}, "sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.52.5", "", { "os": "android", "cpu": "arm" }, "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.52.5", "", { "os": "android", "cpu": "arm64" }, "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA=="],
@@ -602,6 +644,8 @@
"@tanstack/svelte-store": ["@tanstack/svelte-store@0.7.7", "", { "dependencies": { "@tanstack/store": "0.7.7" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-JeDyY7SxBi6EKzkf2wWoghdaC2bvmwNL9X/dgkx7LKEvJVle+te7tlELI3cqRNGbjXt9sx+97jx9M5dCCHcuog=="], "@tanstack/svelte-store": ["@tanstack/svelte-store@0.7.7", "", { "dependencies": { "@tanstack/store": "0.7.7" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-JeDyY7SxBi6EKzkf2wWoghdaC2bvmwNL9X/dgkx7LKEvJVle+te7tlELI3cqRNGbjXt9sx+97jx9M5dCCHcuog=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/cookie": ["@types/cookie@1.0.0", "", { "dependencies": { "cookie": "*" } }, "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w=="], "@types/cookie": ["@types/cookie@1.0.0", "", { "dependencies": { "cookie": "*" } }, "sha512-mGFXbkDQJ6kAXByHS7QAggRXgols0mAdP4MuXgloGY1tXokvzaFFM4SMqWvf7AH0oafI7zlFJwoGWzmhDqTZ9w=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -650,6 +694,8 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="],
@@ -668,8 +714,6 @@
"arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="], "asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
"async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="],
@@ -686,8 +730,6 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.8.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-JU0h5APyQNsHOlAM7HnQnPToSDQoEBZqzu/YBlqDnEeymPnZDREeXJA3KBMQee+dKteAxZ2AtvQEvVYdZf241Q=="],
"bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="],
"better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="], "better-auth": ["better-auth@1.3.27", "", { "dependencies": { "@better-auth/core": "1.3.27", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.18", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "better-call": "1.0.19", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.5" } }, "sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w=="],
"better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="], "better-call": ["better-call@1.0.19", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.5.1", "set-cookie-parser": "^2.7.1", "uncrypto": "^0.1.3" } }, "sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw=="],
@@ -700,8 +742,6 @@
"browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="], "browserslist": ["browserslist@4.27.0", "", { "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", "electron-to-chromium": "^1.5.238", "node-releases": "^2.0.26", "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" } }, "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw=="],
"buildcheck": ["buildcheck@0.0.6", "", {}, "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A=="],
"call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
@@ -740,8 +780,6 @@
"core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="], "core-js": ["core-js@3.46.0", "", {}, "sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA=="],
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="], "css-line-break": ["css-line-break@2.1.0", "", { "dependencies": { "utrie": "^1.0.2" } }, "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w=="],
@@ -1072,8 +1110,6 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nan": ["nan@2.23.1", "", {}, "sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="], "nanostores": ["nanostores@1.0.1", "", {}, "sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw=="],
@@ -1188,6 +1224,8 @@
"rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="], "rgbcolor": ["rgbcolor@1.0.1", "", {}, "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw=="],
"rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="],
"rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="], "rollup": ["rollup@4.52.5", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.52.5", "@rollup/rollup-android-arm64": "4.52.5", "@rollup/rollup-darwin-arm64": "4.52.5", "@rollup/rollup-darwin-x64": "4.52.5", "@rollup/rollup-freebsd-arm64": "4.52.5", "@rollup/rollup-freebsd-x64": "4.52.5", "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", "@rollup/rollup-linux-arm-musleabihf": "4.52.5", "@rollup/rollup-linux-arm64-gnu": "4.52.5", "@rollup/rollup-linux-arm64-musl": "4.52.5", "@rollup/rollup-linux-loong64-gnu": "4.52.5", "@rollup/rollup-linux-ppc64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-gnu": "4.52.5", "@rollup/rollup-linux-riscv64-musl": "4.52.5", "@rollup/rollup-linux-s390x-gnu": "4.52.5", "@rollup/rollup-linux-x64-gnu": "4.52.5", "@rollup/rollup-linux-x64-musl": "4.52.5", "@rollup/rollup-openharmony-arm64": "4.52.5", "@rollup/rollup-win32-arm64-msvc": "4.52.5", "@rollup/rollup-win32-ia32-msvc": "4.52.5", "@rollup/rollup-win32-x64-gnu": "4.52.5", "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw=="],
"rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="], "rou3": ["rou3@0.5.1", "", {}, "sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ=="],
@@ -1204,8 +1242,6 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -1234,8 +1270,6 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="], "stackblur-canvas": ["stackblur-canvas@2.7.0", "", {}, "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ=="],
"stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="],
@@ -1260,10 +1294,14 @@
"svelte": ["svelte@5.43.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA=="], "svelte": ["svelte@5.43.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
"svelte-chartjs": ["svelte-chartjs@3.1.5", "", { "peerDependencies": { "chart.js": "^3.5.0 || ^4.0.0", "svelte": "^4.0.0" } }, "sha512-ka2zh7v5FiwfAX1oMflZ0HkNkgjHjFqANgRyC+vNYXfxtx2ku68Zo+2KgbKeBH2nS1ThDqkIACPzGxy4T0UaoA=="], "svelte-chartjs": ["svelte-chartjs@3.1.5", "", { "peerDependencies": { "chart.js": "^3.5.0 || ^4.0.0", "svelte": "^4.0.0" } }, "sha512-ka2zh7v5FiwfAX1oMflZ0HkNkgjHjFqANgRyC+vNYXfxtx2ku68Zo+2KgbKeBH2nS1ThDqkIACPzGxy4T0UaoA=="],
"svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="], "svelte-check": ["svelte-check@4.3.3", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg=="],
"svelte-dnd-action": ["svelte-dnd-action@0.9.67", "", { "peerDependencies": { "svelte": ">=3.23.0 || ^5.0.0-next.0" } }, "sha512-yEJQZ9SFy3O4mnOdtjwWyotRsWRktNf4W8k67zgiLiMtMNQnwCyJHBjkGMgZMDh8EGZ4gr88l+GebBWoHDwo+g=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA=="], "svelte-eslint-parser": ["svelte-eslint-parser@1.4.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA=="],
"svelte-sonner": ["svelte-sonner@1.0.5", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg=="], "svelte-sonner": ["svelte-sonner@1.0.5", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg=="],
@@ -1302,8 +1340,6 @@
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="], "turbo-windows-arm64": ["turbo-windows-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-eFC5XzLmgXJfnAK3UMTmVECCwuBcORrWdewoiXBnUm934DY6QN8YowC/srhNnROMpaKaqNeRpoB5FxCww3eteQ=="],
"tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="],

View File

@@ -32,6 +32,7 @@
"jiti": "^2.6.1", "jiti": "^2.6.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"svelte-dnd-action": "^0.9.67",
"turbo": "^2.5.8", "turbo": "^2.5.8",
"typescript-eslint": "^8.46.3" "typescript-eslint": "^8.46.3"
}, },
@@ -41,7 +42,8 @@
"lucide-svelte": "^0.552.0", "lucide-svelte": "^0.552.0",
"prettier-plugin-svelte": "^3.4.0", "prettier-plugin-svelte": "^3.4.0",
"svelte-chartjs": "^3.1.5", "svelte-chartjs": "^3.1.5",
"svelte-sonner": "^1.0.5" "svelte-sonner": "^1.0.5",
"@convex-dev/better-auth": "^0.9.7"
}, },
"packageManager": "bun@1.3.0" "packageManager": "bun@1.3.0"
} }

View File

@@ -1,3 +1,2 @@
.env .env*
.env.local
.convex/ .convex/

View File

@@ -9,7 +9,6 @@
*/ */
import type * as actions_email from "../actions/email.js"; import type * as actions_email from "../actions/email.js";
import type * as actions_jitsiServer from "../actions/jitsiServer.js";
import type * as actions_linkPreview from "../actions/linkPreview.js"; import type * as actions_linkPreview from "../actions/linkPreview.js";
import type * as actions_pushNotifications from "../actions/pushNotifications.js"; import type * as actions_pushNotifications from "../actions/pushNotifications.js";
import type * as actions_smtp from "../actions/smtp.js"; import type * as actions_smtp from "../actions/smtp.js";
@@ -35,6 +34,7 @@ import type * as email from "../email.js";
import type * as empresas from "../empresas.js"; import type * as empresas from "../empresas.js";
import type * as enderecosMarcacao from "../enderecosMarcacao.js"; import type * as enderecosMarcacao from "../enderecosMarcacao.js";
import type * as ferias from "../ferias.js"; import type * as ferias from "../ferias.js";
import type * as flows from "../flows.js";
import type * as funcionarioEnderecos from "../funcionarioEnderecos.js"; import type * as funcionarioEnderecos from "../funcionarioEnderecos.js";
import type * as funcionarios from "../funcionarios.js"; import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js"; import type * as healthCheck from "../healthCheck.js";
@@ -51,6 +51,7 @@ import type * as roles from "../roles.js";
import type * as saldoFerias from "../saldoFerias.js"; import type * as saldoFerias from "../saldoFerias.js";
import type * as security from "../security.js"; import type * as security from "../security.js";
import type * as seed from "../seed.js"; import type * as seed from "../seed.js";
import type * as setores from "../setores.js";
import type * as simbolos from "../simbolos.js"; import type * as simbolos from "../simbolos.js";
import type * as templatesMensagens from "../templatesMensagens.js"; import type * as templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js"; import type * as times from "../times.js";
@@ -67,7 +68,6 @@ import type {
declare const fullApi: ApiFromModules<{ declare const fullApi: ApiFromModules<{
"actions/email": typeof actions_email; "actions/email": typeof actions_email;
"actions/jitsiServer": typeof actions_jitsiServer;
"actions/linkPreview": typeof actions_linkPreview; "actions/linkPreview": typeof actions_linkPreview;
"actions/pushNotifications": typeof actions_pushNotifications; "actions/pushNotifications": typeof actions_pushNotifications;
"actions/smtp": typeof actions_smtp; "actions/smtp": typeof actions_smtp;
@@ -93,6 +93,7 @@ declare const fullApi: ApiFromModules<{
empresas: typeof empresas; empresas: typeof empresas;
enderecosMarcacao: typeof enderecosMarcacao; enderecosMarcacao: typeof enderecosMarcacao;
ferias: typeof ferias; ferias: typeof ferias;
flows: typeof flows;
funcionarioEnderecos: typeof funcionarioEnderecos; funcionarioEnderecos: typeof funcionarioEnderecos;
funcionarios: typeof funcionarios; funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck; healthCheck: typeof healthCheck;
@@ -109,6 +110,7 @@ declare const fullApi: ApiFromModules<{
saldoFerias: typeof saldoFerias; saldoFerias: typeof saldoFerias;
security: typeof security; security: typeof security;
seed: typeof seed; seed: typeof seed;
setores: typeof setores;
simbolos: typeof simbolos; simbolos: typeof simbolos;
templatesMensagens: typeof templatesMensagens; templatesMensagens: typeof templatesMensagens;
times: typeof times; times: typeof times;

View File

@@ -1,432 +1,432 @@
"use node"; // "use node";
import { action } from "../_generated/server"; // import { action } from "../_generated/server";
import { v } from "convex/values"; // import { v } from "convex/values";
import { api, internal } from "../_generated/api"; // import { api, internal } from "../_generated/api";
import { Client } from "ssh2"; // import { Client } from "ssh2";
import { readFileSync } from "fs"; // import { readFileSync } from "fs";
import { decryptSMTPPasswordNode } from "./utils/nodeCrypto"; // import { decryptSMTPPasswordNode } from "./utils/nodeCrypto";
/** // /**
* Interface para configuração SSH // * Interface para configuração SSH
*/ // */
interface SSHConfig { // interface SSHConfig {
host: string; // host: string;
port: number; // port: number;
username: string; // username: string;
password?: string; // password?: string;
keyPath?: string; // keyPath?: string;
} // }
/** // /**
* Executar comando via SSH // * Executar comando via SSH
*/ // */
async function executarComandoSSH( // async function executarComandoSSH(
config: SSHConfig, // config: SSHConfig,
comando: string // comando: string
): Promise<{ sucesso: boolean; output: string; erro?: string }> { // ): Promise<{ sucesso: boolean; output: string; erro?: string }> {
return new Promise((resolve) => { // return new Promise((resolve) => {
const conn = new Client(); // const conn = new Client();
let output = ""; // let output = "";
let errorOutput = ""; // let errorOutput = "";
conn.on("ready", () => { // conn.on("ready", () => {
conn.exec(comando, (err, stream) => { // conn.exec(comando, (err, stream) => {
if (err) { // if (err) {
conn.end(); // conn.end();
resolve({ sucesso: false, output: "", erro: err.message }); // resolve({ sucesso: false, output: "", erro: err.message });
return; // return;
} // }
stream // stream
.on("close", (code: number | null, signal: string | null) => { // .on("close", (code: number | null, signal: string | null) => {
conn.end(); // conn.end();
if (code === 0) { // if (code === 0) {
resolve({ sucesso: true, output: output.trim() }); // resolve({ sucesso: true, output: output.trim() });
} else { // } else {
resolve({ // resolve({
sucesso: false, // sucesso: false,
output: output.trim(), // output: output.trim(),
erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`, // erro: `Comando retornou código ${code}${signal ? ` (signal: ${signal})` : ""}. ${errorOutput}`,
}); // });
} // }
}) // })
.on("data", (data: Buffer) => { // .on("data", (data: Buffer) => {
output += data.toString(); // output += data.toString();
}) // })
.stderr.on("data", (data: Buffer) => { // .stderr.on("data", (data: Buffer) => {
errorOutput += data.toString(); // errorOutput += data.toString();
}); // });
}); // });
}).on("error", (err) => { // }).on("error", (err) => {
resolve({ sucesso: false, output: "", erro: err.message }); // resolve({ sucesso: false, output: "", erro: err.message });
}).connect({ // }).connect({
host: config.host, // host: config.host,
port: config.port, // port: config.port,
username: config.username, // username: config.username,
password: config.password, // password: config.password,
privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined, // privateKey: config.keyPath ? readFileSync(config.keyPath) : undefined,
readyTimeout: 10000, // readyTimeout: 10000,
}); // });
}); // });
} // }
/** // /**
* Ler arquivo via SSH // * Ler arquivo via SSH
*/ // */
async function lerArquivoSSH( // async function lerArquivoSSH(
config: SSHConfig, // config: SSHConfig,
caminho: string // caminho: string
): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> { // ): Promise<{ sucesso: boolean; conteudo?: string; erro?: string }> {
const comando = `cat "${caminho}" 2>&1`; // const comando = `cat "${caminho}" 2>&1`;
const resultado = await executarComandoSSH(config, comando); // const resultado = await executarComandoSSH(config, comando);
if (!resultado.sucesso) { // if (!resultado.sucesso) {
return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" }; // return { sucesso: false, erro: resultado.erro || "Erro ao ler arquivo" };
} // }
return { sucesso: true, conteudo: resultado.output }; // return { sucesso: true, conteudo: resultado.output };
} // }
/** // /**
* Escrever arquivo via SSH // * Escrever arquivo via SSH
*/ // */
async function escreverArquivoSSH( // async function escreverArquivoSSH(
config: SSHConfig, // config: SSHConfig,
caminho: string, // caminho: string,
conteudo: string // conteudo: string
): Promise<{ sucesso: boolean; erro?: string }> { // ): Promise<{ sucesso: boolean; erro?: string }> {
// Escapar conteúdo para shell // // Escapar conteúdo para shell
const conteudoEscapado = conteudo // const conteudoEscapado = conteudo
.replace(/\\/g, "\\\\") // .replace(/\\/g, "\\\\")
.replace(/"/g, '\\"') // .replace(/"/g, '\\"')
.replace(/\$/g, "\\$") // .replace(/\$/g, "\\$")
.replace(/`/g, "\\`"); // .replace(/`/g, "\\`");
const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF' // const comando = `cat > "${caminho}" << 'JITSI_CONFIG_EOF'
${conteudo} // ${conteudo}
JITSI_CONFIG_EOF`; // JITSI_CONFIG_EOF`;
const resultado = await executarComandoSSH(config, comando); // const resultado = await executarComandoSSH(config, comando);
if (!resultado.sucesso) { // if (!resultado.sucesso) {
return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" }; // return { sucesso: false, erro: resultado.erro || "Erro ao escrever arquivo" };
} // }
return { sucesso: true }; // return { sucesso: true };
} // }
/** // /**
* Aplicar configurações do Jitsi no servidor Docker via SSH // * Aplicar configurações do Jitsi no servidor Docker via SSH
*/ // */
export const aplicarConfiguracaoServidor = action({ // export const aplicarConfiguracaoServidor = action({
args: { // args: {
configId: v.id("configuracaoJitsi"), // configId: v.id("configuracaoJitsi"),
sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave) // sshPassword: v.optional(v.string()), // Senha SSH (se não usar chave)
}, // },
returns: v.union( // returns: v.union(
v.object({ // v.object({
sucesso: v.literal(true), // sucesso: v.literal(true),
mensagem: v.string(), // mensagem: v.string(),
detalhes: v.optional(v.string()), // detalhes: v.optional(v.string()),
}), // }),
v.object({ sucesso: v.literal(false), erro: v.string() }) // v.object({ sucesso: v.literal(false), erro: v.string() })
), // ),
handler: async (ctx, args): Promise< // handler: async (ctx, args): Promise<
| { sucesso: true; mensagem: string; detalhes?: string } // | { sucesso: true; mensagem: string; detalhes?: string }
| { sucesso: false; erro: string } // | { sucesso: false; erro: string }
> => { // > => {
try { // try {
// Buscar configuração // // Buscar configuração
const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {}); // const config = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsi, {});
if (!config || config._id !== args.configId) { // if (!config || config._id !== args.configId) {
return { sucesso: false as const, erro: "Configuração não encontrada" }; // return { sucesso: false as const, erro: "Configuração não encontrada" };
} // }
// Verificar se tem configurações SSH // // Verificar se tem configurações SSH
const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, { // const configFull = await ctx.runQuery(api.configuracaoJitsi.obterConfigJitsiCompleta, {
configId: args.configId, // configId: args.configId,
}); // });
if (!configFull || !configFull.sshHost) { // if (!configFull || !configFull.sshHost) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.", // erro: "Configurações SSH não estão definidas. Configure o servidor SSH primeiro.",
}; // };
} // }
// Configurar SSH // // Configurar SSH
let sshPasswordDecrypted: string | undefined = undefined; // let sshPasswordDecrypted: string | undefined = undefined;
// Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada // // Se senha foi fornecida, usar ela. Caso contrário, tentar descriptografar a armazenada
if (args.sshPassword) { // if (args.sshPassword) {
sshPasswordDecrypted = args.sshPassword; // sshPasswordDecrypted = args.sshPassword;
} else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") { // } else if (configFull.sshPasswordHash && configFull.sshPasswordHash !== "********") {
// Tentar descriptografar senha armazenada // // Tentar descriptografar senha armazenada
try { // try {
sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash); // sshPasswordDecrypted = await decryptSMTPPasswordNode(configFull.sshPasswordHash);
} catch (error) { // } catch (error) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.", // erro: "Não foi possível descriptografar a senha SSH armazenada. Forneça a senha novamente.",
}; // };
} // }
} // }
const sshConfig: SSHConfig = { // const sshConfig: SSHConfig = {
host: configFull.sshHost, // host: configFull.sshHost,
port: configFull.sshPort || 22, // port: configFull.sshPort || 22,
username: configFull.sshUsername || "", // username: configFull.sshUsername || "",
password: sshPasswordDecrypted, // password: sshPasswordDecrypted,
keyPath: configFull.sshKeyPath || undefined, // keyPath: configFull.sshKeyPath || undefined,
}; // };
if (!sshConfig.username) { // if (!sshConfig.username) {
return { sucesso: false as const, erro: "Usuário SSH não configurado" }; // return { sucesso: false as const, erro: "Usuário SSH não configurado" };
} // }
if (!sshConfig.password && !sshConfig.keyPath) { // if (!sshConfig.password && !sshConfig.keyPath) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: "Senha SSH ou caminho da chave deve ser fornecido", // erro: "Senha SSH ou caminho da chave deve ser fornecido",
}; // };
} // }
const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg"; // const basePath = configFull.jitsiConfigPath || "~/.jitsi-meet-cfg";
const dockerComposePath = configFull.dockerComposePath || "."; // const dockerComposePath = configFull.dockerComposePath || ".";
// Extrair host e porta do domain // // Extrair host e porta do domain
const [host, portStr] = configFull.domain.split(":"); // const [host, portStr] = configFull.domain.split(":");
const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80; // const port = portStr ? parseInt(portStr, 10) : configFull.useHttps ? 443 : 80;
const protocol = configFull.useHttps ? "https" : "http"; // const protocol = configFull.useHttps ? "https" : "http";
const detalhes: string[] = []; // const detalhes: string[] = [];
// 1. Atualizar arquivo .env do docker-compose // // 1. Atualizar arquivo .env do docker-compose
if (dockerComposePath) { // if (dockerComposePath) {
const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE // const envContent = `# Configuração Jitsi - Atualizada automaticamente pelo SGSE
CONFIG=${basePath} // CONFIG=${basePath}
TZ=America/Recife // TZ=America/Recife
ENABLE_LETSENCRYPT=0 // ENABLE_LETSENCRYPT=0
HTTP_PORT=${protocol === "https" ? 8000 : port} // HTTP_PORT=${protocol === "https" ? 8000 : port}
HTTPS_PORT=${configFull.useHttps ? port : 8443} // HTTPS_PORT=${configFull.useHttps ? port : 8443}
PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""} // PUBLIC_URL=${protocol}://${host}${portStr ? `:${port}` : ""}
DOMAIN=${host} // DOMAIN=${host}
ENABLE_AUTH=0 // ENABLE_AUTH=0
ENABLE_GUESTS=1 // ENABLE_GUESTS=1
ENABLE_TRANSCRIPTION=0 // ENABLE_TRANSCRIPTION=0
ENABLE_RECORDING=0 // ENABLE_RECORDING=0
ENABLE_PREJOIN_PAGE=0 // ENABLE_PREJOIN_PAGE=0
START_AUDIO_MUTED=0 // START_AUDIO_MUTED=0
START_VIDEO_MUTED=0 // START_VIDEO_MUTED=0
ENABLE_XMPP_WEBSOCKET=0 // ENABLE_XMPP_WEBSOCKET=0
ENABLE_P2P=1 // ENABLE_P2P=1
MAX_NUMBER_OF_PARTICIPANTS=10 // MAX_NUMBER_OF_PARTICIPANTS=10
RESOLUTION_WIDTH=1280 // RESOLUTION_WIDTH=1280
RESOLUTION_HEIGHT=720 // RESOLUTION_HEIGHT=720
JWT_APP_ID=${configFull.appId} // JWT_APP_ID=${configFull.appId}
JWT_APP_SECRET= // JWT_APP_SECRET=
`; // `;
const envPath = `${dockerComposePath}/.env`; // const envPath = `${dockerComposePath}/.env`;
const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent); // const resultadoEnv = await escreverArquivoSSH(sshConfig, envPath, envContent);
if (!resultadoEnv.sucesso) { // if (!resultadoEnv.sucesso) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`, // erro: `Erro ao atualizar .env: ${resultadoEnv.erro}`,
}; // };
} // }
detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`); // detalhes.push(`✓ Arquivo .env atualizado: ${envPath}`);
} // }
// 2. Atualizar configuração do Prosody (conforme documentação oficial) // // 2. Atualizar configuração do Prosody (conforme documentação oficial)
const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`; // const prosodyConfigPath = `${basePath}/prosody/config/${host}.cfg.lua`;
const prosodyContent = `-- Configuração Prosody para ${host} // const prosodyContent = `-- Configuração Prosody para ${host}
-- Gerada automaticamente pelo SGSE // -- Gerada automaticamente pelo SGSE
-- Baseado na documentação oficial do Jitsi Meet // -- Baseado na documentação oficial do Jitsi Meet
VirtualHost "${host}" // VirtualHost "${host}"
authentication = "anonymous" // authentication = "anonymous"
modules_enabled = { // modules_enabled = {
"bosh"; // "bosh";
"websocket"; // "websocket";
"ping"; // "ping";
"speakerstats"; // "speakerstats";
"turncredentials"; // "turncredentials";
"presence"; // "presence";
"conference_duration"; // "conference_duration";
"stats"; // "stats";
} // }
c2s_require_encryption = false // c2s_require_encryption = false
allow_anonymous_s2s = false // allow_anonymous_s2s = false
bosh_max_inactivity = 60 // bosh_max_inactivity = 60
bosh_max_polling = 5 // bosh_max_polling = 5
bosh_max_stanzas = 5 // bosh_max_stanzas = 5
Component "conference.${host}" "muc" // Component "conference.${host}" "muc"
storage = "memory" // storage = "memory"
muc_room_locking = false // muc_room_locking = false
muc_room_default_public_jids = true // muc_room_default_public_jids = true
muc_room_cache_size = 1000 // muc_room_cache_size = 1000
muc_log_presences = true // muc_log_presences = true
Component "jitsi-videobridge.${host}" // Component "jitsi-videobridge.${host}"
component_secret = "" // component_secret = ""
Component "focus.${host}" // Component "focus.${host}"
component_secret = "" // component_secret = ""
`; // `;
const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent); // const resultadoProsody = await escreverArquivoSSH(sshConfig, prosodyConfigPath, prosodyContent);
if (!resultadoProsody.sucesso) { // if (!resultadoProsody.sucesso) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`, // erro: `Erro ao atualizar Prosody: ${resultadoProsody.erro}`,
}; // };
} // }
detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`); // detalhes.push(`✓ Configuração Prosody atualizada: ${prosodyConfigPath}`);
// 3. Atualizar configuração do Jicofo // // 3. Atualizar configuração do Jicofo
const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`; // const jicofoConfigPath = `${basePath}/jicofo/sip-communicator.properties`;
const jicofoContent = `# Configuração Jicofo // const jicofoContent = `# Configuração Jicofo
# Gerada automaticamente pelo SGSE // # Gerada automaticamente pelo SGSE
org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host} // org.jitsi.jicofo.BRIDGE_MUC=JvbBrewery@internal.${host}
org.jitsi.jicofo.jid=XMPP_USER@${host} // org.jitsi.jicofo.jid=XMPP_USER@${host}
org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host} // org.jitsi.jicofo.BRIDGE_MUC_JID=MUC_BRIDGE_JID@internal.${host}
org.jitsi.jicofo.app.ID=${configFull.appId} // org.jitsi.jicofo.app.ID=${configFull.appId}
`; // `;
const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent); // const resultadoJicofo = await escreverArquivoSSH(sshConfig, jicofoConfigPath, jicofoContent);
if (!resultadoJicofo.sucesso) { // if (!resultadoJicofo.sucesso) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`, // erro: `Erro ao atualizar Jicofo: ${resultadoJicofo.erro}`,
}; // };
} // }
detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`); // detalhes.push(`✓ Configuração Jicofo atualizada: ${jicofoConfigPath}`);
// 4. Atualizar configuração do JVB // // 4. Atualizar configuração do JVB
const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`; // const jvbConfigPath = `${basePath}/jvb/sip-communicator.properties`;
const jvbContent = `# Configuração JVB (Jitsi Video Bridge) // const jvbContent = `# Configuração JVB (Jitsi Video Bridge)
# Gerada automaticamente pelo SGSE // # Gerada automaticamente pelo SGSE
org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.* // org.jitsi.videobridge.AUTHORIZED_SOURCE_REGEXP=.*@${host}/.*
org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host} // org.jitsi.videobridge.xmpp.user.shard.HOSTNAME=${host}
org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host} // org.jitsi.videobridge.xmpp.user.shard.DOMAIN=auth.${host}
org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb // org.jitsi.videobridge.xmpp.user.shard.USERNAME=jvb
org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host} // org.jitsi.videobridge.xmpp.user.shard.MUC_JIDS=JvbBrewery@internal.${host}
`; // `;
const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent); // const resultadoJvb = await escreverArquivoSSH(sshConfig, jvbConfigPath, jvbContent);
if (!resultadoJvb.sucesso) { // if (!resultadoJvb.sucesso) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`, // erro: `Erro ao atualizar JVB: ${resultadoJvb.erro}`,
}; // };
} // }
detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`); // detalhes.push(`✓ Configuração JVB atualizada: ${jvbConfigPath}`);
// 5. Reiniciar containers Docker // // 5. Reiniciar containers Docker
if (dockerComposePath) { // if (dockerComposePath) {
const resultadoRestart = await executarComandoSSH( // const resultadoRestart = await executarComandoSSH(
sshConfig, // sshConfig,
`cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1` // `cd "${dockerComposePath}" && docker-compose restart 2>&1 || docker compose restart 2>&1`
); // );
if (!resultadoRestart.sucesso) { // if (!resultadoRestart.sucesso) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`, // erro: `Erro ao reiniciar containers: ${resultadoRestart.erro}`,
}; // };
} // }
detalhes.push(`✓ Containers Docker reiniciados`); // detalhes.push(`✓ Containers Docker reiniciados`);
} // }
// Atualizar timestamp de configuração no servidor // // Atualizar timestamp de configuração no servidor
await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, { // await ctx.runMutation(internal.configuracaoJitsi.marcarConfiguradoNoServidor, {
configId: args.configId, // configId: args.configId,
}); // });
return { // return {
sucesso: true as const, // sucesso: true as const,
mensagem: "Configurações aplicadas com sucesso no servidor Jitsi", // mensagem: "Configurações aplicadas com sucesso no servidor Jitsi",
detalhes: detalhes.join("\n"), // detalhes: detalhes.join("\n"),
}; // };
} catch (error: unknown) { // } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); // const errorMessage = error instanceof Error ? error.message : String(error);
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao aplicar configurações: ${errorMessage}`, // erro: `Erro ao aplicar configurações: ${errorMessage}`,
}; // };
} // }
}, // },
}); // });
/** // /**
* Testar conexão SSH // * Testar conexão SSH
*/ // */
export const testarConexaoSSH = action({ // export const testarConexaoSSH = action({
args: { // args: {
sshHost: v.string(), // sshHost: v.string(),
sshPort: v.optional(v.number()), // sshPort: v.optional(v.number()),
sshUsername: v.string(), // sshUsername: v.string(),
sshPassword: v.optional(v.string()), // sshPassword: v.optional(v.string()),
sshKeyPath: v.optional(v.string()), // sshKeyPath: v.optional(v.string()),
}, // },
returns: v.union( // returns: v.union(
v.object({ sucesso: v.literal(true), mensagem: v.string() }), // v.object({ sucesso: v.literal(true), mensagem: v.string() }),
v.object({ sucesso: v.literal(false), erro: v.string() }) // v.object({ sucesso: v.literal(false), erro: v.string() })
), // ),
handler: async (ctx, args): Promise< // handler: async (ctx, args): Promise<
| { sucesso: true; mensagem: string } // | { sucesso: true; mensagem: string }
| { sucesso: false; erro: string } // | { sucesso: false; erro: string }
> => { // > => {
try { // try {
if (!args.sshPassword && !args.sshKeyPath) { // if (!args.sshPassword && !args.sshKeyPath) {
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: "Senha SSH ou caminho da chave deve ser fornecido", // erro: "Senha SSH ou caminho da chave deve ser fornecido",
}; // };
} // }
const sshConfig: SSHConfig = { // const sshConfig: SSHConfig = {
host: args.sshHost, // host: args.sshHost,
port: args.sshPort || 22, // port: args.sshPort || 22,
username: args.sshUsername, // username: args.sshUsername,
password: args.sshPassword || undefined, // password: args.sshPassword || undefined,
keyPath: args.sshKeyPath || undefined, // keyPath: args.sshKeyPath || undefined,
}; // };
// Tentar executar um comando simples // // Tentar executar um comando simples
const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'"); // const resultado = await executarComandoSSH(sshConfig, "echo 'SSH_OK'");
if (resultado.sucesso && resultado.output.includes("SSH_OK")) { // if (resultado.sucesso && resultado.output.includes("SSH_OK")) {
return { // return {
sucesso: true as const, // sucesso: true as const,
mensagem: "Conexão SSH estabelecida com sucesso", // mensagem: "Conexão SSH estabelecida com sucesso",
}; // };
} // }
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: resultado.erro || "Falha ao estabelecer conexão SSH", // erro: resultado.erro || "Falha ao estabelecer conexão SSH",
}; // };
} catch (error: unknown) { // } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error); // const errorMessage = error instanceof Error ? error.message : String(error);
return { // return {
sucesso: false as const, // sucesso: false as const,
erro: `Erro ao testar SSH: ${errorMessage}`, // erro: `Erro ao testar SSH: ${errorMessage}`,
}; // };
} // }
}, // },
}); // });

File diff suppressed because it is too large Load Diff

View File

@@ -295,6 +295,106 @@ const PERMISSOES_BASE = {
recurso: 'gestao_pessoas', recurso: 'gestao_pessoas',
acao: 'ver', acao: 'ver',
descricao: 'Acessar telas do módulo de gestão de pessoas' descricao: 'Acessar telas do módulo de gestão de pessoas'
},
// Setores
{
nome: 'setores.listar',
recurso: 'setores',
acao: 'listar',
descricao: 'Listar setores'
},
{
nome: 'setores.criar',
recurso: 'setores',
acao: 'criar',
descricao: 'Criar novos setores'
},
{
nome: 'setores.editar',
recurso: 'setores',
acao: 'editar',
descricao: 'Editar setores'
},
{
nome: 'setores.excluir',
recurso: 'setores',
acao: 'excluir',
descricao: 'Excluir setores'
},
// Flow Templates
{
nome: 'fluxos.templates.listar',
recurso: 'fluxos_templates',
acao: 'listar',
descricao: 'Listar templates de fluxo'
},
{
nome: 'fluxos.templates.criar',
recurso: 'fluxos_templates',
acao: 'criar',
descricao: 'Criar templates de fluxo'
},
{
nome: 'fluxos.templates.editar',
recurso: 'fluxos_templates',
acao: 'editar',
descricao: 'Editar templates de fluxo'
},
{
nome: 'fluxos.templates.excluir',
recurso: 'fluxos_templates',
acao: 'excluir',
descricao: 'Excluir templates de fluxo'
},
// Flow Instances
{
nome: 'fluxos.instancias.listar',
recurso: 'fluxos_instancias',
acao: 'listar',
descricao: 'Listar instâncias de fluxo'
},
{
nome: 'fluxos.instancias.criar',
recurso: 'fluxos_instancias',
acao: 'criar',
descricao: 'Criar instâncias de fluxo'
},
{
nome: 'fluxos.instancias.ver',
recurso: 'fluxos_instancias',
acao: 'ver',
descricao: 'Visualizar detalhes de instâncias de fluxo'
},
{
nome: 'fluxos.instancias.atualizar_status',
recurso: 'fluxos_instancias',
acao: 'atualizar_status',
descricao: 'Atualizar status de instâncias de fluxo'
},
{
nome: 'fluxos.instancias.atribuir',
recurso: 'fluxos_instancias',
acao: 'atribuir',
descricao: 'Atribuir responsáveis em instâncias de fluxo'
},
// Flow Documents
{
nome: 'fluxos.documentos.listar',
recurso: 'fluxos_documentos',
acao: 'listar',
descricao: 'Listar documentos de fluxo'
},
{
nome: 'fluxos.documentos.upload',
recurso: 'fluxos_documentos',
acao: 'upload',
descricao: 'Fazer upload de documentos em fluxos'
},
{
nome: 'fluxos.documentos.excluir',
recurso: 'fluxos_documentos',
acao: 'excluir',
descricao: 'Excluir documentos de fluxos'
} }
] ]
} as const; } as const;

View File

@@ -120,6 +120,31 @@ export const reportStatus = v.union(
v.literal("falhou") v.literal("falhou")
); );
// Status de templates de fluxo
export const flowTemplateStatus = v.union(
v.literal("draft"),
v.literal("published"),
v.literal("archived")
);
export type FlowTemplateStatus = Infer<typeof flowTemplateStatus>;
// Status de instâncias de fluxo
export const flowInstanceStatus = v.union(
v.literal("active"),
v.literal("completed"),
v.literal("cancelled")
);
export type FlowInstanceStatus = Infer<typeof flowInstanceStatus>;
// Status de passos de instância de fluxo
export const flowInstanceStepStatus = v.union(
v.literal("pending"),
v.literal("in_progress"),
v.literal("completed"),
v.literal("blocked")
);
export type FlowInstanceStepStatus = Infer<typeof flowInstanceStepStatus>;
export const situacaoContrato = v.union( export const situacaoContrato = v.union(
v.literal("em_execucao"), v.literal("em_execucao"),
v.literal("rescendido"), v.literal("rescendido"),
@@ -128,6 +153,129 @@ export const situacaoContrato = v.union(
); );
export default defineSchema({ export default defineSchema({
// Setores da organização
setores: defineTable({
nome: v.string(),
sigla: v.string(),
criadoPor: v.id("usuarios"),
createdAt: v.number(),
})
.index("by_nome", ["nome"])
.index("by_sigla", ["sigla"]),
// Relação muitos-para-muitos entre funcionários e setores
funcionarioSetores: defineTable({
funcionarioId: v.id("funcionarios"),
setorId: v.id("setores"),
createdAt: v.number(),
})
.index("by_funcionarioId", ["funcionarioId"])
.index("by_setorId", ["setorId"])
.index("by_funcionarioId_and_setorId", ["funcionarioId", "setorId"]),
// Templates de fluxo
flowTemplates: defineTable({
name: v.string(),
description: v.optional(v.string()),
status: flowTemplateStatus,
createdBy: v.id("usuarios"),
createdAt: v.number(),
})
.index("by_status", ["status"])
.index("by_createdBy", ["createdBy"]),
// Passos de template de fluxo
flowSteps: defineTable({
flowTemplateId: v.id("flowTemplates"),
name: v.string(),
description: v.optional(v.string()),
position: v.number(),
expectedDuration: v.number(), // em dias
setorId: v.id("setores"),
defaultAssigneeId: v.optional(v.id("usuarios")),
requiredDocuments: v.optional(v.array(v.string())),
})
.index("by_flowTemplateId", ["flowTemplateId"])
.index("by_flowTemplateId_and_position", ["flowTemplateId", "position"]),
// Instâncias de fluxo
flowInstances: defineTable({
flowTemplateId: v.id("flowTemplates"),
contratoId: v.optional(v.id("contratos")),
managerId: v.id("usuarios"),
status: flowInstanceStatus,
startedAt: v.number(),
finishedAt: v.optional(v.number()),
currentStepId: v.optional(v.id("flowInstanceSteps")),
})
.index("by_flowTemplateId", ["flowTemplateId"])
.index("by_contratoId", ["contratoId"])
.index("by_managerId", ["managerId"])
.index("by_status", ["status"]),
// Passos de instância de fluxo
flowInstanceSteps: defineTable({
flowInstanceId: v.id("flowInstances"),
flowStepId: v.id("flowSteps"),
setorId: v.id("setores"),
assignedToId: v.optional(v.id("usuarios")),
status: flowInstanceStepStatus,
startedAt: v.optional(v.number()),
finishedAt: v.optional(v.number()),
notes: v.optional(v.string()),
notesUpdatedBy: v.optional(v.id("usuarios")),
notesUpdatedAt: v.optional(v.number()),
dueDate: v.optional(v.number()),
})
.index("by_flowInstanceId", ["flowInstanceId"])
.index("by_flowInstanceId_and_status", ["flowInstanceId", "status"])
.index("by_setorId", ["setorId"])
.index("by_assignedToId", ["assignedToId"]),
// Documentos de instância de fluxo
flowInstanceDocuments: defineTable({
flowInstanceStepId: v.id("flowInstanceSteps"),
uploadedById: v.id("usuarios"),
storageId: v.id("_storage"),
name: v.string(),
uploadedAt: v.number(),
})
.index("by_flowInstanceStepId", ["flowInstanceStepId"])
.index("by_uploadedById", ["uploadedById"]),
// Sub-etapas de fluxo (para templates e instâncias)
flowSubSteps: defineTable({
flowStepId: v.optional(v.id("flowSteps")), // Para templates
flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), // Para instâncias
name: v.string(),
description: v.optional(v.string()),
status: v.union(
v.literal("pending"),
v.literal("in_progress"),
v.literal("completed"),
v.literal("blocked")
),
position: v.number(),
createdBy: v.id("usuarios"),
createdAt: v.number(),
})
.index("by_flowStepId", ["flowStepId"])
.index("by_flowInstanceStepId", ["flowInstanceStepId"]),
// Notas de steps e sub-etapas
flowStepNotes: defineTable({
flowStepId: v.optional(v.id("flowSteps")),
flowInstanceStepId: v.optional(v.id("flowInstanceSteps")),
flowSubStepId: v.optional(v.id("flowSubSteps")),
texto: v.string(),
criadoPor: v.id("usuarios"),
criadoEm: v.number(),
arquivos: v.array(v.id("_storage")),
})
.index("by_flowStepId", ["flowStepId"])
.index("by_flowInstanceStepId", ["flowInstanceStepId"])
.index("by_flowSubStepId", ["flowSubStepId"]),
contratos: defineTable({ contratos: defineTable({
contratadaId: v.id("empresas"), contratadaId: v.id("empresas"),
objeto: v.string(), objeto: v.string(),
@@ -897,7 +1045,8 @@ export default defineSchema({
v.literal("mencao"), v.literal("mencao"),
v.literal("grupo_criado"), v.literal("grupo_criado"),
v.literal("adicionado_grupo"), v.literal("adicionado_grupo"),
v.literal("alerta_seguranca") v.literal("alerta_seguranca"),
v.literal("etapa_fluxo_concluida")
), ),
conversaId: v.optional(v.id("conversas")), conversaId: v.optional(v.id("conversas")),
mensagemId: v.optional(v.id("mensagens")), mensagemId: v.optional(v.id("mensagens")),

View File

@@ -0,0 +1,318 @@
import { query, mutation } from './_generated/server';
import { v } from 'convex/values';
import { getCurrentUserFunction } from './auth';
/**
* Listar todos os setores
*/
export const list = query({
args: {},
returns: v.array(
v.object({
_id: v.id('setores'),
_creationTime: v.number(),
nome: v.string(),
sigla: v.string(),
criadoPor: v.id('usuarios'),
createdAt: v.number()
})
),
handler: async (ctx) => {
const setores = await ctx.db.query('setores').order('asc').collect();
return setores;
}
});
/**
* Obter um setor pelo ID
*/
export const getById = query({
args: { id: v.id('setores') },
returns: v.union(
v.object({
_id: v.id('setores'),
_creationTime: v.number(),
nome: v.string(),
sigla: v.string(),
criadoPor: v.id('usuarios'),
createdAt: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
const setor = await ctx.db.get(args.id);
return setor;
}
});
/**
* Criar um novo setor
*/
export const create = mutation({
args: {
nome: v.string(),
sigla: v.string()
},
returns: v.id('setores'),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se já existe setor com mesmo nome ou sigla
const existenteNome = await ctx.db
.query('setores')
.withIndex('by_nome', (q) => q.eq('nome', args.nome))
.first();
if (existenteNome) {
throw new Error('Já existe um setor com este nome');
}
const existenteSigla = await ctx.db
.query('setores')
.withIndex('by_sigla', (q) => q.eq('sigla', args.sigla))
.first();
if (existenteSigla) {
throw new Error('Já existe um setor com esta sigla');
}
const setorId = await ctx.db.insert('setores', {
nome: args.nome,
sigla: args.sigla.toUpperCase(),
criadoPor: usuario._id,
createdAt: Date.now()
});
return setorId;
}
});
/**
* Atualizar um setor existente
*/
export const update = mutation({
args: {
id: v.id('setores'),
nome: v.string(),
sigla: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const setor = await ctx.db.get(args.id);
if (!setor) {
throw new Error('Setor não encontrado');
}
// Verificar se já existe outro setor com mesmo nome
const existenteNome = await ctx.db
.query('setores')
.withIndex('by_nome', (q) => q.eq('nome', args.nome))
.first();
if (existenteNome && existenteNome._id !== args.id) {
throw new Error('Já existe um setor com este nome');
}
// Verificar se já existe outro setor com mesma sigla
const existenteSigla = await ctx.db
.query('setores')
.withIndex('by_sigla', (q) => q.eq('sigla', args.sigla))
.first();
if (existenteSigla && existenteSigla._id !== args.id) {
throw new Error('Já existe um setor com esta sigla');
}
await ctx.db.patch(args.id, {
nome: args.nome,
sigla: args.sigla.toUpperCase()
});
return null;
}
});
/**
* Obter funcionários de um setor específico
*/
export const getFuncionariosBySetor = query({
args: { setorId: v.id('setores') },
returns: v.array(
v.object({
_id: v.id('funcionarios'),
_creationTime: v.number(),
nome: v.string(),
matricula: v.optional(v.string()),
email: v.string(),
cpf: v.string()
})
),
handler: async (ctx, args) => {
// Buscar todas as relações funcionarioSetores para este setor
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_setorId', (q) => q.eq('setorId', args.setorId))
.collect();
// Buscar os funcionários correspondentes
const funcionarios = [];
for (const relacao of funcionarioSetores) {
const funcionario = await ctx.db.get(relacao.funcionarioId);
if (funcionario) {
funcionarios.push({
_id: funcionario._id,
_creationTime: funcionario._creationTime,
nome: funcionario.nome,
matricula: funcionario.matricula,
email: funcionario.email,
cpf: funcionario.cpf
});
}
}
return funcionarios;
}
});
/**
* Obter setores de um funcionário
*/
export const getSetoresByFuncionario = query({
args: { funcionarioId: v.id('funcionarios') },
returns: v.array(
v.object({
_id: v.id('setores'),
_creationTime: v.number(),
nome: v.string(),
sigla: v.string(),
criadoPor: v.id('usuarios'),
createdAt: v.number()
})
),
handler: async (ctx, args) => {
// Buscar todas as relações funcionarioSetores para este funcionário
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
.collect();
// Buscar os setores correspondentes
const setores = [];
for (const relacao of funcionarioSetores) {
const setor = await ctx.db.get(relacao.setorId);
if (setor) {
setores.push(setor);
}
}
return setores;
}
});
/**
* Atualizar setores de um funcionário
*/
export const atualizarSetoresFuncionario = mutation({
args: {
funcionarioId: v.id('funcionarios'),
setorIds: v.array(v.id('setores'))
},
returns: v.null(),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
// Verificar se o funcionário existe
const funcionario = await ctx.db.get(args.funcionarioId);
if (!funcionario) {
throw new Error('Funcionário não encontrado');
}
// Verificar se todos os setores existem
for (const setorId of args.setorIds) {
const setor = await ctx.db.get(setorId);
if (!setor) {
throw new Error(`Setor ${setorId} não encontrado`);
}
}
// Remover todas as relações existentes do funcionário
const funcionarioSetoresExistentes = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
.collect();
for (const relacao of funcionarioSetoresExistentes) {
await ctx.db.delete(relacao._id);
}
// Criar novas relações para os setores selecionados
const now = Date.now();
for (const setorId of args.setorIds) {
// Verificar se já existe relação (evitar duplicatas)
const existe = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId_and_setorId', (q) =>
q.eq('funcionarioId', args.funcionarioId).eq('setorId', setorId)
)
.first();
if (!existe) {
await ctx.db.insert('funcionarioSetores', {
funcionarioId: args.funcionarioId,
setorId,
createdAt: now
});
}
}
return null;
}
});
/**
* Excluir um setor
*/
export const remove = mutation({
args: { id: v.id('setores') },
returns: v.null(),
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) {
throw new Error('Usuário não autenticado');
}
const setor = await ctx.db.get(args.id);
if (!setor) {
throw new Error('Setor não encontrado');
}
// Verificar se há funcionários vinculados
const funcionariosVinculados = await ctx.db
.query('funcionarioSetores')
.withIndex('by_setorId', (q) => q.eq('setorId', args.id))
.first();
if (funcionariosVinculados) {
throw new Error('Não é possível excluir um setor com funcionários vinculados');
}
// Verificar se há passos de fluxo vinculados
const passosVinculados = await ctx.db
.query('flowSteps')
.collect();
const temPassosVinculados = passosVinculados.some((p) => p.setorId === args.id);
if (temPassosVinculados) {
throw new Error('Não é possível excluir um setor vinculado a passos de fluxo');
}
await ctx.db.delete(args.id);
return null;
}
});

View File

@@ -28,7 +28,6 @@
"@types/ssh2": "^1.15.5", "@types/ssh2": "^1.15.5",
"better-auth": "catalog:", "better-auth": "catalog:",
"convex": "catalog:", "convex": "catalog:",
"nodemailer": "^7.0.10", "nodemailer": "^7.0.10"
"ssh2": "^1.17.0"
} }
} }

View File

@@ -5,7 +5,7 @@
"build": { "build": {
"dependsOn": ["^build"], "dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"], "inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": ["dist/**"] "outputs": ["build/**"]
}, },
"lint": { "lint": {
"dependsOn": ["^lint"] "dependsOn": ["^lint"]