Compare commits

..

12 Commits

Author SHA1 Message Date
6128c20da0 feat: implement sub-steps management in workflow editor
- Added functionality for creating, updating, and deleting sub-steps within the workflow editor.
- Introduced a modal for adding new sub-steps, including fields for name and description.
- Enhanced the UI to display sub-steps with status indicators and options for updating their status.
- Updated navigation links to reflect changes in the workflow structure, ensuring consistency across the application.
- Refactored related components to accommodate the new sub-steps feature, improving overall workflow management.
2025-11-25 14:14:43 -03:00
f8d9c17f63 feat: add Svelte DnD action and enhance flow management features
- Added "svelte-dnd-action" dependency to facilitate drag-and-drop functionality.
- Introduced new "Fluxos de Trabalho" section in the dashboard for managing workflow templates and instances.
- Updated permission handling for sectors and flow templates in the backend.
- Enhanced schema definitions to support flow templates, instances, and associated documents.
- Improved UI components to include new workflow management features across various dashboard pages.
2025-11-25 00:21:35 -03:00
409872352c docs: Create guidelines for integrating Convex with Svelte/SvelteKit. 2025-11-24 14:48:04 -03:00
d4a3214451 Merge remote-tracking branch 'origin' into refinament-1 2025-11-24 09:01:00 -03:00
649b9b145c Merge pull request #42 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-23 16:29:55 -03:00
ae4fc1c4d5 Merge pull request #41 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-23 15:11:29 -03:00
2d7761ee94 Merge pull request #40 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-22 22:41:20 -03:00
9dc816977d Merge pull request #39 from killer-cf/call-audio-video-jitsi
Call audio video jitsi
2025-11-22 18:40:54 -03:00
3cc35d3a1e feat: Add explicit TypeScript types and improve error handling for employee reports. 2025-11-22 16:52:31 -03:00
Kilder Costa
7871b87bb9 Merge pull request #38 from killer-cf/refinament-1
Refinament 1
2025-11-22 10:26:24 -03:00
b8a2e67f3a refactor: Update email configuration page to load data once and improve error handling, and add Svelte agent rules documentation. 2025-11-22 10:25:43 -03:00
ce94eb53b3 generated file 2025-11-21 14:02:22 -03:00
24 changed files with 8872 additions and 893 deletions

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"svelte": "^5.38.1", "svelte": "^5.38.1",
"svelte-check": "^4.3.1", "svelte-check": "^4.3.1",
"svelte-dnd-action": "^0.9.67",
"tailwindcss": "^4.1.12", "tailwindcss": "^4.1.12",
"typescript": "catalog:", "typescript": "catalog:",
"vite": "^7.1.2" "vite": "^7.1.2"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "sgse-app", "name": "sgse-app",
@@ -18,6 +19,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",
}, },
@@ -66,6 +68,7 @@
"postcss": "^8.5.6", "postcss": "^8.5.6",
"svelte": "^5.38.1", "svelte": "^5.38.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",
@@ -1264,6 +1267,8 @@
"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=="],

View File

@@ -32,6 +32,7 @@
"jiti": "^2.6.1", "jiti": "^2.6.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
"svelte-dnd-action": "^0.9.67",
"turbo": "^2.5.8", "turbo": "^2.5.8",
"typescript-eslint": "^8.46.3" "typescript-eslint": "^8.46.3"
}, },

View File

@@ -35,6 +35,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 +52,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";
@@ -93,6 +95,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 +112,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;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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