Compare commits
24 Commits
call-audio
...
config-sel
| Author | SHA1 | Date | |
|---|---|---|---|
| 5e7de6c943 | |||
| af21a35f05 | |||
| 277dc616b3 | |||
| 0c0c7a29c0 | |||
| be959eb230 | |||
| 86ae2a1084 | |||
| e1bd6fa61a | |||
| 75989b0546 | |||
| 08869fe5da | |||
| 71959f6553 | |||
| de694ed665 | |||
| daee99191c | |||
| 6128c20da0 | |||
| f8d9c17f63 | |||
| 409872352c | |||
| d4a3214451 | |||
| 649b9b145c | |||
| ae4fc1c4d5 | |||
| 2d7761ee94 | |||
| 9dc816977d | |||
| 3cc35d3a1e | |||
|
|
7871b87bb9 | ||
| b8a2e67f3a | |||
| ce94eb53b3 |
275
.agent/rules/convex-svelte-guidelines.md
Normal file
275
.agent/rules/convex-svelte-guidelines.md
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
---
|
||||||
|
trigger: glob
|
||||||
|
globs: **/*.svelte, **/*.ts, **/*.svelte.ts
|
||||||
|
---
|
||||||
|
|
||||||
|
# Convex + Svelte Guidelines
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
These guidelines describe how to write **Convex** backend code **and** consume it from a **Svelte** (SvelteKit) frontend. The syntax for Convex functions stays exactly the same, but the way you import and call them from the client differs from a React/Next.js project. Below you will find the adapted sections from the original Convex style guide with Svelte‑specific notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Function Syntax (Backend)
|
||||||
|
|
||||||
|
> **No change** – keep the new Convex function syntax.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import {
|
||||||
|
query,
|
||||||
|
mutation,
|
||||||
|
action,
|
||||||
|
internalQuery,
|
||||||
|
internalMutation,
|
||||||
|
internalAction
|
||||||
|
} from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const getUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.object({ name: v.string(), email: v.string() }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await ctx.db.get(args.userId);
|
||||||
|
if (!user) throw new Error('User not found');
|
||||||
|
return { name: user.name, email: user.email };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. HTTP Endpoints (Backend)
|
||||||
|
|
||||||
|
> **No change** – keep the same `convex/http.ts` file.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { httpRouter } from 'convex/server';
|
||||||
|
import { httpAction } from './_generated/server';
|
||||||
|
|
||||||
|
const http = httpRouter();
|
||||||
|
|
||||||
|
http.route({
|
||||||
|
path: '/api/echo',
|
||||||
|
method: 'POST',
|
||||||
|
handler: httpAction(async (ctx, req) => {
|
||||||
|
const body = await req.bytes();
|
||||||
|
return new Response(body, { status: 200 });
|
||||||
|
})
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Validators (Backend)
|
||||||
|
|
||||||
|
> **No change** – keep the same validators (`v.string()`, `v.id()`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Function Registration (Backend)
|
||||||
|
|
||||||
|
> **No change** – use `query`, `mutation`, `action` for public functions and `internal*` for private ones.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Function Calling from **Svelte**
|
||||||
|
|
||||||
|
### 5.1 Install the Convex client
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm i convex @convex-dev/convex-svelte
|
||||||
|
```
|
||||||
|
|
||||||
|
> The `@convex-dev/convex-svelte` package provides a thin wrapper that works with Svelte stores.
|
||||||
|
|
||||||
|
### 5.2 Initialise the client (e.g. in `src/lib/convex.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createConvexClient } from '@convex-dev/convex-svelte';
|
||||||
|
|
||||||
|
export const convex = createConvexClient({
|
||||||
|
url: import.meta.env.VITE_CONVEX_URL // set in .env
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 Using queries in a component
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { convex } from '$lib/convex';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { api } from '../convex/_generated/api';
|
||||||
|
|
||||||
|
let user: { name: string; email: string } | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
user = await convex.query(api.users.getUser, { userId: 'some-id' });
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Loading…</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{:else if user}
|
||||||
|
<h2>{user.name}</h2>
|
||||||
|
<p>{user.email}</p>
|
||||||
|
{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 Using mutations in a component
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { convex } from '$lib/convex';
|
||||||
|
import { api } from '../convex/_generated/api';
|
||||||
|
let name = '';
|
||||||
|
let creating = false;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
async function createUser() {
|
||||||
|
creating = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const userId = await convex.mutation(api.users.createUser, { name });
|
||||||
|
console.log('Created user', userId);
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input bind:value={name} placeholder="Name" />
|
||||||
|
<button on:click={createUser} disabled={creating}>Create</button>
|
||||||
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 Using **actions** (Node‑only) from Svelte
|
||||||
|
|
||||||
|
Actions run in a Node environment, so they cannot be called directly from the browser. Use a **mutation** that internally calls the action, or expose a HTTP endpoint that triggers the action.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Scheduler / Cron (Backend)
|
||||||
|
|
||||||
|
> Same as original guide – define `crons.ts` and export the default `crons` object.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. File Storage (Backend)
|
||||||
|
|
||||||
|
> Same as original guide – use `ctx.storage.getUrl()` and query `_storage` for metadata.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. TypeScript Helpers (Backend)
|
||||||
|
|
||||||
|
> Keep using `Id<'table'>` from `./_generated/dataModel`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Svelte‑Specific Tips
|
||||||
|
|
||||||
|
| Topic | Recommendation |
|
||||||
|
| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Store‑based data** | If you need reactive data across many components, wrap `convex.query` in a Svelte store (`readable`, `writable`). |
|
||||||
|
| **Error handling** | Use `try / catch` around every client call; surface the error in the UI. |
|
||||||
|
| **SSR / SvelteKit** | Calls made in `load` functions run on the server; you can use `convex.query` there without worrying about the browser environment. |
|
||||||
|
| **Environment variables** | Prefix with `VITE_` for client‑side access (`import.meta.env.VITE_CONVEX_URL`). |
|
||||||
|
| **Testing** | Use the Convex mock client (`createMockConvexClient`) provided by `@convex-dev/convex-svelte` for unit tests. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Full Example (SvelteKit + Convex)
|
||||||
|
|
||||||
|
### 10.1 Backend (`convex/users.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const createUser = mutation({
|
||||||
|
args: { name: v.string() },
|
||||||
|
returns: v.id('users'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.insert('users', { name: args.name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getUser = query({
|
||||||
|
args: { userId: v.id('users') },
|
||||||
|
returns: v.object({ name: v.string() }),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await ctx.db.get(args.userId);
|
||||||
|
if (!user) throw new Error('Not found');
|
||||||
|
return { name: user.name };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.2 Frontend (`src/routes/+page.svelte`)
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
import { convex } from '$lib/convex';
|
||||||
|
import { api } from '$lib/convex/_generated/api';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
|
let name = '';
|
||||||
|
let createdId: string | null = null;
|
||||||
|
let loading = false;
|
||||||
|
let error: string | null = null;
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
loading = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
createdId = await convex.mutation(api.users.createUser, { name });
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input bind:value={name} placeholder="Your name" />
|
||||||
|
<button on:click={create} disabled={loading}>Create user</button>
|
||||||
|
{#if createdId}<p>Created user id: {createdId}</p>{/if}
|
||||||
|
{#if error}<p class="error">{error}</p>{/if}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Checklist for New Files
|
||||||
|
|
||||||
|
- ✅ All Convex functions use the **new syntax** (`query({ … })`).
|
||||||
|
- ✅ Every public function has **argument** and **return** validators.
|
||||||
|
- ✅ Svelte components import the generated `api` object from `convex/_generated/api`.
|
||||||
|
- ✅ All client calls use the `convex` instance from `$lib/convex`.
|
||||||
|
- ✅ Environment variable `VITE_CONVEX_URL` is defined in `.env`.
|
||||||
|
- ✅ Errors are caught and displayed in the UI.
|
||||||
|
- ✅ Types are imported from `convex/_generated/dataModel` when needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. References
|
||||||
|
|
||||||
|
- Convex Docs – [Functions](https://docs.convex.dev/functions)
|
||||||
|
- Convex Svelte SDK – [`@convex-dev/convex-svelte`](https://github.com/convex-dev/convex-svelte)
|
||||||
|
- SvelteKit Docs – [Loading Data](https://kit.svelte.dev/docs/loading)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Keep these guidelines alongside the existing `svelte-rules.md` so that contributors have a single source of truth for both frontend and backend conventions._
|
||||||
28
.agent/rules/svelte-rules.md
Normal file
28
.agent/rules/svelte-rules.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
trigger: glob
|
||||||
|
globs: **/*.svelte.ts,**/*.svelte
|
||||||
|
---
|
||||||
|
|
||||||
|
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||||
|
|
||||||
|
## Available MCP Tools:
|
||||||
|
|
||||||
|
### 1. list-sections
|
||||||
|
|
||||||
|
Use this FIRST to discover all available documentation sections. Returns a structured list with titles, use_cases, and paths.
|
||||||
|
When asked about Svelte or SvelteKit topics, ALWAYS use this tool at the start of the chat to find relevant sections.
|
||||||
|
|
||||||
|
### 2. get-documentation
|
||||||
|
|
||||||
|
Retrieves full documentation content for specific sections. Accepts single or multiple sections.
|
||||||
|
After calling the list-sections tool, you MUST analyze the returned documentation sections (especially the use_cases field) and then use the get-documentation tool to fetch ALL documentation sections that are relevant for the user's task.
|
||||||
|
|
||||||
|
### 3. svelte-autofixer
|
||||||
|
|
||||||
|
Analyzes Svelte code and returns issues and suggestions.
|
||||||
|
You MUST use this tool whenever writing Svelte code before sending it to the user. Keep calling it until no issues or suggestions are returned.
|
||||||
|
|
||||||
|
### 4. playground-link
|
||||||
|
|
||||||
|
Generates a Svelte Playground link with the provided code.
|
||||||
|
After completing the code, ask the user if they want a playground link. Only call this tool after user confirmation and NEVER if code was written to files in their project.
|
||||||
1
.dockerignore
Normal file
1
.dockerignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
37
.github/workflows/deploy.yml
vendored
Normal file
37
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Build Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["master"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push-dockerfile-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
|
||||||
|
- name: Log in to Docker Hub
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKERHUB_USERNAME }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
|
||||||
|
password: ${{ secrets.DOCKERHUB_TOKEN }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./apps/web/Dockerfile
|
||||||
|
push: true
|
||||||
|
# Make sure to replace with your own namespace and repository
|
||||||
|
tags: |
|
||||||
|
killercf/sgc:latest
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
PUBLIC_CONVEX_URL=${{ secrets.PUBLIC_CONVEX_URL }}
|
||||||
|
PUBLIC_CONVEX_SITE_URL=${{ secrets.PUBLIC_CONVEX_SITE_URL }}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -49,3 +49,5 @@ coverage
|
|||||||
tmp
|
tmp
|
||||||
temp
|
temp
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
|
out
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
# Correções Implementadas para Integração Jitsi
|
|
||||||
|
|
||||||
## Resumo das Alterações
|
|
||||||
|
|
||||||
Este documento descreve todas as correções implementadas para integrar o servidor Jitsi ao projeto SGSE e fazer as chamadas de áudio e vídeo funcionarem corretamente.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Configuração do JitsiConnection
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- A configuração do `serviceUrl` e `muc` estava incorreta para Docker Jitsi local
|
|
||||||
- O domínio incluía a porta, causando problemas na conexão
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
// Separar host e porta corretamente
|
|
||||||
const { host, porta } = obterHostEPorta(config.domain);
|
|
||||||
const protocol = config.useHttps ? 'https' : 'http';
|
|
||||||
|
|
||||||
const options = {
|
|
||||||
hosts: {
|
|
||||||
domain: host, // Apenas o host (sem porta)
|
|
||||||
muc: `conference.${host}` // MUC no mesmo domínio
|
|
||||||
},
|
|
||||||
serviceUrl: `${protocol}://${host}:${porta}/http-bind`, // BOSH com porta
|
|
||||||
bosh: `${protocol}://${host}:${porta}/http-bind`, // BOSH alternativo
|
|
||||||
clientNode: config.appId
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
**Arquivo criado/atualizado:**
|
|
||||||
- `apps/web/src/lib/utils/jitsi.ts` - Adicionada função `obterHostEPorta()`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Criação de Tracks Locais
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Os tracks locais não estavam sendo criados após entrar na conferência
|
|
||||||
- Faltava o evento `CONFERENCE_JOINED` para criar tracks locais
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
conference.on(JitsiMeetJS.constants.events.conference.CONFERENCE_JOINED, async () => {
|
|
||||||
// Criar tracks locais com constraints apropriadas
|
|
||||||
const constraints = {
|
|
||||||
audio: estadoAtual.audioHabilitado ? {
|
|
||||||
echoCancellation: true,
|
|
||||||
noiseSuppression: true,
|
|
||||||
autoGainControl: true
|
|
||||||
} : false,
|
|
||||||
video: estadoAtual.videoHabilitado ? {
|
|
||||||
facingMode: 'user',
|
|
||||||
width: { ideal: 1280 },
|
|
||||||
height: { ideal: 720 }
|
|
||||||
} : false
|
|
||||||
};
|
|
||||||
|
|
||||||
const tracks = await JitsiMeetJS.createLocalTracks(constraints, {
|
|
||||||
devices: [],
|
|
||||||
cameraDeviceId: estadoChamada.dispositivos.cameraId || undefined,
|
|
||||||
micDeviceId: estadoChamada.dispositivos.microphoneId || undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
// Adicionar tracks à conferência e anexar ao vídeo local
|
|
||||||
for (const track of tracks) {
|
|
||||||
await conference.addTrack(track);
|
|
||||||
if (track.getType() === 'video' && localVideo) {
|
|
||||||
track.attach(localVideo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Gerenciamento de Tracks
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Tracks locais não eram armazenados corretamente
|
|
||||||
- Falta de limpeza adequada ao finalizar chamada
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
- Adicionada variável de estado `localTracks: JitsiTrack[]` para rastrear todos os tracks locais
|
|
||||||
- Implementada limpeza adequada no método `finalizar()`:
|
|
||||||
- Desconectar tracks antes de liberar
|
|
||||||
- Dispor de todos os tracks locais
|
|
||||||
- Limpar referências
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. Attach/Detach de Tracks Remotos
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Tracks remotos não eram anexados corretamente aos elementos de vídeo/áudio
|
|
||||||
- Não havia tratamento específico para áudio vs vídeo
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
function adicionarTrackRemoto(track: JitsiTrack): void {
|
|
||||||
const participantId = track.getParticipantId();
|
|
||||||
const trackType = track.getType();
|
|
||||||
|
|
||||||
if (trackType === 'audio') {
|
|
||||||
// Criar elemento de áudio invisível
|
|
||||||
const audioElement = document.createElement('audio');
|
|
||||||
audioElement.id = `remote-audio-${participantId}`;
|
|
||||||
audioElement.autoplay = true;
|
|
||||||
track.attach(audioElement);
|
|
||||||
videoContainer.appendChild(audioElement);
|
|
||||||
} else if (trackType === 'video') {
|
|
||||||
// Criar elemento de vídeo
|
|
||||||
const videoElement = document.createElement('video');
|
|
||||||
videoElement.id = `remote-video-${participantId}`;
|
|
||||||
videoElement.autoplay = true;
|
|
||||||
track.attach(videoElement);
|
|
||||||
videoContainer.appendChild(videoElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Controles de Áudio e Vídeo
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Os métodos `handleToggleAudio` e `handleToggleVideo` não criavam novos tracks quando necessário
|
|
||||||
- Não atualizavam corretamente o estado dos tracks locais
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
- Implementada lógica para criar tracks se não existirem
|
|
||||||
- Atualização correta do estado dos tracks (mute/unmute)
|
|
||||||
- Sincronização com o backend quando anfitrião
|
|
||||||
- Anexar/desanexar tracks ao vídeo local corretamente
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. Tratamento de Erros
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Uso de `alert()` para erros (não amigável)
|
|
||||||
- Falta de mensagens de erro claras
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
- Implementado sistema de tratamento de erros com `ErrorModal`
|
|
||||||
- Integrado com `traduzirErro()` para mensagens amigáveis
|
|
||||||
- Adicionado estado de erro no componente:
|
|
||||||
```typescript
|
|
||||||
let showErrorModal = $state(false);
|
|
||||||
let errorTitle = $state('Erro na Chamada');
|
|
||||||
let errorMessage = $state('');
|
|
||||||
let errorDetails = $state<string | undefined>(undefined);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivos modificados:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
- Integração com `apps/web/src/lib/utils/erroHelpers.ts`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. Inicialização do Jitsi Meet JS
|
|
||||||
|
|
||||||
### Problema Identificado
|
|
||||||
- Configuração básica do Jitsi pode estar incompleta
|
|
||||||
- Nível de log muito restritivo
|
|
||||||
|
|
||||||
### Correção Implementada
|
|
||||||
```typescript
|
|
||||||
JitsiMeetJS.init({
|
|
||||||
disableAudioLevels: false, // Habilitado para melhor qualidade
|
|
||||||
disableSimulcast: false,
|
|
||||||
enableWindowOnErrorHandler: true,
|
|
||||||
enableRemb: true, // REMB para controle de bitrate
|
|
||||||
enableTcc: true, // TCC para controle de congestionamento
|
|
||||||
disableThirdPartyRequests: false
|
|
||||||
});
|
|
||||||
|
|
||||||
JitsiMeetJS.setLogLevel(JitsiMeetJS.constants.logLevels.INFO); // Mais verboso para debug
|
|
||||||
```
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. UI/UX Melhorias
|
|
||||||
|
|
||||||
### Implementado
|
|
||||||
- Indicador de conexão durante estabelecimento da chamada
|
|
||||||
- Mensagem de "Conectando..." enquanto não há conexão estabelecida
|
|
||||||
- Tratamento visual adequado de estados de conexão
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 9. Eventos da Conferência
|
|
||||||
|
|
||||||
### Adicionado
|
|
||||||
- `CONFERENCE_JOINED`: Criar tracks locais após entrar
|
|
||||||
- `CONFERENCE_LEFT`: Limpar tracks ao sair
|
|
||||||
- Melhor tratamento de `TRACK_ADDED` e `TRACK_REMOVED`
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 10. Correção de Interfaces TypeScript
|
|
||||||
|
|
||||||
### Adicionado
|
|
||||||
- Método `addTrack()` na interface `JitsiConference`
|
|
||||||
- Melhor tipagem de `JitsiTrack` com propriedade `track: MediaStreamTrack`
|
|
||||||
|
|
||||||
**Arquivo modificado:**
|
|
||||||
- `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Configuração Necessária
|
|
||||||
|
|
||||||
### Variáveis de Ambiente (.env)
|
|
||||||
```env
|
|
||||||
# Jitsi Meet Configuration (Docker Local)
|
|
||||||
VITE_JITSI_DOMAIN=localhost:8443
|
|
||||||
VITE_JITSI_APP_ID=sgse-app
|
|
||||||
VITE_JITSI_ROOM_PREFIX=sgse
|
|
||||||
VITE_JITSI_USE_HTTPS=true
|
|
||||||
```
|
|
||||||
|
|
||||||
**Nota:** Para Docker Jitsi local, geralmente usa-se HTTPS na porta 8443.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Verificações Necessárias
|
|
||||||
|
|
||||||
### 1. Docker Jitsi Rodando
|
|
||||||
```bash
|
|
||||||
docker ps | grep jitsi
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Porta 8443 Acessível
|
|
||||||
```bash
|
|
||||||
curl -k https://localhost:8443
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Permissões do Navegador
|
|
||||||
- Microfone deve estar permitido
|
|
||||||
- Câmera deve estar permitida (para chamadas de vídeo)
|
|
||||||
|
|
||||||
### 4. Logs do Navegador
|
|
||||||
- Abrir DevTools (F12)
|
|
||||||
- Verificar Console para erros de conexão
|
|
||||||
- Verificar Network para erros de rede
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Próximos Passos (Se Necessário)
|
|
||||||
|
|
||||||
1. **Testar conectividade** - Verificar se o servidor Jitsi responde corretamente
|
|
||||||
2. **Ajustar configuração de rede** - Se houver problemas de firewall ou CORS
|
|
||||||
3. **Configurar STUN/TURN** - Para conexões através de NAT (se necessário)
|
|
||||||
4. **Otimizar qualidade** - Ajustar bitrates e resoluções conforme necessário
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Status
|
|
||||||
|
|
||||||
✅ **Todas as correções foram implementadas**
|
|
||||||
✅ **Código sem erros de lint**
|
|
||||||
✅ **Tratamento de erros adequado**
|
|
||||||
✅ **Interfaces TypeScript corretas**
|
|
||||||
✅ **Gerenciamento de recursos adequado**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Data:** $(date)
|
|
||||||
**Versão:** 1.0.0
|
|
||||||
|
|
||||||
@@ -1,701 +0,0 @@
|
|||||||
# Plano de Implementação - Chamadas de Áudio e Vídeo com Jitsi Meet
|
|
||||||
|
|
||||||
## Opção Escolhida: Docker Local (Desenvolvimento)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Etapas Fora do Código - Configuração Docker
|
|
||||||
|
|
||||||
### Etapa 1: Preparar Ambiente Docker
|
|
||||||
|
|
||||||
**Requisitos:**
|
|
||||||
|
|
||||||
- Docker Desktop instalado e rodando
|
|
||||||
- Mínimo 4GB RAM disponível
|
|
||||||
- Portas livres: 8000, 8443, 10000-20000/udp
|
|
||||||
|
|
||||||
**Passos:**
|
|
||||||
|
|
||||||
1. **Criar diretório para configuração Docker Jitsi:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/jitsi-docker
|
|
||||||
cd ~/jitsi-docker
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Clonar repositório oficial:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/jitsi/docker-jitsi-meet.git
|
|
||||||
cd docker-jitsi-meet
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Configurar variáveis de ambiente:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cp env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Editar arquivo `.env` com as seguintes configurações:**
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Configuração básica para desenvolvimento local
|
|
||||||
CONFIG=~/.jitsi-meet-cfg
|
|
||||||
TZ=America/Recife
|
|
||||||
|
|
||||||
# Desabilitar Let's Encrypt (não necessário para localhost)
|
|
||||||
ENABLE_LETSENCRYPT=0
|
|
||||||
|
|
||||||
# Portas HTTP/HTTPS
|
|
||||||
HTTP_PORT=8000
|
|
||||||
HTTPS_PORT=8443
|
|
||||||
|
|
||||||
# Domínio local
|
|
||||||
PUBLIC_URL=http://localhost:8000
|
|
||||||
DOMAIN=localhost
|
|
||||||
|
|
||||||
# Desabilitar autenticação para facilitar testes
|
|
||||||
ENABLE_AUTH=0
|
|
||||||
ENABLE_GUESTS=1
|
|
||||||
|
|
||||||
# Desabilitar transcrissão (não necessário para desenvolvimento)
|
|
||||||
ENABLE_TRANSCRIPTION=0
|
|
||||||
|
|
||||||
# Desabilitar gravação no servidor (usaremos gravação local)
|
|
||||||
ENABLE_RECORDING=0
|
|
||||||
|
|
||||||
# Configurações de vídeo (ajustar conforme necessidade)
|
|
||||||
ENABLE_PREJOIN_PAGE=0
|
|
||||||
START_AUDIO_MUTED=0
|
|
||||||
START_VIDEO_MUTED=0
|
|
||||||
|
|
||||||
# Configurações de segurança
|
|
||||||
ENABLE_XMPP_WEBSOCKET=0
|
|
||||||
ENABLE_P2P=1
|
|
||||||
|
|
||||||
# Limites
|
|
||||||
MAX_NUMBER_OF_PARTICIPANTS=10
|
|
||||||
RESOLUTION_WIDTH=1280
|
|
||||||
RESOLUTION_HEIGHT=720
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Criar diretórios necessários:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p ~/.jitsi-meet-cfg/{web/letsencrypt,transcripts,prosody/config,prosody/prosody-plugins-custom,jicofo,jvb}
|
|
||||||
```
|
|
||||||
|
|
||||||
6. **Iniciar containers:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Verificar status:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose ps
|
|
||||||
```
|
|
||||||
|
|
||||||
8. **Ver logs se necessário:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker-compose logs -f
|
|
||||||
```
|
|
||||||
|
|
||||||
9. **Testar acesso:**
|
|
||||||
|
|
||||||
- Acessar: http://localhost:8000
|
|
||||||
- Criar uma sala de teste e verificar se funciona
|
|
||||||
|
|
||||||
**Troubleshooting:**
|
|
||||||
|
|
||||||
- Se houver erro de permissão nos diretórios: `sudo chown -R $USER:$USER ~/.jitsi-meet-cfg`
|
|
||||||
- Se portas estiverem em uso, alterar HTTP_PORT e HTTPS_PORT no .env
|
|
||||||
- Para parar: `docker-compose down`
|
|
||||||
- Para reiniciar: `docker-compose restart`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 Etapas no Código - Backend Convex
|
|
||||||
|
|
||||||
### Etapa 2: Atualizar Schema
|
|
||||||
|
|
||||||
**Arquivo:** `packages/backend/convex/schema.ts`
|
|
||||||
|
|
||||||
**Adicionar nova tabela `chamadas`:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
chamadas: defineTable({
|
|
||||||
conversaId: v.id('conversas'),
|
|
||||||
tipo: v.union(v.literal('audio'), v.literal('video')),
|
|
||||||
roomName: v.string(), // Nome único da sala Jitsi
|
|
||||||
criadoPor: v.id('usuarios'), // Anfitrião/criador
|
|
||||||
participantes: v.array(v.id('usuarios')),
|
|
||||||
status: v.union(
|
|
||||||
v.literal('aguardando'),
|
|
||||||
v.literal('em_andamento'),
|
|
||||||
v.literal('finalizada'),
|
|
||||||
v.literal('cancelada')
|
|
||||||
),
|
|
||||||
iniciadaEm: v.optional(v.number()),
|
|
||||||
finalizadaEm: v.optional(v.number()),
|
|
||||||
duracaoSegundos: v.optional(v.number()),
|
|
||||||
gravando: v.boolean(),
|
|
||||||
gravacaoIniciadaPor: v.optional(v.id('usuarios')),
|
|
||||||
gravacaoIniciadaEm: v.optional(v.number()),
|
|
||||||
gravacaoFinalizadaEm: v.optional(v.number()),
|
|
||||||
configuracoes: v.optional(
|
|
||||||
v.object({
|
|
||||||
audioHabilitado: v.boolean(),
|
|
||||||
videoHabilitado: v.boolean(),
|
|
||||||
participantesConfig: v.optional(
|
|
||||||
v.array(
|
|
||||||
v.object({
|
|
||||||
usuarioId: v.id('usuarios'),
|
|
||||||
audioHabilitado: v.boolean(),
|
|
||||||
videoHabilitado: v.boolean(),
|
|
||||||
forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
),
|
|
||||||
criadoEm: v.number()
|
|
||||||
})
|
|
||||||
.index('by_conversa', ['conversaId', 'status'])
|
|
||||||
.index('by_conversa_ativa', ['conversaId', 'status'])
|
|
||||||
.index('by_criado_por', ['criadoPor'])
|
|
||||||
.index('by_status', ['status'])
|
|
||||||
.index('by_room_name', ['roomName']);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 3: Criar Backend de Chamadas
|
|
||||||
|
|
||||||
**Arquivo:** `packages/backend/convex/chamadas.ts`
|
|
||||||
|
|
||||||
**Funções a implementar:**
|
|
||||||
|
|
||||||
#### Mutations:
|
|
||||||
|
|
||||||
1. `criarChamada` - Criar nova chamada
|
|
||||||
2. `iniciarChamada` - Marcar como em andamento
|
|
||||||
3. `finalizarChamada` - Finalizar e calcular duração
|
|
||||||
4. `adicionarParticipante` - Adicionar participante
|
|
||||||
5. `removerParticipante` - Remover participante
|
|
||||||
6. `toggleAudioVideo` - Anfitrião controla áudio/vídeo de participante
|
|
||||||
7. `atualizarConfiguracaoParticipante` - Atualizar configuração individual
|
|
||||||
8. `iniciarGravacao` - Marcar início de gravação
|
|
||||||
9. `finalizarGravacao` - Marcar fim de gravação
|
|
||||||
|
|
||||||
#### Queries:
|
|
||||||
|
|
||||||
1. `obterChamadaAtiva` - Buscar chamada ativa de uma conversa
|
|
||||||
2. `listarChamadas` - Listar histórico
|
|
||||||
3. `verificarAnfitriao` - Verificar se usuário é anfitrião
|
|
||||||
4. `obterParticipantesChamada` - Listar participantes
|
|
||||||
|
|
||||||
**Tipos TypeScript (sem usar `any`):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type { Id } from './_generated/dataModel';
|
|
||||||
import type { QueryCtx, MutationCtx } from './_generated/server';
|
|
||||||
|
|
||||||
type ChamadaTipo = 'audio' | 'video';
|
|
||||||
type ChamadaStatus = 'aguardando' | 'em_andamento' | 'finalizada' | 'cancelada';
|
|
||||||
|
|
||||||
interface ParticipanteConfig {
|
|
||||||
usuarioId: Id<'usuarios'>;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
forcadoPeloAnfitriao?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfiguracoesChamada {
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
participantesConfig?: ParticipanteConfig[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Etapas no Código - Frontend Svelte
|
|
||||||
|
|
||||||
### Etapa 4: Instalar Dependências
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/package.json`
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd apps/web
|
|
||||||
bun add lib-jitsi-meet
|
|
||||||
```
|
|
||||||
|
|
||||||
**Dependências adicionais necessárias:**
|
|
||||||
|
|
||||||
- `lib-jitsi-meet` - Biblioteca oficial Jitsi
|
|
||||||
- (Possivelmente tipos) `@types/lib-jitsi-meet` se disponível
|
|
||||||
|
|
||||||
### Etapa 5: Configurar Variáveis de Ambiente
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/.env`
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Jitsi Meet Configuration (Docker Local)
|
|
||||||
VITE_JITSI_DOMAIN=localhost:8443
|
|
||||||
VITE_JITSI_APP_ID=sgse-app
|
|
||||||
VITE_JITSI_ROOM_PREFIX=sgse
|
|
||||||
VITE_JITSI_USE_HTTPS=false
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 6: Criar Utilitários Jitsi
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/utils/jitsi.ts`
|
|
||||||
|
|
||||||
**Funções:**
|
|
||||||
|
|
||||||
- `gerarRoomName(conversaId: string, tipo: "audio" | "video"): string` - Gerar nome único da sala
|
|
||||||
- `obterConfiguracaoJitsi()` - Retornar configuração do Jitsi baseada em .env
|
|
||||||
- `validarDispositivos()` - Validar disponibilidade de microfone/webcam
|
|
||||||
- `obterDispositivosDisponiveis()` - Listar dispositivos de mídia
|
|
||||||
|
|
||||||
**Tipos (sem `any`):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface ConfiguracaoJitsi {
|
|
||||||
domain: string;
|
|
||||||
appId: string;
|
|
||||||
roomPrefix: string;
|
|
||||||
useHttps: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispositivoMedia {
|
|
||||||
deviceId: string;
|
|
||||||
label: string;
|
|
||||||
kind: 'audioinput' | 'audiooutput' | 'videoinput';
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DispositivosDisponiveis {
|
|
||||||
microphones: DispositivoMedia[];
|
|
||||||
speakers: DispositivoMedia[];
|
|
||||||
cameras: DispositivoMedia[];
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 7: Criar Store de Chamadas
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/stores/callStore.ts`
|
|
||||||
|
|
||||||
**Estado gerenciado:**
|
|
||||||
|
|
||||||
- Chamada ativa (se houver)
|
|
||||||
- Estado de mídia (áudio/vídeo ligado/desligado)
|
|
||||||
- Dispositivos selecionados
|
|
||||||
- Status de gravação
|
|
||||||
- Lista de participantes
|
|
||||||
- Duração da chamada
|
|
||||||
- É anfitrião ou não
|
|
||||||
|
|
||||||
**Tipos:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface EstadoChamada {
|
|
||||||
chamadaId: Id<'chamadas'> | null;
|
|
||||||
conversaId: Id<'conversas'> | null;
|
|
||||||
tipo: 'audio' | 'video' | null;
|
|
||||||
roomName: string | null;
|
|
||||||
estaConectado: boolean;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
gravando: boolean;
|
|
||||||
ehAnfitriao: boolean;
|
|
||||||
participantes: Array<{
|
|
||||||
usuarioId: Id<'usuarios'>;
|
|
||||||
nome: string;
|
|
||||||
avatar?: string;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
}>;
|
|
||||||
duracaoSegundos: number;
|
|
||||||
dispositivos: {
|
|
||||||
microphoneId: string | null;
|
|
||||||
cameraId: string | null;
|
|
||||||
speakerId: string | null;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
interface EventosChamada {
|
|
||||||
'participant-joined': (participant: ParticipanteJitsi) => void;
|
|
||||||
'participant-left': (participantId: string) => void;
|
|
||||||
'audio-mute-status-changed': (isMuted: boolean) => void;
|
|
||||||
'video-mute-status-changed': (isMuted: boolean) => void;
|
|
||||||
'connection-failed': (error: Error) => void;
|
|
||||||
'connection-disconnected': () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Métodos principais:**
|
|
||||||
|
|
||||||
- `iniciarChamada(conversaId, tipo)`
|
|
||||||
- `finalizarChamada()`
|
|
||||||
- `toggleAudio()`
|
|
||||||
- `toggleVideo()`
|
|
||||||
- `iniciarGravacao()`
|
|
||||||
- `finalizarGravacao()`
|
|
||||||
- `atualizarDispositivos()`
|
|
||||||
|
|
||||||
### Etapa 8: Criar Utilitários de Gravação
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/utils/mediaRecorder.ts`
|
|
||||||
|
|
||||||
**Funções:**
|
|
||||||
|
|
||||||
- `iniciarGravacaoAudio(stream: MediaStream): MediaRecorder` - Gravar apenas áudio
|
|
||||||
- `iniciarGravacaoVideo(stream: MediaStream): MediaRecorder` - Gravar áudio + vídeo
|
|
||||||
- `pararGravacao(recorder: MediaRecorder): Promise<Blob>` - Parar e retornar blob
|
|
||||||
- `salvarGravacao(blob: Blob, nomeArquivo: string): void` - Salvar localmente
|
|
||||||
- `obterDuracaoGravacao(recorder: MediaRecorder): number` - Obter duração
|
|
||||||
|
|
||||||
**Tipos:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface OpcoesGravacao {
|
|
||||||
audioBitsPerSecond?: number;
|
|
||||||
videoBitsPerSecond?: number;
|
|
||||||
mimeType?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ResultadoGravacao {
|
|
||||||
blob: Blob;
|
|
||||||
duracaoSegundos: number;
|
|
||||||
nomeArquivo: string;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 9: Criar Componente CallWindow
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/CallWindow.svelte`
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
|
|
||||||
- Janela flutuante redimensionável e arrastável
|
|
||||||
- Integração com lib-jitsi-meet
|
|
||||||
- Container para vídeo dos participantes
|
|
||||||
- Barra de controles
|
|
||||||
- Indicador de gravação
|
|
||||||
- Contador de duração
|
|
||||||
|
|
||||||
**Props (TypeScript estrito):**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
chamadaId: Id<'chamadas'>;
|
|
||||||
conversaId: Id<'conversas'>;
|
|
||||||
tipo: 'audio' | 'video';
|
|
||||||
roomName: string;
|
|
||||||
ehAnfitriao: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Estrutura:**
|
|
||||||
|
|
||||||
- `<script lang="ts">` com tipos explícitos
|
|
||||||
- Uso de `$state`, `$derived`, `$effect` (Svelte 5)
|
|
||||||
- Integração com `callStore`
|
|
||||||
- Eventos do Jitsi tratados tipados
|
|
||||||
|
|
||||||
**Bibliotecas para janela flutuante:**
|
|
||||||
|
|
||||||
- Usar eventos nativos de mouse/touch para drag
|
|
||||||
- CSS para redimensionamento com handles
|
|
||||||
- localStorage para persistir posição/tamanho
|
|
||||||
|
|
||||||
### Etapa 10: Criar Componente CallControls
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/CallControls.svelte`
|
|
||||||
|
|
||||||
**Controles:**
|
|
||||||
|
|
||||||
- Botão toggle áudio
|
|
||||||
- Botão toggle vídeo
|
|
||||||
- Botão gravação (se anfitrião)
|
|
||||||
- Botão configurações
|
|
||||||
- Botão encerrar chamada
|
|
||||||
- Contador de duração (HH:MM:SS)
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
gravando: boolean;
|
|
||||||
ehAnfitriao: boolean;
|
|
||||||
duracaoSegundos: number;
|
|
||||||
onToggleAudio: () => void;
|
|
||||||
onToggleVideo: () => void;
|
|
||||||
onIniciarGravacao: () => void;
|
|
||||||
onPararGravacao: () => void;
|
|
||||||
onAbrirConfiguracoes: () => void;
|
|
||||||
onEncerrar: () => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 11: Criar Componente CallSettings
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/CallSettings.svelte`
|
|
||||||
|
|
||||||
**Funcionalidades:**
|
|
||||||
|
|
||||||
- Listar microfones disponíveis
|
|
||||||
- Listar webcams disponíveis
|
|
||||||
- Listar alto-falantes disponíveis
|
|
||||||
- Preview de vídeo antes de aplicar
|
|
||||||
- Teste de áudio
|
|
||||||
- Botões aplicar/cancelar
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
open: boolean;
|
|
||||||
dispositivoAtual: {
|
|
||||||
microphoneId: string | null;
|
|
||||||
cameraId: string | null;
|
|
||||||
speakerId: string | null;
|
|
||||||
};
|
|
||||||
onClose: () => void;
|
|
||||||
onAplicar: (dispositivos: {
|
|
||||||
microphoneId: string | null;
|
|
||||||
cameraId: string | null;
|
|
||||||
speakerId: string | null;
|
|
||||||
}) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 12: Criar Componente HostControls
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/HostControls.svelte`
|
|
||||||
|
|
||||||
**Funcionalidades (apenas para anfitrião):**
|
|
||||||
|
|
||||||
- Lista de participantes
|
|
||||||
- Toggle áudio por participante
|
|
||||||
- Toggle vídeo por participante
|
|
||||||
- Indicador visual de quem está gravando
|
|
||||||
- Status de cada participante
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
participantes: Array<{
|
|
||||||
usuarioId: Id<'usuarios'>;
|
|
||||||
nome: string;
|
|
||||||
avatar?: string;
|
|
||||||
audioHabilitado: boolean;
|
|
||||||
videoHabilitado: boolean;
|
|
||||||
forcadoPeloAnfitriao?: boolean;
|
|
||||||
}>;
|
|
||||||
onToggleParticipanteAudio: (usuarioId: Id<'usuarios'>) => void;
|
|
||||||
onToggleParticipanteVideo: (usuarioId: Id<'usuarios'>) => void;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 13: Criar Componente RecordingIndicator
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/call/RecordingIndicator.svelte`
|
|
||||||
|
|
||||||
**Características:**
|
|
||||||
|
|
||||||
- Banner visível no topo da janela
|
|
||||||
- Ícone animado de gravação
|
|
||||||
- Mensagem clara de que está gravando
|
|
||||||
- Informação de quem iniciou (se disponível)
|
|
||||||
|
|
||||||
**Props:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface Props {
|
|
||||||
gravando: boolean;
|
|
||||||
iniciadoPor?: string; // Nome do usuário que iniciou
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 14: Criar Utilitário de Janela Flutuante
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/utils/floatingWindow.ts`
|
|
||||||
|
|
||||||
**Funções:**
|
|
||||||
|
|
||||||
- `criarDragHandler(element: HTMLElement, handle: HTMLElement): () => void` - Criar handler de arrastar
|
|
||||||
- `criarResizeHandler(element: HTMLElement, handles: HTMLElement[]): () => void` - Criar handler de redimensionar
|
|
||||||
- `salvarPosicaoJanela(id: string, posicao: { x: number; y: number; width: number; height: number }): void` - Salvar no localStorage
|
|
||||||
- `restaurarPosicaoJanela(id: string): { x: number; y: number; width: number; height: number } | null` - Restaurar do localStorage
|
|
||||||
|
|
||||||
**Tipos:**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
interface PosicaoJanela {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LimitesJanela {
|
|
||||||
minWidth: number;
|
|
||||||
minHeight: number;
|
|
||||||
maxWidth?: number;
|
|
||||||
maxHeight?: number;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Etapa 15: Integrar com ChatWindow
|
|
||||||
|
|
||||||
**Arquivo:** `apps/web/src/lib/components/chat/ChatWindow.svelte`
|
|
||||||
|
|
||||||
**Modificações:**
|
|
||||||
|
|
||||||
- Adicionar botão de chamada de áudio
|
|
||||||
- Adicionar botão de chamada de vídeo
|
|
||||||
- Mostrar indicador quando há chamada ativa
|
|
||||||
- Importar e usar CallWindow quando houver chamada
|
|
||||||
|
|
||||||
**Adicionar no topo (junto com outros botões):**
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-circle"
|
|
||||||
onclick={() => iniciarChamada('audio')}
|
|
||||||
title="Ligação de áudio"
|
|
||||||
>
|
|
||||||
<Phone class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-sm btn-circle"
|
|
||||||
onclick={() => iniciarChamada('video')}
|
|
||||||
title="Ligação de vídeo"
|
|
||||||
>
|
|
||||||
<Video class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Ordem de Implementação Recomendada
|
|
||||||
|
|
||||||
1. ✅ **Etapa 1:** Configurar Docker Jitsi (fora do código)
|
|
||||||
2. ✅ **Etapa 2:** Atualizar schema com tabela chamadas
|
|
||||||
3. ✅ **Etapa 3:** Criar backend chamadas.ts com todas as funções
|
|
||||||
4. ✅ **Etapa 4:** Instalar dependências frontend
|
|
||||||
5. ✅ **Etapa 5:** Configurar variáveis de ambiente
|
|
||||||
6. ✅ **Etapa 6:** Criar utilitários Jitsi (jitsi.ts)
|
|
||||||
7. ✅ **Etapa 7:** Criar store de chamadas (callStore.ts)
|
|
||||||
8. ✅ **Etapa 8:** Criar utilitários de gravação (mediaRecorder.ts)
|
|
||||||
9. ✅ **Etapa 9:** Criar CallWindow básico (apenas estrutura)
|
|
||||||
10. ✅ **Etapa 10:** Integrar lib-jitsi-meet no CallWindow
|
|
||||||
11. ✅ **Etapa 11:** Criar CallControls e integrar
|
|
||||||
12. ✅ **Etapa 12:** Implementar contador de duração
|
|
||||||
13. ✅ **Etapa 13:** Implementar janela flutuante (drag & resize)
|
|
||||||
14. ✅ **Etapa 14:** Criar CallSettings e integração de dispositivos
|
|
||||||
15. ✅ **Etapa 15:** Criar HostControls e lógica de anfitrião
|
|
||||||
16. ✅ **Etapa 16:** Implementar gravação local
|
|
||||||
17. ✅ **Etapa 17:** Criar RecordingIndicator
|
|
||||||
18. ✅ **Etapa 18:** Integrar botões no ChatWindow
|
|
||||||
19. ✅ **Etapa 19:** Testes completos
|
|
||||||
20. ✅ **Etapa 20:** Ajustes finais e tratamento de erros
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛡️ Segurança e Boas Práticas
|
|
||||||
|
|
||||||
### TypeScript
|
|
||||||
|
|
||||||
- ❌ **NUNCA** usar `any`
|
|
||||||
- ✅ Usar tipos explícitos em todas as funções
|
|
||||||
- ✅ Usar tipos inferidos do Convex quando possível
|
|
||||||
- ✅ Criar interfaces para objetos complexos
|
|
||||||
|
|
||||||
### Svelte 5
|
|
||||||
|
|
||||||
- ✅ Usar `$props()` para props
|
|
||||||
- ✅ Usar `$state()` para estado reativo
|
|
||||||
- ✅ Usar `$derived()` para valores derivados
|
|
||||||
- ✅ Usar `$effect()` para side effects
|
|
||||||
|
|
||||||
### Validação
|
|
||||||
|
|
||||||
- ✅ Validar permissões no backend antes de mutações
|
|
||||||
- ✅ Validar entrada de dados
|
|
||||||
- ✅ Tratar erros adequadamente
|
|
||||||
- ✅ Logs de segurança (criação/finalização de chamadas)
|
|
||||||
|
|
||||||
### Performance
|
|
||||||
|
|
||||||
- ✅ Cleanup adequado de event listeners
|
|
||||||
- ✅ Desconectar Jitsi ao fechar janela
|
|
||||||
- ✅ Parar gravação ao finalizar chamada
|
|
||||||
- ✅ Liberar streams de mídia
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Notas Importantes
|
|
||||||
|
|
||||||
1. **Room Names:** Gerar room names únicos usando conversaId + timestamp + hash
|
|
||||||
2. **Persistência:** Salvar posição/tamanho da janela no localStorage
|
|
||||||
3. **Notificações:** Notificar participantes quando chamada é criada/finalizada
|
|
||||||
4. **Limpeza:** Sempre limpar recursos ao finalizar chamada
|
|
||||||
5. **Erros:** Tratar erros de conexão, permissões de mídia, etc.
|
|
||||||
6. **Acessibilidade:** Adicionar labels, ARIA attributes, suporte a teclado
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 Testes
|
|
||||||
|
|
||||||
### Testes Funcionais
|
|
||||||
|
|
||||||
- [ ] Criar chamada de áudio individual
|
|
||||||
- [ ] Criar chamada de vídeo individual
|
|
||||||
- [ ] Criar chamada em grupo
|
|
||||||
- [ ] Toggle áudio/vídeo
|
|
||||||
- [ ] Anfitrião controlar participantes
|
|
||||||
- [ ] Iniciar/parar gravação
|
|
||||||
- [ ] Contador de duração
|
|
||||||
- [ ] Configuração de dispositivos
|
|
||||||
- [ ] Janela flutuante drag/resize
|
|
||||||
|
|
||||||
### Testes de Segurança
|
|
||||||
|
|
||||||
- [ ] Não anfitrião não pode controlar outros
|
|
||||||
- [ ] Não anfitrião não pode iniciar gravação
|
|
||||||
- [ ] Validação de participantes
|
|
||||||
- [ ] Rate limiting de criação de chamadas
|
|
||||||
|
|
||||||
### Testes de Erros
|
|
||||||
|
|
||||||
- [ ] Conexão perdida
|
|
||||||
- [ ] Sem permissão de mídia
|
|
||||||
- [ ] Dispositivos não disponíveis
|
|
||||||
- [ ] Servidor Jitsi offline
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Referências
|
|
||||||
|
|
||||||
- [Jitsi Meet Docker](https://github.com/jitsi/docker-jitsi-meet)
|
|
||||||
- [lib-jitsi-meet Documentation](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-ljm-api)
|
|
||||||
- [Svelte 5 Documentation](https://svelte.dev/docs)
|
|
||||||
- [Convex Documentation](https://docs.convex.dev)
|
|
||||||
- [WebRTC API](https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API)
|
|
||||||
- [MediaRecorder API](https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Data de Criação:** 2025-01-XX
|
|
||||||
**Versão:** 1.0
|
|
||||||
**Opção:** Docker Local (Desenvolvimento)
|
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
# Relatório de Testes - Sistema de Temas Personalizados
|
|
||||||
|
|
||||||
## Data: 2025-01-27
|
|
||||||
|
|
||||||
## Resumo Executivo
|
|
||||||
Foram testados todos os 10 temas disponíveis no sistema SGSE através da aba "Aparência" na página de perfil. Cada tema foi selecionado e validado visualmente através de screenshots.
|
|
||||||
|
|
||||||
## Temas Testados
|
|
||||||
|
|
||||||
### 1. ✅ Tema Roxo (Purple)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema padrão com cores roxa e azul
|
|
||||||
- **Screenshot**: `tema-roxo.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe cores roxas/azuis
|
|
||||||
|
|
||||||
### 2. ✅ Tema Azul (Blue)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema azul clássico e profissional
|
|
||||||
- **Screenshot**: `tema-azul.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de azul
|
|
||||||
|
|
||||||
### 3. ✅ Tema Verde (Green)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema verde natural e harmonioso
|
|
||||||
- **Screenshot**: `tema-verde.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de verde
|
|
||||||
|
|
||||||
### 4. ✅ Tema Laranja (Orange)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema laranja vibrante e energético
|
|
||||||
- **Screenshot**: `tema-laranja.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de laranja
|
|
||||||
|
|
||||||
### 5. ✅ Tema Vermelho (Red)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema vermelho intenso e impactante
|
|
||||||
- **Screenshot**: `tema-vermelho.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de vermelho
|
|
||||||
|
|
||||||
### 6. ✅ Tema Rosa (Pink)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema rosa suave e elegante
|
|
||||||
- **Screenshot**: `tema-rosa.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de rosa
|
|
||||||
|
|
||||||
### 7. ✅ Tema Verde-água (Teal)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema verde-água refrescante
|
|
||||||
- **Screenshot**: `tema-verde-agua.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons de verde-água
|
|
||||||
|
|
||||||
### 8. ✅ Tema Escuro (Dark)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema escuro para uso noturno
|
|
||||||
- **Screenshot**: `tema-escuro.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe fundo escuro
|
|
||||||
|
|
||||||
### 9. ✅ Tema Claro (Light)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema claro e minimalista
|
|
||||||
- **Screenshot**: `tema-claro.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe fundo claro
|
|
||||||
|
|
||||||
### 10. ✅ Tema Corporativo (Corporate)
|
|
||||||
- **Status**: Funcionando
|
|
||||||
- **Descrição**: Tema corporativo azul escuro
|
|
||||||
- **Screenshot**: `tema-corporativo.png`
|
|
||||||
- **Observações**: Tema aplicado corretamente, interface exibe tons corporativos
|
|
||||||
|
|
||||||
## Funcionalidades Testadas
|
|
||||||
|
|
||||||
### ✅ Seleção de Temas
|
|
||||||
- Todos os 10 temas podem ser selecionados através dos botões na interface
|
|
||||||
- A seleção é visualmente indicada com "Tema Ativo"
|
|
||||||
- A mudança de tema é aplicada imediatamente na interface
|
|
||||||
|
|
||||||
### ✅ Interface de Seleção
|
|
||||||
- A aba "Aparência" está acessível na página de perfil
|
|
||||||
- Todos os 10 temas são exibidos em cards com preview visual
|
|
||||||
- Cada card mostra o nome, descrição e um gradiente de cores representativo
|
|
||||||
|
|
||||||
### ✅ Aplicação de Temas
|
|
||||||
- Os temas são aplicados dinamicamente ao elemento `<html>` via atributo `data-theme`
|
|
||||||
- As cores são alteradas em toda a interface (sidebar, header, botões, etc.)
|
|
||||||
- A mudança é instantânea, sem necessidade de recarregar a página
|
|
||||||
|
|
||||||
## Screenshots Capturados
|
|
||||||
|
|
||||||
Todos os screenshots foram salvos com os seguintes nomes:
|
|
||||||
- `tema-verde-agua-atual.png` - Estado inicial (tema verde-água)
|
|
||||||
- `tema-roxo.png`
|
|
||||||
- `tema-azul.png`
|
|
||||||
- `tema-verde.png`
|
|
||||||
- `tema-laranja.png`
|
|
||||||
- `tema-vermelho.png`
|
|
||||||
- `tema-rosa.png`
|
|
||||||
- `tema-verde-agua.png`
|
|
||||||
- `tema-escuro.png`
|
|
||||||
- `tema-claro.png`
|
|
||||||
- `tema-corporativo.png`
|
|
||||||
|
|
||||||
## Conclusão
|
|
||||||
|
|
||||||
✅ **Todos os 10 temas estão funcionando corretamente!**
|
|
||||||
|
|
||||||
- Cada tema altera a aparência da interface conforme esperado
|
|
||||||
- As cores são aplicadas consistentemente em todos os componentes
|
|
||||||
- A seleção de temas funciona de forma intuitiva e responsiva
|
|
||||||
- O sistema está pronto para uso em produção
|
|
||||||
|
|
||||||
## Próximos Passos Recomendados
|
|
||||||
|
|
||||||
1. Testar a persistência do tema salvo no banco de dados
|
|
||||||
2. Validar que o tema é aplicado automaticamente ao fazer login
|
|
||||||
3. Verificar que o tema padrão (roxo) é aplicado ao fazer logout
|
|
||||||
4. Testar com diferentes usuários para garantir isolamento de preferências
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
# Validação e Correções do Sistema de Temas
|
|
||||||
|
|
||||||
## Correções Implementadas
|
|
||||||
|
|
||||||
### 1. Temas Customizados Melhorados
|
|
||||||
- Adicionadas todas as variáveis CSS necessárias do DaisyUI para cada tema customizado
|
|
||||||
- Incluídas variáveis de arredondamento, animação e bordas
|
|
||||||
- Adicionado `color-scheme` para temas claros/escuros
|
|
||||||
|
|
||||||
### 2. Estrutura Padronizada
|
|
||||||
- Todos os temas customizados seguem o mesmo padrão de variáveis CSS
|
|
||||||
- Temas nativos do DaisyUI (purple/aqua, dark, light) mantidos
|
|
||||||
- Temas customizados (sgse-blue, sgse-green, etc.) com variáveis completas
|
|
||||||
|
|
||||||
### 3. Aplicação de Temas
|
|
||||||
- Função `aplicarTema()` atualizada para aplicar corretamente no elemento HTML
|
|
||||||
- Removido localStorage - tema salvo apenas no banco de dados
|
|
||||||
- Tema padrão aplicado ao fazer logout
|
|
||||||
|
|
||||||
## Como Testar Manualmente
|
|
||||||
|
|
||||||
1. **Fazer Login:**
|
|
||||||
- Email: `dfw@poli.br` / Senha: `Admin@2025`
|
|
||||||
- OU Email: `kilder@kilder.com.br` / Senha: `Mudar@123`
|
|
||||||
|
|
||||||
2. **Acessar Página de Perfil:**
|
|
||||||
- Clique no avatar do usuário no canto superior direito
|
|
||||||
- Selecione "Meu Perfil"
|
|
||||||
- OU acesse diretamente: `/perfil`
|
|
||||||
|
|
||||||
3. **Testar Cada Tema:**
|
|
||||||
- Clique na aba "Aparência"
|
|
||||||
- Teste cada um dos 10 temas:
|
|
||||||
- **Roxo** (purple/aqua) - Padrão
|
|
||||||
- **Azul** (sgse-blue)
|
|
||||||
- **Verde** (sgse-green)
|
|
||||||
- **Laranja** (sgse-orange)
|
|
||||||
- **Vermelho** (sgse-red)
|
|
||||||
- **Rosa** (sgse-pink)
|
|
||||||
- **Verde-água** (sgse-teal)
|
|
||||||
- **Escuro** (dark)
|
|
||||||
- **Claro** (light)
|
|
||||||
- **Corporativo** (sgse-corporate)
|
|
||||||
|
|
||||||
4. **Validar Mudanças:**
|
|
||||||
- Ao clicar em um tema, a interface deve mudar imediatamente
|
|
||||||
- Verificar cores em:
|
|
||||||
- Sidebar
|
|
||||||
- Botões
|
|
||||||
- Cards
|
|
||||||
- Badges
|
|
||||||
- Links
|
|
||||||
- Backgrounds
|
|
||||||
|
|
||||||
5. **Salvar Tema:**
|
|
||||||
- Clique em "Salvar Tema" após selecionar
|
|
||||||
- Faça logout e login novamente
|
|
||||||
- O tema salvo deve ser aplicado automaticamente
|
|
||||||
|
|
||||||
6. **Testar Logout:**
|
|
||||||
- Ao fazer logout, o tema deve voltar ao padrão (roxo)
|
|
||||||
|
|
||||||
## Problemas Identificados e Corrigidos
|
|
||||||
|
|
||||||
1. ✅ Variáveis CSS incompletas nos temas customizados
|
|
||||||
2. ✅ Falta de `color-scheme` nos temas
|
|
||||||
3. ✅ localStorage removido (tema apenas no banco)
|
|
||||||
4. ✅ Tema padrão aplicado ao logout
|
|
||||||
5. ✅ Estrutura padronizada de todos os temas
|
|
||||||
|
|
||||||
## Próximos Passos para Validação
|
|
||||||
|
|
||||||
Se algum tema não estiver funcionando:
|
|
||||||
|
|
||||||
1. Verificar no console do navegador (F12) se há erros
|
|
||||||
2. Verificar o atributo `data-theme` no elemento `<html>` (deve mudar ao selecionar tema)
|
|
||||||
3. Verificar se as variáveis CSS estão sendo aplicadas (DevTools > Elements > Computed)
|
|
||||||
4. Testar em modo anônimo para garantir que não há cache
|
|
||||||
|
|
||||||
## Arquivos Modificados
|
|
||||||
|
|
||||||
- `apps/web/src/app.css` - Temas customizados melhorados
|
|
||||||
- `apps/web/src/lib/utils/temas.ts` - Funções de aplicação de temas
|
|
||||||
- `apps/web/src/routes/+layout.svelte` - Aplicação automática do tema
|
|
||||||
- `apps/web/src/routes/(dashboard)/perfil/+page.svelte` - Interface de seleção
|
|
||||||
- `apps/web/src/lib/components/Sidebar.svelte` - Reset de tema no logout
|
|
||||||
- `packages/backend/convex/schema.ts` - Campo temaPreferido
|
|
||||||
- `packages/backend/convex/usuarios.ts` - Função atualizarTema
|
|
||||||
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
# ⚙️ Configuração de Variáveis de Ambiente
|
|
||||||
|
|
||||||
## 📁 Arquivo .env
|
|
||||||
|
|
||||||
Crie um arquivo `.env` na pasta `apps/web/` com as seguintes variáveis:
|
|
||||||
|
|
||||||
```env
|
|
||||||
# Google Maps API Key (opcional)
|
|
||||||
# Obtenha sua chave em: https://console.cloud.google.com/
|
|
||||||
# Ative a "Geocoding API" para buscar coordenadas por endereço
|
|
||||||
# Deixe vazio para usar OpenStreetMap (gratuito, sem necessidade de chave)
|
|
||||||
VITE_GOOGLE_MAPS_API_KEY=
|
|
||||||
|
|
||||||
# VAPID Public Key para Push Notifications (opcional)
|
|
||||||
VITE_VAPID_PUBLIC_KEY=
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📖 Documentação Completa
|
|
||||||
|
|
||||||
Para instruções detalhadas sobre como obter e configurar a Google Maps API Key, consulte:
|
|
||||||
|
|
||||||
📄 **[GOOGLE_MAPS_SETUP.md](./GOOGLE_MAPS_SETUP.md)**
|
|
||||||
|
|
||||||
## ⚠️ Importante
|
|
||||||
|
|
||||||
- O arquivo `.env` não deve ser commitado no Git (já está no .gitignore)
|
|
||||||
- Variáveis de ambiente começam com `VITE_` para serem acessíveis no frontend
|
|
||||||
- Reinicie o servidor de desenvolvimento após alterar o arquivo `.env`
|
|
||||||
|
|
||||||
72
apps/web/Dockerfile
Normal file
72
apps/web/Dockerfile
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Use the official Bun image
|
||||||
|
FROM oven/bun:1 AS base
|
||||||
|
|
||||||
|
# Set the working directory inside the container
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# ---
|
||||||
|
FROM base AS prepare
|
||||||
|
|
||||||
|
RUN bun add -g turbo@^2
|
||||||
|
COPY . .
|
||||||
|
RUN turbo prune web --docker
|
||||||
|
|
||||||
|
|
||||||
|
# ---
|
||||||
|
FROM base AS builder
|
||||||
|
|
||||||
|
# First install the dependencies (as they change less often)
|
||||||
|
COPY --from=prepare /app/out/json/ .
|
||||||
|
RUN bun install
|
||||||
|
# Build the project
|
||||||
|
COPY --from=prepare /app/out/full/ .
|
||||||
|
|
||||||
|
ARG PUBLIC_CONVEX_URL
|
||||||
|
ENV PUBLIC_CONVEX_URL=$PUBLIC_CONVEX_URL
|
||||||
|
|
||||||
|
ARG PUBLIC_CONVEX_SITE_URL
|
||||||
|
ENV PUBLIC_CONVEX_SITE_URL=$PUBLIC_CONVEX_SITE_URL
|
||||||
|
|
||||||
|
RUN bunx turbo build
|
||||||
|
|
||||||
|
# Production stage
|
||||||
|
FROM oven/bun:1-slim AS production
|
||||||
|
|
||||||
|
# Set working directory to match builder structure
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup --system --gid 1001 sveltekit
|
||||||
|
RUN adduser --system --uid 1001 sveltekit
|
||||||
|
|
||||||
|
# Copy root node_modules (contains hoisted dependencies)
|
||||||
|
COPY --from=builder --chown=sveltekit:sveltekit /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy built application and workspace files
|
||||||
|
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/build ./apps/web/build
|
||||||
|
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/package.json ./apps/web/package.json
|
||||||
|
# Copy workspace node_modules (contains symlinks to root node_modules)
|
||||||
|
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/node_modules ./apps/web/node_modules
|
||||||
|
|
||||||
|
# Copy any additional files needed for runtime
|
||||||
|
COPY --from=builder --chown=sveltekit:sveltekit /app/apps/web/static ./apps/web/static
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER sveltekit
|
||||||
|
|
||||||
|
# Set working directory to the app
|
||||||
|
WORKDIR /app/apps/web
|
||||||
|
|
||||||
|
# Expose the port that the app runs on
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=5173
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD bun --version || exit 1
|
||||||
|
|
||||||
|
# Start the application
|
||||||
|
CMD ["bun", "./build/index.js"]
|
||||||
@@ -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.
|
|
||||||
|
|
||||||
@@ -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"
|
||||||
|
|||||||
124
apps/web/src/lib/components/RelogioPrazo.svelte
Normal file
124
apps/web/src/lib/components/RelogioPrazo.svelte
Normal 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}
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|
||||||
433
apps/web/src/routes/(dashboard)/fluxos/+page.svelte
Normal file
433
apps/web/src/routes/(dashboard)/fluxos/+page.svelte
Normal 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}
|
||||||
|
|
||||||
722
apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte
Normal file
722
apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte
Normal 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}
|
||||||
|
|
||||||
800
apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
Normal file
800
apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
Normal 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}
|
||||||
|
|
||||||
373
apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
Normal file
373
apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
Normal 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}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
365
apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
Normal file
365
apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
Normal 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}
|
||||||
|
|
||||||
1251
apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
Normal file
1251
apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} */
|
||||||
|
|||||||
@@ -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
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
70
bun.lock
70
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
3
packages/backend/.gitignore
vendored
3
packages/backend/.gitignore
vendored
@@ -1,3 +1,2 @@
|
|||||||
.env
|
.env*
|
||||||
.env.local
|
|
||||||
.convex/
|
.convex/
|
||||||
|
|||||||
6
packages/backend/convex/_generated/api.d.ts
vendored
6
packages/backend/convex/_generated/api.d.ts
vendored
@@ -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;
|
||||||
|
|||||||
@@ -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}`,
|
||||||
};
|
// };
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
});
|
// });
|
||||||
|
|
||||||
|
|||||||
2161
packages/backend/convex/flows.ts
Normal file
2161
packages/backend/convex/flows.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
318
packages/backend/convex/setores.ts
Normal file
318
packages/backend/convex/setores.ts
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user