diff --git a/.agent/rules/convex-svelte-guidelines.md b/.agent/rules/convex-svelte-guidelines.md
new file mode 100644
index 0000000..f61e324
--- /dev/null
+++ b/.agent/rules/convex-svelte-guidelines.md
@@ -0,0 +1,275 @@
+---
+trigger: glob
+globs: **/*.svelte, **/*.ts, **/*.svelte.ts
+---
+
+# Convex + Svelte Guidelines
+
+## Overview
+
+These guidelines describe how to write **Convex** backend code **and** consume it from a **Svelte** (SvelteKit) frontend. The syntax for Convex functions stays exactly the same, but the way you import and call them from the client differs from a React/Next.js project. Below you will find the adapted sections from the original Convex style guide with Svelte‑specific notes.
+
+---
+
+## 1. Function Syntax (Backend)
+
+> **No change** – keep the new Convex function syntax.
+
+```typescript
+import {
+ query,
+ mutation,
+ action,
+ internalQuery,
+ internalMutation,
+ internalAction
+} from './_generated/server';
+import { v } from 'convex/values';
+
+export const getUser = query({
+ args: { userId: v.id('users') },
+ returns: v.object({ name: v.string(), email: v.string() }),
+ handler: async (ctx, args) => {
+ const user = await ctx.db.get(args.userId);
+ if (!user) throw new Error('User not found');
+ return { name: user.name, email: user.email };
+ }
+});
+```
+
+---
+
+## 2. HTTP Endpoints (Backend)
+
+> **No change** – keep the same `convex/http.ts` file.
+
+```typescript
+import { httpRouter } from 'convex/server';
+import { httpAction } from './_generated/server';
+
+const http = httpRouter();
+
+http.route({
+ path: '/api/echo',
+ method: 'POST',
+ handler: httpAction(async (ctx, req) => {
+ const body = await req.bytes();
+ return new Response(body, { status: 200 });
+ })
+});
+```
+
+---
+
+## 3. Validators (Backend)
+
+> **No change** – keep the same validators (`v.string()`, `v.id()`, etc.).
+
+---
+
+## 4. Function Registration (Backend)
+
+> **No change** – use `query`, `mutation`, `action` for public functions and `internal*` for private ones.
+
+---
+
+## 5. Function Calling from **Svelte**
+
+### 5.1 Install the Convex client
+
+```bash
+npm i convex @convex-dev/convex-svelte
+```
+
+> The `@convex-dev/convex-svelte` package provides a thin wrapper that works with Svelte stores.
+
+### 5.2 Initialise the client (e.g. in `src/lib/convex.ts`)
+
+```typescript
+import { createConvexClient } from '@convex-dev/convex-svelte';
+
+export const convex = createConvexClient({
+ url: import.meta.env.VITE_CONVEX_URL // set in .env
+});
+```
+
+### 5.3 Using queries in a component
+
+```svelte
+
+
+{#if loading}
+
Loading…
+{:else if error}
+ {error}
+{:else if user}
+ {user.name}
+ {user.email}
+{/if}
+```
+
+### 5.4 Using mutations in a component
+
+```svelte
+
+
+
+Create
+{#if error}{error}
{/if}
+```
+
+### 5.5 Using **actions** (Node‑only) from Svelte
+
+Actions run in a Node environment, so they cannot be called directly from the browser. Use a **mutation** that internally calls the action, or expose a HTTP endpoint that triggers the action.
+
+---
+
+## 6. Scheduler / Cron (Backend)
+
+> Same as original guide – define `crons.ts` and export the default `crons` object.
+
+---
+
+## 7. File Storage (Backend)
+
+> Same as original guide – use `ctx.storage.getUrl()` and query `_storage` for metadata.
+
+---
+
+## 8. TypeScript Helpers (Backend)
+
+> Keep using `Id<'table'>` from `./_generated/dataModel`.
+
+---
+
+## 9. Svelte‑Specific Tips
+
+| Topic | Recommendation |
+| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
+| **Store‑based data** | If you need reactive data across many components, wrap `convex.query` in a Svelte store (`readable`, `writable`). |
+| **Error handling** | Use `try / catch` around every client call; surface the error in the UI. |
+| **SSR / SvelteKit** | Calls made in `load` functions run on the server; you can use `convex.query` there without worrying about the browser environment. |
+| **Environment variables** | Prefix with `VITE_` for client‑side access (`import.meta.env.VITE_CONVEX_URL`). |
+| **Testing** | Use the Convex mock client (`createMockConvexClient`) provided by `@convex-dev/convex-svelte` for unit tests. |
+
+---
+
+## 10. Full Example (SvelteKit + Convex)
+
+### 10.1 Backend (`convex/users.ts`)
+
+```typescript
+import { mutation, query } from './_generated/server';
+import { v } from 'convex/values';
+
+export const createUser = mutation({
+ args: { name: v.string() },
+ returns: v.id('users'),
+ handler: async (ctx, args) => {
+ return await ctx.db.insert('users', { name: args.name });
+ }
+});
+
+export const getUser = query({
+ args: { userId: v.id('users') },
+ returns: v.object({ name: v.string() }),
+ handler: async (ctx, args) => {
+ const user = await ctx.db.get(args.userId);
+ if (!user) throw new Error('Not found');
+ return { name: user.name };
+ }
+});
+```
+
+### 10.2 Frontend (`src/routes/+page.svelte`)
+
+```svelte
+
+
+
+Create user
+{#if createdId}Created user id: {createdId}
{/if}
+{#if error}{error}
{/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._
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..0a40e7d
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,30 @@
+name: Build Docker images
+
+on:
+ push:
+ branches: ["main"]
+
+jobs:
+ build-and-push-dockerfile-image:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
+ password: ${{ secrets.DOCKERHUB_TOKEN }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ # Make sure to replace with your own namespace and repository
+ tags: |
+ namespace/example:latest
+ platforms: linux/amd64
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0e0d48d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,58 @@
+# Use the official Bun image
+FROM oven/bun:1 AS base
+
+# Set the working directory inside the container
+WORKDIR /usr/src/app
+
+# Create a non-root user for security
+RUN addgroup --system --gid 1001 sveltekit
+RUN adduser --system --uid 1001 sveltekit
+
+# Copy package.json and bun.lockb (if available)
+COPY package.json bun.lockb* ./
+
+# Install dependencies (including dev dependencies for build)
+RUN bun install --frozen-lockfile
+
+# Copy the source code
+COPY . .
+
+# Prepare SvelteKit and build the application
+RUN bun run prepare
+RUN bun run build
+RUN bun run db:migrate
+
+# Production stage
+FROM oven/bun:1-slim AS production
+
+# Set working directory
+WORKDIR /usr/src/app
+
+# Create non-root user
+RUN addgroup --system --gid 1001 sveltekit
+RUN adduser --system --uid 1001 sveltekit
+
+# Copy built application from base stage
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/build ./build
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/package.json ./package.json
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/node_modules ./node_modules
+
+# Copy any additional files needed for runtime
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/static ./static
+
+# Switch to non-root user
+USER sveltekit
+
+# Expose the port that the app runs on
+EXPOSE 5173
+
+# Set environment variables
+ENV NODE_ENV=production
+ENV PORT=5173
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD bun --version || exit 1
+
+# Start the application
+CMD ["bun", "./build/index.js"]
diff --git a/apps/web/package.json b/apps/web/package.json
index c403caa..55c201c 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -23,6 +23,7 @@
"postcss": "^8.5.6",
"svelte": "^5.38.1",
"svelte-check": "^4.3.1",
+ "svelte-dnd-action": "^0.9.67",
"tailwindcss": "^4.1.12",
"typescript": "catalog:",
"vite": "^7.1.2"
diff --git a/apps/web/src/lib/components/RelogioPrazo.svelte b/apps/web/src/lib/components/RelogioPrazo.svelte
new file mode 100644
index 0000000..14c0b91
--- /dev/null
+++ b/apps/web/src/lib/components/RelogioPrazo.svelte
@@ -0,0 +1,124 @@
+
+
+{#if tempoInfo}
+ {@const info = tempoInfo}
+
+ {#if info.tipo === 'concluida'}
+
+
+
+
+ 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}
+ ({info.diasAtrasado} {info.diasAtrasado === 1 ? 'dia' : 'dias'} fora do prazo)
+ {/if}
+
+ {:else if info.tipo === 'andamento'}
+
+
+
+
+ {#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}
+
+ {/if}
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/configuracoes/setores/+page.svelte b/apps/web/src/routes/(dashboard)/configuracoes/setores/+page.svelte
new file mode 100644
index 0000000..e7f03d9
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/configuracoes/setores/+page.svelte
@@ -0,0 +1,397 @@
+
+
+
+
+
+
+
+
+
+
+ Configurações
+
+
+ Gestão de Setores
+
+
+ Gerencie os setores da organização. Setores são utilizados para organizar funcionários e
+ definir responsabilidades em fluxos de trabalho.
+
+
+
+
+
+
+
+
+ Novo Setor
+
+
+
+
+
+
+
+
+ {#if setoresQuery.isLoading}
+
+
+
+ {:else if !setoresQuery.data || setoresQuery.data.length === 0}
+
+
+
+
+
Nenhum setor cadastrado
+
Clique em "Novo Setor" para criar o primeiro setor.
+
+ {:else}
+
+
+
+
+ Sigla
+ Nome
+ Criado em
+ Ações
+
+
+
+ {#each setoresQuery.data as setor (setor._id)}
+
+
+
+ {setor.sigla}
+
+
+ {setor.nome}
+ {formatDate(setor.createdAt)}
+
+
+
+ openEditModal(setor)}
+ aria-label="Editar setor {setor.nome}"
+ >
+
+
+
+
+
+
+ openDeleteModal(setor)}
+ aria-label="Excluir setor {setor.nome}"
+ >
+
+
+
+
+
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showModal}
+
+
+
+ {editingSetor ? 'Editar Setor' : 'Novo Setor'}
+
+
+ {#if error}
+
+ {/if}
+
+
+
+
+
+{/if}
+
+
+{#if showDeleteModal && setorToDelete}
+
+
+
Confirmar Exclusão
+
+ {#if error}
+
+ {/if}
+
+
+ Tem certeza que deseja excluir o setor {setorToDelete.nome} ?
+
+
+ Esta ação não pode ser desfeita. Setores com funcionários ou passos de fluxo vinculados não
+ podem ser excluídos.
+
+
+
+
+ Cancelar
+
+
+ {#if isSubmitting}
+
+ {/if}
+ Excluir
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/fluxos/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/+page.svelte
new file mode 100644
index 0000000..6464732
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/fluxos/+page.svelte
@@ -0,0 +1,433 @@
+
+
+
+
+
+
+
+
+
+
+ Gestão de Fluxos
+
+
+ Templates de Fluxo
+
+
+ Crie e gerencie templates de fluxo de trabalho. Templates definem os passos e
+ responsabilidades que serão instanciados para projetos ou contratos.
+
+
+
+
+
+ Todos os status
+ Rascunho
+ Publicado
+ Arquivado
+
+
+
+
+
+
+
+ Novo Template
+
+
+
+
+
+
+
+
+ {#if templatesQuery.isLoading}
+
+
+
+ {:else if !templatesQuery.data || templatesQuery.data.length === 0}
+
+
+
+
+
Nenhum template encontrado
+
+ {statusFilter ? 'Não há templates com este status.' : 'Clique em "Novo Template" para criar o primeiro.'}
+
+
+ {:else}
+
+ {#each templatesQuery.data as template (template._id)}
+ {@const statusBadge = getStatusBadge(template.status)}
+
+
+
+
{template.name}
+ {statusBadge.label}
+
+
+ {#if template.description}
+
+ {template.description}
+
+ {/if}
+
+
+
+
+
+
+ {template.stepsCount} passos
+
+
+
+
+
+ {formatDate(template.createdAt)}
+
+
+
+
+
+
+
+
+
+
+
+ {#if template.status !== 'draft'}
+
+ handleStatusChange(template._id, 'draft')}>
+ Voltar para Rascunho
+
+
+ {/if}
+ {#if template.status !== 'published'}
+
+ handleStatusChange(template._id, 'published')}>
+ Publicar
+
+
+ {/if}
+ {#if template.status !== 'archived'}
+
+ handleStatusChange(template._id, 'archived')}>
+ Arquivar
+
+
+ {/if}
+
+ openDeleteModal(template)}>
+ Excluir
+
+
+
+
+
+
+
+
+
+ Editar
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+
+
+
+{#if showCreateModal}
+
+
+
Novo Template de Fluxo
+
+ {#if createError}
+
+ {/if}
+
+
+
+
+
+{/if}
+
+
+{#if showDeleteModal && templateToDelete}
+
+
+
Confirmar Exclusão
+
+ {#if deleteError}
+
+ {/if}
+
+
+ Tem certeza que deseja excluir o template {templateToDelete.name} ?
+
+
+ Esta ação não pode ser desfeita. Templates com instâncias vinculadas não podem ser excluídos.
+
+
+
+
+ Cancelar
+
+
+ {#if isDeleting}
+
+ {/if}
+ Excluir
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte
new file mode 100644
index 0000000..18af39c
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/fluxos/[id]-fluxo/+page.svelte
@@ -0,0 +1,722 @@
+
+
+
+ {#if instanceQuery.isLoading}
+
+
+
+ {:else if !instanceQuery.data}
+
+
+
+
+
Fluxo não encontrado
+
Voltar para lista
+
+ {:else}
+ {@const instance = instanceQuery.data.instance}
+ {@const steps = instanceQuery.data.steps}
+ {@const statusBadge = getInstanceStatusBadge(instance.status)}
+
+
+
+
+
+
+
+
+
+
+
+ {instance.templateName ?? 'Fluxo'}
+
+
+ {#if instance.contratoId}
+
+ Contrato
+ {instance.contratoId}
+
+ {/if}
+
+
+
+
+ Gerente: {instance.managerName ?? '-'}
+
+
+
+
+
+ Iniciado: {formatDate(instance.startedAt)}
+
+
+
+
+ {#if instance.status === 'active'}
+
+ showCancelModal = true}>
+
+
+
+ Cancelar Fluxo
+
+
+ {/if}
+
+
+
+
+
+ {#if processingError}
+
+
+
+
+
{processingError}
+
processingError = null}>Fechar
+
+ {/if}
+
+
+
+ Timeline do Fluxo
+
+
+ {#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)}
+
+
+
+ {#if index < steps.length - 1}
+
+ {/if}
+
+
+
+ {#if step.status === 'completed'}
+
+
+
+ {:else if step.status === 'blocked'}
+
+
+
+ {:else}
+
{index + 1}
+ {/if}
+
+
+
+
+
+
+
+
{step.stepName}
+ {stepStatus.label}
+ {#if overdue}
+ Atrasado
+ {/if}
+
+ {#if step.stepDescription}
+
{step.stepDescription}
+ {/if}
+
+
+
+
+
+ {step.setorNome ?? 'Setor não definido'}
+
+ {#if step.assignedToName}
+
+
+
+
+ {step.assignedToName}
+
+ {/if}
+ {#if step.dueDate}
+
+
+
+
+ Prazo: {formatDate(step.dueDate)}
+
+ {/if}
+
+
+
+
+ {#if instance.status === 'active'}
+
+ {#if step.status === 'pending'}
+
+ handleStartStep(step._id)}
+ disabled={isProcessing}
+ >
+ Iniciar
+
+
+ {:else if step.status === 'in_progress'}
+
+ handleCompleteStep(step._id)}
+ disabled={isProcessing}
+ >
+ Concluir
+
+ handleBlockStep(step._id)}
+ disabled={isProcessing}
+ >
+ Bloquear
+
+
+ {:else if step.status === 'blocked'}
+
+ handleStartStep(step._id)}
+ disabled={isProcessing}
+ >
+ Desbloquear
+
+
+ {/if}
+
+
+ openReassignModal(step)}
+ aria-label="Reatribuir responsável"
+ >
+
+
+
+
+
+
+
openNotesModal(step)}
+ aria-label="Editar notas"
+ >
+
+
+
+
+
+
+ openUploadModal(step)}
+ aria-label="Upload de documento"
+ >
+
+
+
+
+
+
+ {/if}
+
+
+
+ {#if step.notes}
+
+ {/if}
+
+
+ {#if step.documents && step.documents.length > 0}
+
+
Documentos
+
+ {#each step.documents as doc (doc._id)}
+
+
+
+
+ {doc.name}
+
+ handleDeleteDocument(doc._id)}
+ aria-label="Excluir documento {doc.name}"
+ >
+ ×
+
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if step.startedAt || step.finishedAt}
+
+ {#if step.startedAt}
+ Iniciado: {formatDate(step.startedAt)}
+ {/if}
+ {#if step.finishedAt}
+ Concluído: {formatDate(step.finishedAt)}
+ {/if}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+
+{#if showReassignModal && stepToReassign}
+
+
+
Reatribuir Responsável
+
+ Selecione o novo responsável pelo passo {stepToReassign.stepName}
+
+
+
+
+ Responsável
+
+
+ Selecione um usuário
+ {#if usuariosQuery.data}
+ {#each usuariosQuery.data as usuario (usuario._id)}
+ {usuario.nome}
+ {/each}
+ {/if}
+
+
+
+
+
+ Cancelar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Reatribuir
+
+
+
+
+
+{/if}
+
+
+{#if showNotesModal && stepForNotes}
+
+
+
Notas do Passo
+
+ Adicione ou edite notas para o passo {stepForNotes.stepName}
+
+
+
+
+ Notas
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Salvar
+
+
+
+
+
+{/if}
+
+
+{#if showUploadModal && stepForUpload}
+
+
+
Upload de Documento
+
+ Anexe um documento ao passo {stepForUpload.stepName}
+
+
+
+
+ Arquivo
+
+
+
+
+ {#if uploadFile}
+
+ Arquivo selecionado: {uploadFile.name}
+
+ {/if}
+
+
+
+ Cancelar
+
+
+ {#if isUploading}
+
+ {/if}
+ Enviar
+
+
+
+
+
+{/if}
+
+
+{#if showCancelModal}
+
+
+
Cancelar Fluxo
+
+ Tem certeza que deseja cancelar este fluxo?
+
+
+ Esta ação não pode ser desfeita. Todos os passos pendentes serão marcados como cancelados.
+
+
+
+ showCancelModal = false} disabled={isProcessing}>
+ Voltar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Cancelar Fluxo
+
+
+
+
showCancelModal = false} aria-label="Fechar modal">
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
new file mode 100644
index 0000000..fc1a89c
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
@@ -0,0 +1,800 @@
+
+
+
+
+
+
+
+
+
+
+
+ Voltar
+
+
+ {#if templateQuery.isLoading}
+
+ {:else if templateQuery.data}
+
{templateQuery.data.name}
+
+ {templateQuery.data.description ?? 'Sem descrição'}
+
+ {/if}
+
+
+
+
+ {#if templateQuery.data?.status === 'draft'}
+
+
+
+
+ Publicar
+
+ {:else if templateQuery.data?.status === 'published'}
+
Publicado
+ {:else if templateQuery.data?.status === 'archived'}
+
Arquivado
+ {/if}
+
+
+
+
+
+
+
+
+
+
Passos do Fluxo
+
+
+
+
+ Novo Passo
+
+
+
+ {#if stepsQuery.isLoading}
+
+
+
+ {:else if !localSteps || localSteps.length === 0}
+
+
+
+
+
Nenhum passo definido
+
Clique em "Novo Passo" para adicionar o primeiro passo
+
+ {:else if localSteps && localSteps.length > 0}
+
+ {#each localSteps as step, index (step._id)}
+
+
+
+
+ {index + 1}
+
+
selectedStepId = step._id}
+ onkeydown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ selectedStepId = step._id;
+ }
+ }}
+ role="button"
+ tabindex="0"
+ >
+
{step.name}
+ {#if step.description}
+
{step.description}
+ {/if}
+
+
+
+
+
+ {step.setorNome ?? 'Setor não definido'}
+
+
+
+
+
+ {step.expectedDuration} dia{step.expectedDuration > 1 ? 's' : ''}
+
+
+
+
+
moveStepUp(index)}
+ disabled={index === 0 || isDragging}
+ aria-label="Mover passo para cima"
+ >
+
+
+
+
+
moveStepDown(index)}
+ disabled={index === localSteps.length - 1 || isDragging}
+ aria-label="Mover passo para baixo"
+ >
+
+
+
+
+
+
+
+
+ {/each}
+
+ {/if}
+
+
+
+
+ {#if selectedStep && editingStep}
+
+
+
Editar Passo
+
selectedStepId = null}
+ aria-label="Fechar edição"
+ >
+
+
+
+
+
+
+
+
+ Nome
+
+
+
+
+
+
+ Descrição
+
+
+
+
+
+
+ Duração Esperada (dias)
+
+
+
+
+
+
+ Setor Responsável
+
+
+ {#if setoresQuery.data}
+ {#each setoresQuery.data as setor (setor._id)}
+ {setor.nome} ({setor.sigla})
+ {/each}
+ {/if}
+
+
+
+
+
+
+
+
+
+
+ Excluir
+
+
+ {#if isSavingStep}
+
+ {/if}
+ Salvar
+
+
+
+ {:else}
+
+
+
+
+
Selecione um passo
+
Clique em um passo para editar seus detalhes
+
+ {/if}
+
+
+
+
+
+{#if showNewStepModal}
+
+
+
Novo Passo
+
+ {#if stepError}
+
+ {/if}
+
+
{ e.preventDefault(); handleCreateStep(); }} class="mt-4 space-y-4">
+
+
+ Nome do Passo
+
+
+
+
+
+
+ Descrição (opcional)
+
+
+
+
+
+
+ Duração Esperada (dias)
+
+
+
+
+
+
+ Setor Responsável
+
+
+ Selecione um setor
+ {#if setoresQuery.data}
+ {#each setoresQuery.data as setor (setor._id)}
+ {setor.nome} ({setor.sigla})
+ {/each}
+ {/if}
+
+
+
+
+
+ Cancelar
+
+
+ {#if isCreatingStep}
+
+ {/if}
+ Criar Passo
+
+
+
+
+
+
+{/if}
+
+
+{#if showSubEtapaModal}
+
+
+
Nova Sub-etapa
+
+ {#if subEtapaError}
+
+
+
+
+
{subEtapaError}
+
+ {/if}
+
+
{ e.preventDefault(); handleCriarSubEtapa(); }} class="mt-4 space-y-4">
+
+
+ Nome da Sub-etapa
+
+
+
+
+
+
+ Descrição (opcional)
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isCriandoSubEtapa}
+
+ {/if}
+ Criar Sub-etapa
+
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
new file mode 100644
index 0000000..96b96a7
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
@@ -0,0 +1,373 @@
+
+
+
+
+
+
+
+
+
+
+
+ Instâncias de Fluxo
+
+
+ Acompanhe e gerencie as execuções de fluxos de trabalho. Visualize o progresso,
+ documentos e responsáveis de cada etapa.
+
+
+
+
+
+ Todos os status
+ Em Andamento
+ Concluído
+ Cancelado
+
+
+
+
+
+
+
+ Nova Instância
+
+
+
+
+
+
+
+
+ {#if instancesQuery.isLoading}
+
+
+
+ {:else if !instancesQuery.data || instancesQuery.data.length === 0}
+
+
+
+
+
Nenhuma instância encontrada
+
+ {statusFilter ? 'Não há instâncias com este status.' : 'Clique em "Nova Instância" para iniciar um fluxo.'}
+
+
+ {:else}
+
+
+
+
+ Template
+ Alvo
+ Gerente
+ Progresso
+ Status
+ Iniciado em
+ Ações
+
+
+
+ {#each instancesQuery.data as instance (instance._id)}
+ {@const statusBadge = getStatusBadge(instance.status)}
+ {@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
+
+
+ {instance.templateName ?? 'Template desconhecido'}
+
+
+
+ {instance.targetType}
+ {instance.targetId}
+
+
+ {instance.managerName ?? '-'}
+
+
+
+
+ {instance.progress.completed}/{instance.progress.total}
+
+
+
+
+ {statusBadge.label}
+
+ {formatDate(instance.startedAt)}
+
+
+
+
+
+
+ Ver
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateModal}
+
+
+
Nova Instância de Fluxo
+
+ {#if createError}
+
+ {/if}
+
+
{ e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
+
+
+
+
+
+
+ Gerente Responsável
+
+
+ Selecione um gerente
+ {#if usuariosQuery.data}
+ {#each usuariosQuery.data as usuario (usuario._id)}
+ {usuario.nome}
+ {/each}
+ {/if}
+
+
+
+
+
+ Cancelar
+
+
+ {#if isCreating}
+
+ {/if}
+ Iniciar Fluxo
+
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
index dce4f1c..a318bc1 100644
--- a/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
@@ -1,7 +1,8 @@
@@ -16,7 +17,16 @@
-
+
+
+
Licitações
+
+ Gerencie empresas, contratos e processos licitatórios
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Fluxos de Trabalho
+
+
Gerencie templates e fluxos de trabalho para contratos e processos
+
+
+
+
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
new file mode 100644
index 0000000..a78a8bd
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
@@ -0,0 +1,365 @@
+
+
+
+
+
+
+
+
+
+
+
+ Fluxos de Trabalho
+
+
+ Acompanhe e gerencie os fluxos de trabalho. Visualize o progresso,
+ documentos e responsáveis de cada etapa.
+
+
+
+
+
+ Todos os status
+ Em Andamento
+ Concluído
+ Cancelado
+
+
+
+
+
+
+
+ Novo Fluxo
+
+
+
+
+
+
+
+
+ {#if instancesQuery.isLoading}
+
+
+
+ {:else if !instancesQuery.data || instancesQuery.data.length === 0}
+
+
+
+
+
Nenhum fluxo encontrado
+
+ {statusFilter ? 'Não há fluxos com este status.' : 'Clique em "Novo Fluxo" para iniciar um fluxo.'}
+
+
+ {:else}
+
+
+
+
+ Template
+ Contrato
+ Gerente
+ Progresso
+ Status
+ Iniciado em
+ Ações
+
+
+
+ {#each instancesQuery.data as instance (instance._id)}
+ {@const statusBadge = getStatusBadge(instance.status)}
+ {@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
+
+
+ {instance.templateName ?? 'Template desconhecido'}
+
+
+ {#if instance.contratoId}
+ {instance.contratoId}
+ {:else}
+ -
+ {/if}
+
+ {instance.managerName ?? '-'}
+
+
+
+
+ {instance.progress.completed}/{instance.progress.total}
+
+
+
+
+ {statusBadge.label}
+
+ {formatDate(instance.startedAt)}
+
+
+
+
+
+
+ Ver
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateModal}
+
+
+
Novo Fluxo de Trabalho
+
+ {#if createError}
+
+ {/if}
+
+
{ e.preventDefault(); handleCreate(); }} class="mt-4 space-y-4">
+
+
+
+
+
+
+ Gerente Responsável
+
+
+ Selecione um gerente
+ {#if usuariosQuery.data}
+ {#each usuariosQuery.data as usuario (usuario._id)}
+ {usuario.nome}
+ {/each}
+ {/if}
+
+
+
+
+
+ Cancelar
+
+
+ {#if isCreating}
+
+ {/if}
+ Iniciar Fluxo
+
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
new file mode 100644
index 0000000..f8fd33e
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
@@ -0,0 +1,1251 @@
+
+
+
+ {#if instanceQuery.isLoading}
+
+
+
+ {:else if !instanceQuery.data}
+
+
+
+
+
Fluxo não encontrado
+
Voltar para lista
+
+ {:else}
+ {@const instance = instanceQuery.data.instance}
+ {@const steps = instanceQuery.data.steps}
+ {@const statusBadge = getInstanceStatusBadge(instance.status)}
+
+
+
+
+
+
+
+
{
+ if (typeof window !== 'undefined' && window.history.length > 1) {
+ window.history.back();
+ } else {
+ goto(resolve('/licitacoes/fluxos'));
+ }
+ }}
+ aria-label="Voltar para página anterior"
+ >
+
+
+
+ Voltar
+
+
{statusBadge.label}
+
+
+
+
+
+ {instance.templateName ?? 'Fluxo'}
+
+
+ {#if instance.contratoId}
+
+ Contrato
+ {instance.contratoId}
+
+ {/if}
+
+
+
+
+
+
Gerente: {instance.managerName ?? '-'}
+
+
+
+
+
+
+
+
+
+
+
+ Iniciado: {formatDate(instance.startedAt)}
+
+
+
+
+
+
+
+ {#if instance.status === 'active'}
+
+ showCancelModal = true}>
+
+
+
+ Cancelar Fluxo
+
+
+ {/if}
+
+
+
+
+
+ {#if processingError}
+
+
+
+
+
{processingError}
+
processingError = null}>Fechar
+
+ {/if}
+
+
+
+ Timeline do Fluxo
+
+
+ {#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)}
+ {@const subEtapasQuery = useQuery(api.flows.listarSubEtapas, () => ({ flowInstanceStepId: step._id }))}
+ {@const subEtapas = subEtapasQuery.data}
+ {@const subEtapasCount = subEtapas?.length ?? 0}
+ {@const subEtapasCompleted = subEtapas?.filter((s: { status: string }) => s.status === 'completed').length ?? 0}
+
+
+
+ {#if index < steps.length - 1}
+
+ {/if}
+
+
+
+ {#if step.status === 'completed'}
+
+
+
+ {:else if step.status === 'blocked'}
+
+
+
+ {:else}
+
{index + 1}
+ {/if}
+
+
+
+
+
+
+
+
+
{step.stepName}
+ {stepStatus.label}
+
+
+ {#if step.stepDescription}
+
{step.stepDescription}
+ {/if}
+
+
+
+
+
+ {step.setorNome ?? 'Setor não definido'}
+
+ {#if step.assignedToName}
+
+
+
+
+ {step.assignedToName}
+
+ {/if}
+ {#if step.dueDate}
+
+
+
+
+ Prazo: {formatDate(step.dueDate)}
+
+ {/if}
+
+
+
+
+ {#if instance.status === 'active'}
+
+ {#if step.status === 'pending'}
+
+ handleStartStep(step._id)}
+ disabled={isProcessing}
+ >
+ Iniciar
+
+
+ {:else if step.status === 'in_progress'}
+
+ handleCompleteStep(step._id)}
+ disabled={isProcessing}
+ >
+ Concluir
+
+ handleBlockStep(step._id)}
+ disabled={isProcessing}
+ >
+ Bloquear
+
+
+ {:else if step.status === 'blocked'}
+
+ handleStartStep(step._id)}
+ disabled={isProcessing}
+ >
+ Desbloquear
+
+
+ {/if}
+
+
+ openReassignModal(step)}
+ aria-label="Reatribuir responsável"
+ title="Reatribuir responsável"
+ >
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Sub-etapas
+ {#if subEtapasCount > 0}
+
+ {subEtapasCompleted} / {subEtapasCount} concluídas
+
+ {/if}
+
+ {#if instance.status === 'active'}
+
openSubEtapaModal(step._id)}
+ aria-label="Adicionar sub-etapa"
+ >
+
+
+
+ Adicionar
+
+ {/if}
+
+
+ {#if subEtapasQuery.isLoading}
+
+
+
+ {:else if subEtapas && subEtapas.length > 0}
+
+ {#each subEtapas as subEtapa (subEtapa._id)}
+
+
+
+
{subEtapa.name}
+
+ {subEtapa.status === 'completed' ? 'Concluída' : subEtapa.status === 'in_progress' ? 'Em Andamento' : subEtapa.status === 'blocked' ? 'Bloqueada' : 'Pendente'}
+
+
+ {#if subEtapa.description}
+
{subEtapa.description}
+ {/if}
+
+
+ {#if instance.status === 'active'}
+
handleAtualizarStatusSubEtapa(subEtapa._id, e.currentTarget.value as 'pending' | 'in_progress' | 'completed' | 'blocked')}
+ >
+ Pendente
+ Em Andamento
+ Concluída
+ Bloqueada
+
+
handleDeletarSubEtapa(subEtapa._id)}
+ aria-label="Deletar sub-etapa"
+ >
+
+
+
+
+ {/if}
+
+
+ {/each}
+
+ {:else}
+
+ Nenhuma sub-etapa adicionada
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Documentos
+ {#if step.documents && step.documents.length > 0}
+
{step.documents.length}
+ {/if}
+
+ {#if instance.status === 'active'}
+
+ openUploadModal(step)}
+ aria-label="Upload de documento"
+ >
+
+
+
+ Enviar
+
+
+ {/if}
+
+
+ {#if step.documents && step.documents.length > 0}
+
+ {#each step.documents as doc (doc._id)}
+
+
+
+
+
+ {#if instance.status === 'active'}
+
+ handleDeleteDocument(doc._id)}
+ aria-label="Excluir documento {doc.name}"
+ >
+
+
+
+
+
+ {/if}
+
+ {/each}
+
+ {:else}
+
+ Nenhum documento anexado
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Notas e Comentários
+ {#if step.notes}
+
Atualizado
+ {/if}
+
+
openNotesModal(step)}
+ aria-label="Editar notas"
+ >
+
+
+
+ {step.notes ? 'Editar' : 'Adicionar'}
+
+
+
+ {#if step.notes}
+
+
{step.notes}
+ {#if step.notesUpdatedByName || step.notesUpdatedAt}
+
+
+
+
+ {#if step.notesUpdatedByName}
+
Atualizado por {step.notesUpdatedByName}
+ {/if}
+ {#if step.notesUpdatedAt}
+
•
+
{formatDate(step.notesUpdatedAt)}
+ {/if}
+
+ {/if}
+
+ {:else}
+
+ Nenhuma nota ou comentário adicionado
+
+ {/if}
+
+
+
+ {#if step.startedAt || step.finishedAt}
+
+ {#if step.startedAt}
+
+
+
+
+
+ Iniciado: {formatDate(step.startedAt)}
+
+ {/if}
+ {#if step.finishedAt}
+
+
+
+
+ Concluído: {formatDate(step.finishedAt)}
+
+ {/if}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if showAlterarGestorModal}
+
+
+
Alterar Gestor do Fluxo
+
+ Selecione o novo gestor responsável por este fluxo
+
+
+ {#if alterarGestorError}
+
+
+
+
+
{alterarGestorError}
+
+ {/if}
+
+
+
+ Novo Gestor
+
+
+ Selecione um usuário
+ {#if usuariosQuery.data}
+ {#each usuariosQuery.data as usuario (usuario._id)}
+ {usuario.nome}
+ {/each}
+ {/if}
+
+
+
+
+
+ Cancelar
+
+
+ {#if isAlterandoGestor}
+
+ {/if}
+ Alterar
+
+
+
+
+
+ {/if}
+
+
+ {#if showSubEtapaModal && stepIdParaSubEtapas}
+
+
+
Nova Sub-etapa
+
+ {#if subEtapaError}
+
+
+
+
+
{subEtapaError}
+
+ {/if}
+
+
{ e.preventDefault(); handleCriarSubEtapa(); }} class="mt-4 space-y-4">
+
+
+ Nome da Sub-etapa
+
+
+
+
+
+
+ Descrição (opcional)
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isCriandoSubEtapa}
+
+ {/if}
+ Criar Sub-etapa
+
+
+
+
+
+
+ {/if}
+
+
+ {#if toastMessage}
+
+
+
{toastMessage}
+
(toastMessage = null)}
+ aria-label="Fechar notificação"
+ >
+
+
+
+
+
+
+ {/if}
+
+
+
+{#if showReassignModal && stepToReassign}
+
+
+
Reatribuir Responsável
+
+ Selecione o novo responsável pelo passo {stepToReassign.stepName}
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Reatribuir
+
+
+
+
+
+{/if}
+
+
+{#if showNotesModal && stepForNotes}
+
+
+
Notas do Passo
+
+ Adicione ou edite notas para o passo {stepForNotes.stepName}
+
+
+
+
+ Notas
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Salvar
+
+
+
+
+
+{/if}
+
+
+{#if showUploadModal && stepForUpload}
+
+
+
Upload de Documento
+
+ Anexe um documento ao passo {stepForUpload.stepName}
+
+
+
+
+ Arquivo
+
+
+
+
+ {#if uploadFile}
+
+ Arquivo selecionado: {uploadFile.name}
+
+ {/if}
+
+
+
+ Cancelar
+
+
+ {#if isUploading}
+
+ {/if}
+ Enviar
+
+
+
+
+
+{/if}
+
+
+{#if showCancelModal}
+
+
+
Cancelar Fluxo
+
+ Tem certeza que deseja cancelar este fluxo?
+
+
+ Esta ação não pode ser desfeita. Todos os passos pendentes serão marcados como cancelados.
+
+
+
+ showCancelModal = false} disabled={isProcessing}>
+ Voltar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Cancelar Fluxo
+
+
+
+
showCancelModal = false} aria-label="Fechar modal">
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte
index 3da1d68..d294ee3 100644
--- a/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/programas-esportivos/+page.svelte
@@ -1,5 +1,5 @@
@@ -56,6 +56,23 @@
+
+
+
+
+
+
+
+
Fluxos de Trabalho
+
+
+ Gerencie templates e instâncias de fluxos de trabalho para programas e projetos esportivos.
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte
index 515fd6a..ca05422 100644
--- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte
@@ -1,23 +1,42 @@
-
+
-
+
@@ -118,7 +186,7 @@
-
+
-
+
Nome
CPF
@@ -277,7 +345,7 @@
{:else}
- {#each filtered as f}
+ {#each filtered as f (f._id)}
{f.nome}
{f.cpf}
@@ -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"
>
-
+
Ver Detalhes
-
+
Editar
-
+
Ver Documentos
+
+ openSetoresModal(f._id, f.nome)}
+ class="hover:bg-primary/10"
+ >
+ Atribuir Setores
+
+
openPrintModal(f._id)} class="hover:bg-primary/10">
Imprimir Ficha
@@ -347,7 +423,7 @@
-
+
Exibindo {filtered.length} de {list.length} funcionário(s)
@@ -359,4 +435,85 @@
onClose={() => (funcionarioParaImprimir = null)}
/>
{/if}
+
+
+ {#if showSetoresModal && funcionarioParaSetores}
+
+
+
Atribuir Setores
+
+ Selecione os setores para {funcionarioParaSetores.nome}
+
+
+ {#if setoresError}
+
+ {/if}
+
+
+ {#if todosSetoresQuery.isLoading}
+
+
+
+ {:else if todosSetoresQuery.data && todosSetoresQuery.data.length > 0}
+
+ {#each todosSetoresQuery.data as setor (setor._id)}
+ {@const isSelected = setoresSelecionados.includes(setor._id)}
+
+ toggleSetor(setor._id)}
+ aria-label="Selecionar setor {setor.nome}"
+ />
+
+
{setor.nome}
+
Sigla: {setor.sigla}
+
+
+ {/each}
+
+ {:else}
+
+
Nenhum setor cadastrado
+
+ {/if}
+
+
+
+
+ Cancelar
+
+
+ {#if isSavingSetores}
+
+ {/if}
+ Salvar
+
+
+
+
+
+ {/if}
diff --git a/apps/web/src/routes/(dashboard)/ti/+page.svelte b/apps/web/src/routes/(dashboard)/ti/+page.svelte
index b3c78d0..faa37e5 100644
--- a/apps/web/src/routes/(dashboard)/ti/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/ti/+page.svelte
@@ -13,7 +13,8 @@
| 'teams'
| 'userPlus'
| 'clock'
- | 'video';
+ | 'video'
+ | 'building';
type PaletteKey = 'primary' | 'success' | 'secondary' | 'accent' | 'info' | 'error' | 'warning';
type TiRouteId =
@@ -30,7 +31,8 @@
| '/(dashboard)/ti/monitoramento'
| '/(dashboard)/ti/configuracoes-ponto'
| '/(dashboard)/ti/configuracoes-relogio'
- | '/(dashboard)/ti/configuracoes-jitsi';
+ | '/(dashboard)/ti/configuracoes-jitsi'
+ | '/(dashboard)/configuracoes/setores';
type FeatureCard = {
title: string;
@@ -211,6 +213,13 @@
strokeLinecap: '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' }
]
},
+ {
+ 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',
description:
diff --git a/bun.lock b/bun.lock
index 0f9a7b8..1f58467 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "sgse-app",
@@ -18,6 +19,7 @@
"jiti": "^2.6.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
+ "svelte-dnd-action": "^0.9.67",
"turbo": "^2.5.8",
"typescript-eslint": "^8.46.3",
},
@@ -66,6 +68,7 @@
"postcss": "^8.5.6",
"svelte": "^5.38.1",
"svelte-check": "^4.3.1",
+ "svelte-dnd-action": "^0.9.67",
"tailwindcss": "^4.1.12",
"typescript": "catalog:",
"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-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-sonner": ["svelte-sonner@1.0.5", "", { "dependencies": { "runed": "^0.28.0" }, "peerDependencies": { "svelte": "^5.0.0" } }, "sha512-9dpGPFqKb/QWudYqGnEz93vuY+NgCEvyNvxoCLMVGw6sDN/3oVeKV1xiEirW2E1N3vJEyj5imSBNOGltQHA7mg=="],
diff --git a/package.json b/package.json
index bb85f3d..6748b71 100644
--- a/package.json
+++ b/package.json
@@ -32,6 +32,7 @@
"jiti": "^2.6.1",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1",
+ "svelte-dnd-action": "^0.9.67",
"turbo": "^2.5.8",
"typescript-eslint": "^8.46.3"
},
diff --git a/packages/backend/convex/_generated/api.d.ts b/packages/backend/convex/_generated/api.d.ts
index 0914ef0..2559da4 100644
--- a/packages/backend/convex/_generated/api.d.ts
+++ b/packages/backend/convex/_generated/api.d.ts
@@ -35,6 +35,7 @@ import type * as email from "../email.js";
import type * as empresas from "../empresas.js";
import type * as enderecosMarcacao from "../enderecosMarcacao.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 funcionarios from "../funcionarios.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 security from "../security.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 templatesMensagens from "../templatesMensagens.js";
import type * as times from "../times.js";
@@ -93,6 +95,7 @@ declare const fullApi: ApiFromModules<{
empresas: typeof empresas;
enderecosMarcacao: typeof enderecosMarcacao;
ferias: typeof ferias;
+ flows: typeof flows;
funcionarioEnderecos: typeof funcionarioEnderecos;
funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck;
@@ -109,6 +112,7 @@ declare const fullApi: ApiFromModules<{
saldoFerias: typeof saldoFerias;
security: typeof security;
seed: typeof seed;
+ setores: typeof setores;
simbolos: typeof simbolos;
templatesMensagens: typeof templatesMensagens;
times: typeof times;
diff --git a/packages/backend/convex/flows.ts b/packages/backend/convex/flows.ts
new file mode 100644
index 0000000..0749140
--- /dev/null
+++ b/packages/backend/convex/flows.ts
@@ -0,0 +1,2161 @@
+import { query, mutation } from './_generated/server';
+import { internal } from './_generated/api';
+import { v } from 'convex/values';
+import { getCurrentUserFunction } from './auth';
+import type { Id, Doc } from './_generated/dataModel';
+import type { MutationCtx, QueryCtx } from './_generated/server';
+import { flowTemplateStatus, flowInstanceStatus, flowInstanceStepStatus } from './schema';
+
+// ============================================
+// HELPER FUNCTIONS
+// ============================================
+
+/**
+ * Criar notificações para todos os funcionários de um setor
+ */
+async function criarNotificacaoParaSetor(
+ ctx: MutationCtx,
+ setorId: Id<'setores'>,
+ titulo: string,
+ descricao: string
+): Promise {
+ // Buscar funcionários do setor
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', setorId))
+ .collect();
+
+ // Para cada funcionário, buscar usuário correspondente e criar notificação
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (!funcionario) continue;
+
+ // Buscar usuário por email
+ const usuarios = await ctx.db.query('usuarios').collect();
+ const usuario = usuarios.find((u: Doc<'usuarios'>) => u.email === funcionario.email);
+
+ if (usuario) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: usuario._id,
+ tipo: 'etapa_fluxo_concluida',
+ titulo,
+ descricao,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+ }
+}
+
+/**
+ * Obter a etapa anterior de um passo
+ */
+async function obterEtapaAnterior(
+ ctx: MutationCtx | QueryCtx,
+ instanceStepId: Id<'flowInstanceSteps'>
+): Promise | null> {
+ const step = await ctx.db.get(instanceStepId);
+ if (!step) {
+ return null;
+ }
+
+ // Buscar todos os passos da instância
+ const allSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', step.flowInstanceId))
+ .collect();
+
+ // Obter posições de cada passo
+ const stepsWithPosition: Array<{ step: Doc<'flowInstanceSteps'>; position: number }> = [];
+ for (const s of allSteps) {
+ const flowStep = await ctx.db.get(s.flowStepId);
+ if (flowStep) {
+ stepsWithPosition.push({ step: s, position: flowStep.position });
+ }
+ }
+
+ // Ordenar por posição
+ stepsWithPosition.sort((a, b) => a.position - b.position);
+
+ // Encontrar posição do passo atual
+ const currentIndex = stepsWithPosition.findIndex((s) => s.step._id === instanceStepId);
+
+ // Se for o primeiro passo, não há etapa anterior
+ if (currentIndex <= 0) {
+ return null;
+ }
+
+ // Retornar etapa anterior
+ return stepsWithPosition[currentIndex - 1].step;
+}
+
+/**
+ * Verificar se usuário pertence a algum setor do fluxo
+ */
+async function usuarioPertenceAAlgumSetorDoFluxo(
+ ctx: QueryCtx | MutationCtx,
+ usuarioId: Id<'usuarios'>,
+ flowInstanceId: Id<'flowInstances'>
+): Promise {
+ // Buscar usuário
+ const usuario = await ctx.db.get(usuarioId);
+ if (!usuario) {
+ return false;
+ }
+
+ // Buscar funcionário por email
+ const funcionarios = await ctx.db.query('funcionarios').collect();
+ const funcionario = funcionarios.find((f) => f.email === usuario.email);
+ if (!funcionario) {
+ return false;
+ }
+
+ // Buscar setores do funcionário
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
+ .collect();
+
+ const setoresDoFuncionario = new Set>();
+ for (const relacao of funcionarioSetores) {
+ setoresDoFuncionario.add(relacao.setorId);
+ }
+
+ // Buscar todos os passos do fluxo
+ const instanceSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', flowInstanceId))
+ .collect();
+
+ // Extrair setores do fluxo
+ const setoresDoFluxo = new Set>();
+ for (const step of instanceSteps) {
+ setoresDoFluxo.add(step.setorId);
+ }
+
+ // Verificar interseção
+ for (const setorId of setoresDoFuncionario) {
+ if (setoresDoFluxo.has(setorId)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Verificar se usuário tem permissão para ver todas as instâncias de fluxo
+ */
+async function verificarPermissaoVerTodasFluxos(ctx: QueryCtx | MutationCtx): Promise {
+ try {
+ await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
+ recurso: 'fluxos_instancias',
+ acao: 'listar'
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// ============================================
+// FLOW TEMPLATES - CRUD
+// ============================================
+
+/**
+ * Listar todos os templates de fluxo
+ */
+export const listTemplates = query({
+ args: {
+ status: v.optional(flowTemplateStatus)
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowTemplates'),
+ _creationTime: v.number(),
+ name: v.string(),
+ description: v.optional(v.string()),
+ status: flowTemplateStatus,
+ createdBy: v.id('usuarios'),
+ createdAt: v.number(),
+ createdByName: v.optional(v.string()),
+ stepsCount: v.number()
+ })
+ ),
+ handler: async (ctx, args) => {
+ let templates;
+
+ if (args.status) {
+ templates = await ctx.db
+ .query('flowTemplates')
+ .withIndex('by_status', (q) => q.eq('status', args.status!))
+ .order('desc')
+ .collect();
+ } else {
+ templates = await ctx.db.query('flowTemplates').order('desc').collect();
+ }
+
+ const result: Array<{
+ _id: Id<'flowTemplates'>;
+ _creationTime: number;
+ name: string;
+ description: string | undefined;
+ status: Doc<'flowTemplates'>['status'];
+ createdBy: Id<'usuarios'>;
+ createdAt: number;
+ createdByName: string | undefined;
+ stepsCount: number;
+ }> = [];
+
+ for (const template of templates) {
+ const creator = await ctx.db.get(template.createdBy);
+ const steps = await ctx.db
+ .query('flowSteps')
+ .withIndex('by_flowTemplateId', (q) => q.eq('flowTemplateId', template._id))
+ .collect();
+
+ result.push({
+ _id: template._id,
+ _creationTime: template._creationTime,
+ name: template.name,
+ description: template.description,
+ status: template.status,
+ createdBy: template.createdBy,
+ createdAt: template.createdAt,
+ createdByName: creator?.nome,
+ stepsCount: steps.length
+ });
+ }
+
+ return result;
+ }
+});
+
+/**
+ * Obter um template de fluxo pelo ID
+ */
+export const getTemplate = query({
+ args: { id: v.id('flowTemplates') },
+ returns: v.union(
+ v.object({
+ _id: v.id('flowTemplates'),
+ _creationTime: v.number(),
+ name: v.string(),
+ description: v.optional(v.string()),
+ status: flowTemplateStatus,
+ createdBy: v.id('usuarios'),
+ createdAt: v.number(),
+ createdByName: v.optional(v.string())
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const template = await ctx.db.get(args.id);
+ if (!template) return null;
+
+ const creator = await ctx.db.get(template.createdBy);
+ return {
+ ...template,
+ createdByName: creator?.nome
+ };
+ }
+});
+
+/**
+ * Criar um novo template de fluxo
+ */
+export const createTemplate = mutation({
+ args: {
+ name: v.string(),
+ description: v.optional(v.string())
+ },
+ returns: v.id('flowTemplates'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const templateId = await ctx.db.insert('flowTemplates', {
+ name: args.name,
+ description: args.description,
+ status: 'draft',
+ createdBy: usuario._id,
+ createdAt: Date.now()
+ });
+
+ return templateId;
+ }
+});
+
+/**
+ * Atualizar um template de fluxo
+ */
+export const updateTemplate = mutation({
+ args: {
+ id: v.id('flowTemplates'),
+ name: v.optional(v.string()),
+ description: v.optional(v.string()),
+ status: v.optional(flowTemplateStatus)
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const template = await ctx.db.get(args.id);
+ if (!template) {
+ throw new Error('Template não encontrado');
+ }
+
+ const updates: Partial> = {};
+ if (args.name !== undefined) updates.name = args.name;
+ if (args.description !== undefined) updates.description = args.description;
+ if (args.status !== undefined) updates.status = args.status;
+
+ await ctx.db.patch(args.id, updates);
+ return null;
+ }
+});
+
+/**
+ * Excluir um template de fluxo
+ */
+export const deleteTemplate = mutation({
+ args: { id: v.id('flowTemplates') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const template = await ctx.db.get(args.id);
+ if (!template) {
+ throw new Error('Template não encontrado');
+ }
+
+ // Verificar se há instâncias vinculadas
+ const instancias = await ctx.db
+ .query('flowInstances')
+ .withIndex('by_flowTemplateId', (q) => q.eq('flowTemplateId', args.id))
+ .first();
+ if (instancias) {
+ throw new Error('Não é possível excluir um template com instâncias vinculadas');
+ }
+
+ // Excluir todos os passos do template
+ const steps = await ctx.db
+ .query('flowSteps')
+ .withIndex('by_flowTemplateId', (q) => q.eq('flowTemplateId', args.id))
+ .collect();
+ for (const step of steps) {
+ await ctx.db.delete(step._id);
+ }
+
+ await ctx.db.delete(args.id);
+ return null;
+ }
+});
+
+// ============================================
+// FLOW STEPS - CRUD
+// ============================================
+
+/**
+ * Listar passos de um template
+ */
+export const listStepsByTemplate = query({
+ args: { flowTemplateId: v.id('flowTemplates') },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowSteps'),
+ _creationTime: v.number(),
+ flowTemplateId: v.id('flowTemplates'),
+ name: v.string(),
+ description: v.optional(v.string()),
+ position: v.number(),
+ expectedDuration: v.number(),
+ setorId: v.id('setores'),
+ setorNome: v.optional(v.string()),
+ defaultAssigneeId: v.optional(v.id('usuarios')),
+ defaultAssigneeName: v.optional(v.string()),
+ requiredDocuments: v.optional(v.array(v.string()))
+ })
+ ),
+ handler: async (ctx, args) => {
+ const steps = await ctx.db
+ .query('flowSteps')
+ .withIndex('by_flowTemplateId', (q) => q.eq('flowTemplateId', args.flowTemplateId))
+ .collect();
+
+ // Ordenar por position
+ steps.sort((a, b) => a.position - b.position);
+
+ const result: Array<{
+ _id: Id<'flowSteps'>;
+ _creationTime: number;
+ flowTemplateId: Id<'flowTemplates'>;
+ name: string;
+ description: string | undefined;
+ position: number;
+ expectedDuration: number;
+ setorId: Id<'setores'>;
+ setorNome: string | undefined;
+ defaultAssigneeId: Id<'usuarios'> | undefined;
+ defaultAssigneeName: string | undefined;
+ requiredDocuments: string[] | undefined;
+ }> = [];
+
+ for (const step of steps) {
+ const setor = await ctx.db.get(step.setorId);
+ const assignee = step.defaultAssigneeId ? await ctx.db.get(step.defaultAssigneeId) : null;
+
+ result.push({
+ _id: step._id,
+ _creationTime: step._creationTime,
+ flowTemplateId: step.flowTemplateId,
+ name: step.name,
+ description: step.description,
+ position: step.position,
+ expectedDuration: step.expectedDuration,
+ setorId: step.setorId,
+ setorNome: setor?.nome,
+ defaultAssigneeId: step.defaultAssigneeId,
+ defaultAssigneeName: assignee?.nome,
+ requiredDocuments: step.requiredDocuments
+ });
+ }
+
+ return result;
+ }
+});
+
+/**
+ * Criar um novo passo
+ */
+export const createStep = mutation({
+ args: {
+ flowTemplateId: v.id('flowTemplates'),
+ name: v.string(),
+ description: v.optional(v.string()),
+ expectedDuration: v.number(),
+ setorId: v.id('setores'),
+ defaultAssigneeId: v.optional(v.id('usuarios')),
+ requiredDocuments: v.optional(v.array(v.string()))
+ },
+ returns: v.id('flowSteps'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // Verificar se o template existe
+ const template = await ctx.db.get(args.flowTemplateId);
+ if (!template) {
+ throw new Error('Template não encontrado');
+ }
+
+ // Verificar se o setor existe
+ const setor = await ctx.db.get(args.setorId);
+ if (!setor) {
+ throw new Error('Setor não encontrado');
+ }
+
+ // Obter a próxima posição
+ const existingSteps = await ctx.db
+ .query('flowSteps')
+ .withIndex('by_flowTemplateId', (q) => q.eq('flowTemplateId', args.flowTemplateId))
+ .collect();
+ const maxPosition = existingSteps.reduce((max, step) => Math.max(max, step.position), 0);
+
+ const stepId = await ctx.db.insert('flowSteps', {
+ flowTemplateId: args.flowTemplateId,
+ name: args.name,
+ description: args.description,
+ position: maxPosition + 1,
+ expectedDuration: args.expectedDuration,
+ setorId: args.setorId,
+ defaultAssigneeId: args.defaultAssigneeId,
+ requiredDocuments: args.requiredDocuments
+ });
+
+ return stepId;
+ }
+});
+
+/**
+ * Atualizar um passo
+ */
+export const updateStep = mutation({
+ args: {
+ id: v.id('flowSteps'),
+ name: v.optional(v.string()),
+ description: v.optional(v.string()),
+ expectedDuration: v.optional(v.number()),
+ setorId: v.optional(v.id('setores')),
+ defaultAssigneeId: v.optional(v.id('usuarios')),
+ requiredDocuments: v.optional(v.array(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 step = await ctx.db.get(args.id);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ const updates: Partial> = {};
+ if (args.name !== undefined) updates.name = args.name;
+ if (args.description !== undefined) updates.description = args.description;
+ if (args.expectedDuration !== undefined) updates.expectedDuration = args.expectedDuration;
+ if (args.setorId !== undefined) updates.setorId = args.setorId;
+ if (args.defaultAssigneeId !== undefined) updates.defaultAssigneeId = args.defaultAssigneeId;
+ if (args.requiredDocuments !== undefined) updates.requiredDocuments = args.requiredDocuments;
+
+ await ctx.db.patch(args.id, updates);
+ return null;
+ }
+});
+
+/**
+ * Reordenar passos
+ */
+export const reorderSteps = mutation({
+ args: {
+ flowTemplateId: v.id('flowTemplates'),
+ stepIds: v.array(v.id('flowSteps'))
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // Atualizar posições
+ for (let i = 0; i < args.stepIds.length; i++) {
+ await ctx.db.patch(args.stepIds[i], { position: i + 1 });
+ }
+
+ return null;
+ }
+});
+
+/**
+ * Excluir um passo
+ */
+export const deleteStep = mutation({
+ args: { id: v.id('flowSteps') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const step = await ctx.db.get(args.id);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ await ctx.db.delete(args.id);
+ return null;
+ }
+});
+
+// ============================================
+// FLOW INSTANCES
+// ============================================
+
+/**
+ * Verificar permissões do usuário para um fluxo
+ */
+export const verificarPermissoesFluxo = query({
+ args: { flowInstanceId: v.id('flowInstances') },
+ returns: v.object({
+ podeAtribuir: v.boolean(),
+ podeEditar: v.boolean(),
+ podeVer: v.boolean(),
+ eCriador: v.boolean(),
+ eGestor: v.boolean()
+ }),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return {
+ podeAtribuir: false,
+ podeEditar: false,
+ podeVer: false,
+ eCriador: false,
+ eGestor: false
+ };
+ }
+
+ const instance = await ctx.db.get(args.flowInstanceId);
+ if (!instance) {
+ return {
+ podeAtribuir: false,
+ podeEditar: false,
+ podeVer: false,
+ eCriador: false,
+ eGestor: false
+ };
+ }
+
+ // Verificar se é gestor
+ const eGestor = instance.managerId === usuario._id;
+
+ // Verificar se é criador do template
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const eCriador = template?.createdBy === usuario._id;
+
+ // Verificar permissão de ver todas
+ const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
+
+ // Verificar se pertence a setor do fluxo
+ const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
+ ctx,
+ usuario._id,
+ args.flowInstanceId
+ );
+
+ // Pode ver se: tem permissão, é gestor, é criador, ou pertence ao setor
+ const podeVer = temPermissaoVerTodas || eGestor || eCriador || pertenceAoSetor;
+
+ // Pode editar se: é criador ou tem permissão de ver todas
+ const podeEditar = eCriador || temPermissaoVerTodas;
+
+ // Pode atribuir se: é criador, é gestor, ou tem permissão de ver todas
+ const podeAtribuir = eCriador || eGestor || temPermissaoVerTodas;
+
+ return {
+ podeAtribuir,
+ podeEditar,
+ podeVer,
+ eCriador,
+ eGestor
+ };
+ }
+});
+
+/**
+ * Listar instâncias de fluxo
+ */
+export const listInstances = query({
+ args: {
+ status: v.optional(flowInstanceStatus),
+ contratoId: v.optional(v.id('contratos')),
+ managerId: v.optional(v.id('usuarios'))
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowInstances'),
+ _creationTime: v.number(),
+ flowTemplateId: v.id('flowTemplates'),
+ templateName: v.optional(v.string()),
+ contratoId: v.optional(v.id('contratos')),
+ managerId: v.id('usuarios'),
+ managerName: v.optional(v.string()),
+ status: flowInstanceStatus,
+ startedAt: v.number(),
+ finishedAt: v.optional(v.number()),
+ currentStepId: v.optional(v.id('flowInstanceSteps')),
+ currentStepName: v.optional(v.string()),
+ progress: v.object({
+ completed: v.number(),
+ total: v.number()
+ })
+ })
+ ),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return [];
+ }
+
+ // Verificar se usuário tem permissão para ver todas
+ const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
+
+ let instances;
+
+ if (args.status) {
+ instances = await ctx.db
+ .query('flowInstances')
+ .withIndex('by_status', (q) => q.eq('status', args.status!))
+ .order('desc')
+ .collect();
+ } else if (args.managerId) {
+ instances = await ctx.db
+ .query('flowInstances')
+ .withIndex('by_managerId', (q) => q.eq('managerId', args.managerId!))
+ .order('desc')
+ .collect();
+ } else if (args.contratoId) {
+ instances = await ctx.db
+ .query('flowInstances')
+ .withIndex('by_contratoId', (q) => q.eq('contratoId', args.contratoId!))
+ .order('desc')
+ .collect();
+ } else {
+ instances = await ctx.db.query('flowInstances').order('desc').collect();
+ }
+
+ const result: Array<{
+ _id: Id<'flowInstances'>;
+ _creationTime: number;
+ flowTemplateId: Id<'flowTemplates'>;
+ templateName: string | undefined;
+ contratoId: Id<'contratos'> | undefined;
+ managerId: Id<'usuarios'>;
+ managerName: string | undefined;
+ status: Doc<'flowInstances'>['status'];
+ startedAt: number;
+ finishedAt: number | undefined;
+ currentStepId: Id<'flowInstanceSteps'> | undefined;
+ currentStepName: string | undefined;
+ progress: { completed: number; total: number };
+ }> = [];
+
+ for (const instance of instances) {
+ // Se não tem permissão de ver todas, verificar se usuário pertence a algum setor do fluxo
+ if (!temPermissaoVerTodas) {
+ const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
+ ctx,
+ usuario._id,
+ instance._id
+ );
+ if (!pertenceAoSetor) {
+ // Também verificar se é o manager
+ if (instance.managerId !== usuario._id) {
+ continue; // Pular esta instância
+ }
+ }
+ }
+
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const manager = await ctx.db.get(instance.managerId);
+
+ // Obter passos da instância
+ const instanceSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', instance._id))
+ .collect();
+
+ const completedSteps = instanceSteps.filter((s) => s.status === 'completed').length;
+
+ // Obter nome do passo atual
+ let currentStepName: string | undefined;
+ if (instance.currentStepId) {
+ const currentStep = await ctx.db.get(instance.currentStepId);
+ if (currentStep) {
+ const flowStep = await ctx.db.get(currentStep.flowStepId);
+ currentStepName = flowStep?.name;
+ }
+ }
+
+ result.push({
+ _id: instance._id,
+ _creationTime: instance._creationTime,
+ flowTemplateId: instance.flowTemplateId,
+ templateName: template?.name,
+ contratoId: instance.contratoId,
+ managerId: instance.managerId,
+ managerName: manager?.nome,
+ status: instance.status,
+ startedAt: instance.startedAt,
+ finishedAt: instance.finishedAt,
+ currentStepId: instance.currentStepId,
+ currentStepName,
+ progress: {
+ completed: completedSteps,
+ total: instanceSteps.length
+ }
+ });
+ }
+
+ return result;
+ }
+});
+
+/**
+ * Obter uma instância de fluxo com seus passos
+ */
+export const getInstanceWithSteps = query({
+ args: { id: v.id('flowInstances') },
+ returns: v.union(
+ v.object({
+ instance: v.object({
+ _id: v.id('flowInstances'),
+ _creationTime: v.number(),
+ flowTemplateId: v.id('flowTemplates'),
+ templateName: v.optional(v.string()),
+ contratoId: v.optional(v.id('contratos')),
+ managerId: v.id('usuarios'),
+ managerName: v.optional(v.string()),
+ status: flowInstanceStatus,
+ startedAt: v.number(),
+ finishedAt: v.optional(v.number()),
+ currentStepId: v.optional(v.id('flowInstanceSteps')),
+ prazoTotalDias: v.number()
+ }),
+ steps: v.array(
+ v.object({
+ _id: v.id('flowInstanceSteps'),
+ _creationTime: v.number(),
+ flowInstanceId: v.id('flowInstances'),
+ flowStepId: v.id('flowSteps'),
+ stepName: v.string(),
+ stepDescription: v.optional(v.string()),
+ setorId: v.id('setores'),
+ setorNome: v.optional(v.string()),
+ assignedToId: v.optional(v.id('usuarios')),
+ assignedToName: v.optional(v.string()),
+ status: flowInstanceStepStatus,
+ startedAt: v.optional(v.number()),
+ finishedAt: v.optional(v.number()),
+ notes: v.optional(v.string()),
+ notesUpdatedBy: v.optional(v.id('usuarios')),
+ notesUpdatedByName: v.optional(v.string()),
+ notesUpdatedAt: v.optional(v.number()),
+ dueDate: v.optional(v.number()),
+ position: v.number(),
+ expectedDuration: v.number(),
+ documents: v.array(
+ v.object({
+ _id: v.id('flowInstanceDocuments'),
+ name: v.string(),
+ uploadedAt: v.number(),
+ uploadedByName: v.optional(v.string())
+ })
+ )
+ })
+ )
+ }),
+ v.null()
+ ),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return null;
+ }
+
+ const instance = await ctx.db.get(args.id);
+ if (!instance) return null;
+
+ // Verificar permissão de visualização
+ const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
+
+ if (!temPermissaoVerTodas) {
+ // Verificar se usuário pertence a algum setor do fluxo ou é o manager
+ const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
+ ctx,
+ usuario._id,
+ instance._id
+ );
+
+ if (!pertenceAoSetor && instance.managerId !== usuario._id) {
+ return null; // Usuário não tem acesso
+ }
+ }
+
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const manager = await ctx.db.get(instance.managerId);
+
+ // Obter passos da instância
+ const instanceSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', args.id))
+ .collect();
+
+ // Mapear passos com informações adicionais
+ const stepsWithDetails: Array<{
+ _id: Id<'flowInstanceSteps'>;
+ _creationTime: number;
+ flowInstanceId: Id<'flowInstances'>;
+ flowStepId: Id<'flowSteps'>;
+ stepName: string;
+ stepDescription: string | undefined;
+ setorId: Id<'setores'>;
+ setorNome: string | undefined;
+ assignedToId: Id<'usuarios'> | undefined;
+ assignedToName: string | undefined;
+ status: Doc<'flowInstanceSteps'>['status'];
+ startedAt: number | undefined;
+ finishedAt: number | undefined;
+ notes: string | undefined;
+ notesUpdatedBy: Id<'usuarios'> | undefined;
+ notesUpdatedByName: string | undefined;
+ notesUpdatedAt: number | undefined;
+ dueDate: number | undefined;
+ position: number;
+ expectedDuration: number;
+ documents: Array<{
+ _id: Id<'flowInstanceDocuments'>;
+ name: string;
+ uploadedAt: number;
+ uploadedByName: string | undefined;
+ }>;
+ }> = [];
+
+ for (const step of instanceSteps) {
+ const flowStep = await ctx.db.get(step.flowStepId);
+ const setor = await ctx.db.get(step.setorId);
+ const assignee = step.assignedToId ? await ctx.db.get(step.assignedToId) : null;
+
+ // Obter documentos do passo
+ const documents = await ctx.db
+ .query('flowInstanceDocuments')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', step._id))
+ .collect();
+
+ const docsWithUploader: Array<{
+ _id: Id<'flowInstanceDocuments'>;
+ name: string;
+ uploadedAt: number;
+ uploadedByName: string | undefined;
+ }> = [];
+ for (const doc of documents) {
+ const uploader = await ctx.db.get(doc.uploadedById);
+ docsWithUploader.push({
+ _id: doc._id,
+ name: doc.name,
+ uploadedAt: doc.uploadedAt,
+ uploadedByName: uploader?.nome
+ });
+ }
+
+ const notesUpdater = step.notesUpdatedBy ? await ctx.db.get(step.notesUpdatedBy) : null;
+ stepsWithDetails.push({
+ _id: step._id,
+ _creationTime: step._creationTime,
+ flowInstanceId: step.flowInstanceId,
+ flowStepId: step.flowStepId,
+ stepName: flowStep?.name ?? 'Passo desconhecido',
+ stepDescription: flowStep?.description,
+ setorId: step.setorId,
+ setorNome: setor?.nome,
+ assignedToId: step.assignedToId,
+ assignedToName: assignee?.nome,
+ status: step.status,
+ startedAt: step.startedAt,
+ finishedAt: step.finishedAt,
+ notes: step.notes,
+ notesUpdatedBy: step.notesUpdatedBy,
+ notesUpdatedByName: notesUpdater?.nome,
+ notesUpdatedAt: step.notesUpdatedAt,
+ dueDate: step.dueDate,
+ position: flowStep?.position ?? 0,
+ expectedDuration: flowStep?.expectedDuration ?? 0,
+ documents: docsWithUploader
+ });
+ }
+
+ // Ordenar por position
+ stepsWithDetails.sort((a, b) => a.position - b.position);
+
+ // Calcular prazo total do fluxo (soma de expectedDuration de todas as etapas)
+ const prazoTotalDias = stepsWithDetails.reduce((sum, step) => sum + step.expectedDuration, 0);
+
+ return {
+ instance: {
+ _id: instance._id,
+ _creationTime: instance._creationTime,
+ flowTemplateId: instance.flowTemplateId,
+ templateName: template?.name,
+ contratoId: instance.contratoId,
+ managerId: instance.managerId,
+ managerName: manager?.nome,
+ status: instance.status,
+ startedAt: instance.startedAt,
+ finishedAt: instance.finishedAt,
+ currentStepId: instance.currentStepId,
+ prazoTotalDias
+ },
+ steps: stepsWithDetails as Array<{
+ _id: Id<'flowInstanceSteps'>;
+ _creationTime: number;
+ flowInstanceId: Id<'flowInstances'>;
+ flowStepId: Id<'flowSteps'>;
+ stepName: string;
+ stepDescription: string | undefined;
+ setorId: Id<'setores'>;
+ setorNome: string | undefined;
+ assignedToId: Id<'usuarios'> | undefined;
+ assignedToName: string | undefined;
+ status: Doc<'flowInstanceSteps'>['status'];
+ startedAt: number | undefined;
+ finishedAt: number | undefined;
+ notes: string | undefined;
+ notesUpdatedBy: Id<'usuarios'> | undefined;
+ notesUpdatedByName: string | undefined;
+ notesUpdatedAt: number | undefined;
+ dueDate: number | undefined;
+ position: number;
+ expectedDuration: number;
+ documents: Array<{
+ _id: Id<'flowInstanceDocuments'>;
+ name: string;
+ uploadedAt: number;
+ uploadedByName: string | undefined;
+ }>;
+ }>
+ };
+ }
+});
+
+/**
+ * Instanciar um fluxo
+ */
+export const instantiateFlow = mutation({
+ args: {
+ flowTemplateId: v.id('flowTemplates'),
+ contratoId: v.optional(v.id('contratos')),
+ managerId: v.id('usuarios')
+ },
+ returns: v.id('flowInstances'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // Verificar se o template existe e está publicado
+ const template = await ctx.db.get(args.flowTemplateId);
+ if (!template) {
+ throw new Error('Template não encontrado');
+ }
+ if (template.status !== 'published') {
+ throw new Error('Apenas templates publicados podem ser instanciados');
+ }
+
+ // Obter passos do template
+ const templateSteps = await ctx.db
+ .query('flowSteps')
+ .withIndex('by_flowTemplateId', (q) => q.eq('flowTemplateId', args.flowTemplateId))
+ .collect();
+
+ if (templateSteps.length === 0) {
+ throw new Error('O template não possui passos definidos');
+ }
+
+ // Ordenar por position
+ templateSteps.sort((a, b) => a.position - b.position);
+
+ const now = Date.now();
+
+ // Criar a instância
+ const instanceId = await ctx.db.insert('flowInstances', {
+ flowTemplateId: args.flowTemplateId,
+ contratoId: args.contratoId,
+ managerId: args.managerId,
+ status: 'active',
+ startedAt: now
+ });
+
+ // Criar os passos da instância
+ let firstStepId: Id<'flowInstanceSteps'> | undefined;
+ let cumulativeDays = 0;
+
+ for (let i = 0; i < templateSteps.length; i++) {
+ const step = templateSteps[i];
+ const dueDate = now + cumulativeDays * 24 * 60 * 60 * 1000 + step.expectedDuration * 24 * 60 * 60 * 1000;
+ cumulativeDays += step.expectedDuration;
+
+ const instanceStepId = await ctx.db.insert('flowInstanceSteps', {
+ flowInstanceId: instanceId,
+ flowStepId: step._id,
+ setorId: step.setorId,
+ assignedToId: step.defaultAssigneeId,
+ status: i === 0 ? 'pending' : 'pending',
+ dueDate
+ });
+
+ if (i === 0) {
+ firstStepId = instanceStepId;
+ }
+ }
+
+ // Atualizar o currentStepId da instância
+ if (firstStepId) {
+ await ctx.db.patch(instanceId, { currentStepId: firstStepId });
+ }
+
+ // Notificar gestor sobre criação do fluxo
+ if (args.managerId) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: args.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Novo Fluxo Criado',
+ descricao: `Um novo fluxo "${template?.name ?? 'Fluxo'}" foi criado e você foi designado como gestor.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+
+ return instanceId;
+ }
+});
+
+/**
+ * Completar um passo da instância
+ */
+export const completeStep = mutation({
+ args: {
+ instanceStepId: v.id('flowInstanceSteps'),
+ notes: v.optional(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 step = await ctx.db.get(args.instanceStepId);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ if (step.status === 'completed') {
+ throw new Error('Este passo já foi concluído');
+ }
+
+ const instance = await ctx.db.get(step.flowInstanceId);
+ if (!instance) {
+ throw new Error('Instância não encontrada');
+ }
+
+ if (instance.status !== 'active') {
+ throw new Error('Não é possível completar passos de uma instância inativa');
+ }
+
+ const now = Date.now();
+
+ // Marcar passo como completado
+ await ctx.db.patch(args.instanceStepId, {
+ status: 'completed',
+ finishedAt: now,
+ notes: args.notes
+ });
+
+ // Obter todos os passos da instância
+ const allSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', step.flowInstanceId))
+ .collect();
+
+ // Encontrar o próximo passo pendente
+ const flowSteps: Array<{ stepId: Id<'flowInstanceSteps'>; position: number }> = [];
+ for (const s of allSteps) {
+ const flowStep = await ctx.db.get(s.flowStepId);
+ if (flowStep) {
+ flowSteps.push({ stepId: s._id, position: flowStep.position });
+ }
+ }
+ flowSteps.sort((a, b) => a.position - b.position);
+
+ const currentPosition = flowSteps.findIndex((s) => s.stepId === args.instanceStepId);
+ const nextStep = flowSteps[currentPosition + 1];
+
+ // Obter informações do template e do passo para as notificações
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const flowStep = await ctx.db.get(step.flowStepId);
+ const setorAtual = await ctx.db.get(step.setorId);
+
+ // Criar notificações para o setor do passo concluído
+ if (setorAtual && flowStep) {
+ const tituloSetorAtual = 'Etapa de Fluxo Concluída';
+ const descricaoSetorAtual = `A etapa "${flowStep.name}" do fluxo "${template?.name ?? 'Fluxo'}" foi concluída.`;
+ await criarNotificacaoParaSetor(ctx, step.setorId, tituloSetorAtual, descricaoSetorAtual);
+ }
+
+ // Notificar gestor sobre conclusão da etapa
+ if (instance.managerId) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: instance.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Etapa de Fluxo Concluída',
+ descricao: `A etapa "${flowStep?.name ?? 'Etapa'}" do fluxo "${template?.name ?? 'Fluxo'}" foi concluída.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+
+ if (nextStep) {
+ // Atualizar currentStepId para o próximo passo
+ await ctx.db.patch(step.flowInstanceId, { currentStepId: nextStep.stepId });
+
+ // Criar notificações para o setor do próximo passo
+ const nextStepData = await ctx.db.get(nextStep.stepId);
+ if (nextStepData) {
+ const nextFlowStep = await ctx.db.get(nextStepData.flowStepId);
+ const nextSetor = await ctx.db.get(nextStepData.setorId);
+
+ if (nextSetor && nextFlowStep) {
+ const tituloProximoSetor = 'Nova Etapa de Fluxo Disponível';
+ const descricaoProximoSetor = `A etapa "${nextFlowStep.name}" do fluxo "${template?.name ?? 'Fluxo'}" está pronta para ser iniciada.`;
+ await criarNotificacaoParaSetor(ctx, nextStepData.setorId, tituloProximoSetor, descricaoProximoSetor);
+ }
+ }
+ } else {
+ // Todos os passos concluídos, marcar instância como completada
+ await ctx.db.patch(step.flowInstanceId, {
+ status: 'completed',
+ finishedAt: now,
+ currentStepId: undefined
+ });
+
+ // Notificar gestor sobre conclusão do fluxo
+ if (instance.managerId) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: instance.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Fluxo Concluído',
+ descricao: `O fluxo "${template?.name ?? 'Fluxo'}" foi concluído com sucesso.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+ }
+
+ return null;
+ }
+});
+
+/**
+ * Atualizar status de um passo
+ */
+export const updateStepStatus = mutation({
+ args: {
+ instanceStepId: v.id('flowInstanceSteps'),
+ status: flowInstanceStepStatus
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const step = await ctx.db.get(args.instanceStepId);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ const updates: Partial> = { status: args.status };
+
+ if (args.status === 'in_progress' && !step.startedAt) {
+ updates.startedAt = Date.now();
+
+ // Notificar gestor sobre início da etapa
+ const instance = await ctx.db.get(step.flowInstanceId);
+ if (instance?.managerId) {
+ const flowStep = await ctx.db.get(step.flowStepId);
+ const template = await ctx.db.get(instance.flowTemplateId);
+ await ctx.db.insert('notificacoes', {
+ usuarioId: instance.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Etapa de Fluxo Iniciada',
+ descricao: `A etapa "${flowStep?.name ?? 'Etapa'}" do fluxo "${template?.name ?? 'Fluxo'}" foi iniciada.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+ }
+
+ await ctx.db.patch(args.instanceStepId, updates);
+ return null;
+ }
+});
+
+/**
+ * Alterar gestor do fluxo
+ */
+export const alterarGestorFluxo = mutation({
+ args: {
+ flowInstanceId: v.id('flowInstances'),
+ novoManagerId: v.id('usuarios')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const instance = await ctx.db.get(args.flowInstanceId);
+ if (!instance) {
+ throw new Error('Fluxo não encontrado');
+ }
+
+ // Verificar se usuário atual é gestor ou tem permissão
+ const eGestor = instance.managerId === usuario._id;
+ const temPermissao = await verificarPermissaoVerTodasFluxos(ctx);
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const eCriador = template?.createdBy === usuario._id;
+
+ if (!eGestor && !temPermissao && !eCriador) {
+ throw new Error('Somente o gestor atual, criador do template ou usuário com permissão pode alterar o gestor');
+ }
+
+ // Verificar se novo gestor existe
+ const novoGestor = await ctx.db.get(args.novoManagerId);
+ if (!novoGestor) {
+ throw new Error('Usuário não encontrado');
+ }
+
+ // Atualizar gestor
+ await ctx.db.patch(args.flowInstanceId, {
+ managerId: args.novoManagerId
+ });
+
+ // Criar notificação para novo gestor
+ await ctx.db.insert('notificacoes', {
+ usuarioId: args.novoManagerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Você foi designado como gestor de um fluxo',
+ descricao: `Você foi designado como gestor do fluxo "${template?.name ?? 'Fluxo'}"`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+
+ return null;
+ }
+});
+
+/**
+ * Reatribuir responsável de um passo
+ */
+export const reassignStep = mutation({
+ args: {
+ instanceStepId: v.id('flowInstanceSteps'),
+ assignedToId: v.id('usuarios')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const step = await ctx.db.get(args.instanceStepId);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ // Verificar se o usuário existe
+ const assignee = await ctx.db.get(args.assignedToId);
+ if (!assignee) {
+ throw new Error('Usuário não encontrado');
+ }
+
+ // Verificar se o usuário atual pode atribuir esta etapa
+ const instance = await ctx.db.get(step.flowInstanceId);
+ if (!instance) {
+ throw new Error('Instância não encontrada');
+ }
+
+ // Verificar se é criador do template
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const eCriador = template?.createdBy === usuario._id;
+
+ // Se for criador, permitir atribuição
+ if (!eCriador) {
+ // Se não for criador, verificar regra normal
+ const etapaAnterior = await obterEtapaAnterior(ctx, args.instanceStepId);
+
+ if (etapaAnterior) {
+ // Se há etapa anterior, verificar se o usuário atual é a pessoa atribuída
+ if (etapaAnterior.assignedToId) {
+ if (etapaAnterior.assignedToId !== usuario._id) {
+ throw new Error('Somente a pessoa da etapa anterior pode atribuir esta etapa');
+ }
+ }
+ // Se a etapa anterior não tem atribuição, permitir (vai para o setor)
+ } else {
+ // Se não há etapa anterior (primeiro passo), verificar se é o manager
+ // Se não for o manager, verificar permissão
+ if (instance.managerId !== usuario._id) {
+ const temPermissao = await verificarPermissaoVerTodasFluxos(ctx);
+ if (!temPermissao) {
+ throw new Error('Somente o gerente do fluxo ou pessoa da etapa anterior pode atribuir esta etapa');
+ }
+ }
+ }
+ }
+
+ // Verificar se o funcionário atribuído pertence ao setor do passo
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', step.setorId))
+ .collect();
+
+ // Buscar funcionários vinculados a este setor
+ const funcionariosDoSetor = [];
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (funcionario) {
+ funcionariosDoSetor.push(funcionario);
+ }
+ }
+
+ // Verificar se o usuário atribuído corresponde a um funcionário do setor
+ const funcionarioDoUsuario = funcionariosDoSetor.find(
+ (f) => f.email === assignee.email
+ );
+
+ if (!funcionarioDoUsuario) {
+ throw new Error('O funcionário atribuído não pertence ao setor deste passo');
+ }
+
+ await ctx.db.patch(args.instanceStepId, { assignedToId: args.assignedToId });
+ return null;
+ }
+});
+
+/**
+ * Atualizar notas de um passo
+ */
+export const updateStepNotes = mutation({
+ args: {
+ instanceStepId: v.id('flowInstanceSteps'),
+ notes: 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 step = await ctx.db.get(args.instanceStepId);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ await ctx.db.patch(args.instanceStepId, {
+ notes: args.notes,
+ notesUpdatedBy: usuario._id,
+ notesUpdatedAt: Date.now()
+ });
+ return null;
+ }
+});
+
+/**
+ * Cancelar uma instância de fluxo
+ */
+export const cancelInstance = mutation({
+ args: { id: v.id('flowInstances') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const instance = await ctx.db.get(args.id);
+ if (!instance) {
+ throw new Error('Instância não encontrada');
+ }
+
+ if (instance.status !== 'active') {
+ throw new Error('Apenas instâncias ativas podem ser canceladas');
+ }
+
+ await ctx.db.patch(args.id, {
+ status: 'cancelled',
+ finishedAt: Date.now()
+ });
+
+ return null;
+ }
+});
+
+// ============================================
+// SUB-ETAPAS
+// ============================================
+
+/**
+ * Listar sub-etapas de um step
+ */
+export const listarSubEtapas = query({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps'))
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowSubSteps'),
+ _creationTime: v.number(),
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ 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'),
+ createdByName: v.optional(v.string())
+ })
+ ),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return [];
+ }
+
+ let subEtapas;
+ if (args.flowStepId) {
+ subEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowStepId', (q) => q.eq('flowStepId', args.flowStepId))
+ .collect();
+ } else if (args.flowInstanceStepId) {
+ subEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+ } else {
+ return [];
+ }
+
+ // Ordenar por position
+ subEtapas.sort((a, b) => a.position - b.position);
+
+ // Adicionar nome do criador
+ const subEtapasComCriador = await Promise.all(
+ subEtapas.map(async (subEtapa) => {
+ const criador = await ctx.db.get(subEtapa.createdBy);
+ return {
+ _id: subEtapa._id,
+ _creationTime: subEtapa._creationTime,
+ flowStepId: subEtapa.flowStepId,
+ flowInstanceStepId: subEtapa.flowInstanceStepId,
+ name: subEtapa.name,
+ description: subEtapa.description,
+ status: subEtapa.status,
+ position: subEtapa.position,
+ createdBy: subEtapa.createdBy,
+ createdByName: criador?.nome
+ };
+ })
+ );
+
+ return subEtapasComCriador;
+ }
+});
+
+/**
+ * Criar sub-etapa
+ */
+export const criarSubEtapa = mutation({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ name: v.string(),
+ description: v.optional(v.string())
+ },
+ returns: v.id('flowSubSteps'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ if (!args.flowStepId && !args.flowInstanceStepId) {
+ throw new Error('É necessário fornecer flowStepId ou flowInstanceStepId');
+ }
+
+ // Verificar se o step existe
+ if (args.flowStepId) {
+ const flowStep = await ctx.db.get(args.flowStepId);
+ if (!flowStep) {
+ throw new Error('Passo do template não encontrado');
+ }
+ }
+
+ if (args.flowInstanceStepId) {
+ const flowInstanceStep = await ctx.db.get(args.flowInstanceStepId);
+ if (!flowInstanceStep) {
+ throw new Error('Passo da instância não encontrado');
+ }
+ }
+
+ // Obter posição máxima
+ let maxPosition = 0;
+ if (args.flowStepId) {
+ const existingSubEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowStepId', (q) => q.eq('flowStepId', args.flowStepId))
+ .collect();
+ if (existingSubEtapas.length > 0) {
+ maxPosition = Math.max(...existingSubEtapas.map((s) => s.position));
+ }
+ } else if (args.flowInstanceStepId) {
+ const existingSubEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+ if (existingSubEtapas.length > 0) {
+ maxPosition = Math.max(...existingSubEtapas.map((s) => s.position));
+ }
+ }
+
+ const subEtapaId = await ctx.db.insert('flowSubSteps', {
+ flowStepId: args.flowStepId,
+ flowInstanceStepId: args.flowInstanceStepId,
+ name: args.name,
+ description: args.description,
+ status: 'pending',
+ position: maxPosition + 1,
+ createdBy: usuario._id,
+ createdAt: Date.now()
+ });
+
+ return subEtapaId;
+ }
+});
+
+/**
+ * Atualizar sub-etapa
+ */
+export const atualizarSubEtapa = mutation({
+ args: {
+ subEtapaId: v.id('flowSubSteps'),
+ name: v.optional(v.string()),
+ description: v.optional(v.string()),
+ status: v.optional(
+ v.union(
+ v.literal('pending'),
+ v.literal('in_progress'),
+ v.literal('completed'),
+ v.literal('blocked')
+ )
+ )
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const subEtapa = await ctx.db.get(args.subEtapaId);
+ if (!subEtapa) {
+ throw new Error('Sub-etapa não encontrada');
+ }
+
+ const updates: Partial> = {};
+ if (args.name !== undefined) {
+ updates.name = args.name;
+ }
+ if (args.description !== undefined) {
+ updates.description = args.description;
+ }
+ if (args.status !== undefined) {
+ updates.status = args.status;
+ }
+
+ await ctx.db.patch(args.subEtapaId, updates);
+ return null;
+ }
+});
+
+/**
+ * Deletar sub-etapa
+ */
+export const deletarSubEtapa = mutation({
+ args: { subEtapaId: v.id('flowSubSteps') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const subEtapa = await ctx.db.get(args.subEtapaId);
+ if (!subEtapa) {
+ throw new Error('Sub-etapa não encontrada');
+ }
+
+ await ctx.db.delete(args.subEtapaId);
+ return null;
+ }
+});
+
+/**
+ * Reordenar sub-etapas
+ */
+export const reordenarSubEtapas = mutation({
+ args: {
+ subEtapaIds: v.array(v.id('flowSubSteps'))
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // Atualizar posições
+ for (let i = 0; i < args.subEtapaIds.length; i++) {
+ await ctx.db.patch(args.subEtapaIds[i], { position: i + 1 });
+ }
+
+ return null;
+ }
+});
+
+// ============================================
+// NOTAS
+// ============================================
+
+/**
+ * Listar notas de um step ou sub-etapa
+ */
+export const listarNotas = query({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ flowSubStepId: v.optional(v.id('flowSubSteps'))
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowStepNotes'),
+ _creationTime: v.number(),
+ 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'),
+ criadoPorNome: v.optional(v.string()),
+ criadoEm: v.number(),
+ arquivos: v.array(
+ v.object({
+ storageId: v.id('_storage'),
+ name: v.string()
+ })
+ )
+ })
+ ),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return [];
+ }
+
+ let notas;
+ if (args.flowStepId) {
+ notas = await ctx.db
+ .query('flowStepNotes')
+ .withIndex('by_flowStepId', (q) => q.eq('flowStepId', args.flowStepId))
+ .collect();
+ } else if (args.flowInstanceStepId) {
+ notas = await ctx.db
+ .query('flowStepNotes')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+ } else if (args.flowSubStepId) {
+ notas = await ctx.db
+ .query('flowStepNotes')
+ .withIndex('by_flowSubStepId', (q) => q.eq('flowSubStepId', args.flowSubStepId))
+ .collect();
+ } else {
+ return [];
+ }
+
+ // Ordenar por data de criação (mais recente primeiro)
+ notas.sort((a, b) => b.criadoEm - a.criadoEm);
+
+ // Adicionar nome do criador e informações dos arquivos
+ const notasComDetalhes = await Promise.all(
+ notas.map(async (nota) => {
+ const criador = await ctx.db.get(nota.criadoPor);
+
+ // Obter informações dos arquivos
+ const arquivosComNome = await Promise.all(
+ nota.arquivos.map(async (storageId) => {
+ // Buscar documento que referencia este storageId
+ // Como não temos uma tabela direta, vamos buscar nos flowInstanceDocuments
+ const documentos = await ctx.db
+ .query('flowInstanceDocuments')
+ .collect();
+ const documento = documentos.find((d) => d.storageId === storageId);
+
+ return {
+ storageId,
+ name: documento?.name ?? 'Arquivo'
+ };
+ })
+ );
+
+ return {
+ _id: nota._id,
+ _creationTime: nota._creationTime,
+ flowStepId: nota.flowStepId,
+ flowInstanceStepId: nota.flowInstanceStepId,
+ flowSubStepId: nota.flowSubStepId,
+ texto: nota.texto,
+ criadoPor: nota.criadoPor,
+ criadoPorNome: criador?.nome,
+ criadoEm: nota.criadoEm,
+ arquivos: arquivosComNome
+ };
+ })
+ );
+
+ return notasComDetalhes;
+ }
+});
+
+/**
+ * Adicionar nota
+ */
+export const adicionarNota = mutation({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ flowSubStepId: v.optional(v.id('flowSubSteps')),
+ texto: v.string(),
+ arquivos: v.optional(v.array(v.id('_storage')))
+ },
+ returns: v.id('flowStepNotes'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ if (!args.flowStepId && !args.flowInstanceStepId && !args.flowSubStepId) {
+ throw new Error('É necessário fornecer flowStepId, flowInstanceStepId ou flowSubStepId');
+ }
+
+ const notaId = await ctx.db.insert('flowStepNotes', {
+ flowStepId: args.flowStepId,
+ flowInstanceStepId: args.flowInstanceStepId,
+ flowSubStepId: args.flowSubStepId,
+ texto: args.texto,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ arquivos: args.arquivos ?? []
+ });
+
+ return notaId;
+ }
+});
+
+/**
+ * Atualizar nota
+ */
+export const atualizarNota = mutation({
+ args: {
+ notaId: v.id('flowStepNotes'),
+ texto: 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 nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode editá-la');
+ }
+
+ await ctx.db.patch(args.notaId, { texto: args.texto });
+ return null;
+ }
+});
+
+/**
+ * Deletar nota
+ */
+export const deletarNota = mutation({
+ args: { notaId: v.id('flowStepNotes') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode deletá-la');
+ }
+
+ await ctx.db.delete(args.notaId);
+ return null;
+ }
+});
+
+/**
+ * Adicionar arquivo a uma nota
+ */
+export const adicionarArquivoNota = mutation({
+ args: {
+ notaId: v.id('flowStepNotes'),
+ storageId: v.id('_storage')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode adicionar arquivos');
+ }
+
+ // Adicionar arquivo à lista
+ const novosArquivos = [...nota.arquivos, args.storageId];
+ await ctx.db.patch(args.notaId, { arquivos: novosArquivos });
+ return null;
+ }
+});
+
+/**
+ * Remover arquivo de uma nota
+ */
+export const removerArquivoNota = mutation({
+ args: {
+ notaId: v.id('flowStepNotes'),
+ storageId: v.id('_storage')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode remover arquivos');
+ }
+
+ // Remover arquivo da lista
+ const novosArquivos = nota.arquivos.filter((id) => id !== args.storageId);
+ await ctx.db.patch(args.notaId, { arquivos: novosArquivos });
+ return null;
+ }
+});
+
+// ============================================
+// FLOW DOCUMENTS
+// ============================================
+
+/**
+ * Listar documentos de um passo
+ */
+export const listDocumentsByStep = query({
+ args: { flowInstanceStepId: v.id('flowInstanceSteps') },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowInstanceDocuments'),
+ _creationTime: v.number(),
+ flowInstanceStepId: v.id('flowInstanceSteps'),
+ uploadedById: v.id('usuarios'),
+ uploadedByName: v.optional(v.string()),
+ storageId: v.id('_storage'),
+ name: v.string(),
+ uploadedAt: v.number(),
+ url: v.optional(v.string())
+ })
+ ),
+ handler: async (ctx, args) => {
+ const documents = await ctx.db
+ .query('flowInstanceDocuments')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+
+ const result: Array<{
+ _id: Id<'flowInstanceDocuments'>;
+ _creationTime: number;
+ flowInstanceStepId: Id<'flowInstanceSteps'>;
+ uploadedById: Id<'usuarios'>;
+ uploadedByName: string | undefined;
+ storageId: Id<'_storage'>;
+ name: string;
+ uploadedAt: number;
+ url: string | undefined;
+ }> = [];
+
+ for (const doc of documents) {
+ const uploader = await ctx.db.get(doc.uploadedById);
+ const url = await ctx.storage.getUrl(doc.storageId);
+
+ result.push({
+ ...doc,
+ uploadedByName: uploader?.nome,
+ url: url ?? undefined
+ });
+ }
+
+ return result;
+ }
+});
+
+/**
+ * Registrar upload de documento
+ */
+export const registerDocument = mutation({
+ args: {
+ flowInstanceStepId: v.id('flowInstanceSteps'),
+ storageId: v.id('_storage'),
+ name: v.string()
+ },
+ returns: v.id('flowInstanceDocuments'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const step = await ctx.db.get(args.flowInstanceStepId);
+ if (!step) {
+ throw new Error('Passo não encontrado');
+ }
+
+ const documentId = await ctx.db.insert('flowInstanceDocuments', {
+ flowInstanceStepId: args.flowInstanceStepId,
+ uploadedById: usuario._id,
+ storageId: args.storageId,
+ name: args.name,
+ uploadedAt: Date.now()
+ });
+
+ return documentId;
+ }
+});
+
+/**
+ * Excluir documento
+ */
+export const deleteDocument = mutation({
+ args: { id: v.id('flowInstanceDocuments') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const document = await ctx.db.get(args.id);
+ if (!document) {
+ throw new Error('Documento não encontrado');
+ }
+
+ // Excluir o arquivo do storage
+ await ctx.storage.delete(document.storageId);
+
+ // Excluir o registro do documento
+ await ctx.db.delete(args.id);
+
+ return null;
+ }
+});
+
+/**
+ * Gerar URL de upload
+ */
+export const generateUploadUrl = mutation({
+ args: {},
+ returns: v.string(),
+ handler: async (ctx) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ return await ctx.storage.generateUploadUrl();
+ }
+});
+
+/**
+ * Obter usuários que podem ser atribuídos a um passo (baseado no setor)
+ */
+export const getUsuariosBySetorForAssignment = query({
+ args: { setorId: v.id('setores') },
+ returns: v.array(
+ v.object({
+ _id: v.id('usuarios'),
+ _creationTime: v.number(),
+ nome: v.string(),
+ email: v.string()
+ })
+ ),
+ handler: async (ctx, args) => {
+ // Buscar funcionários do setor
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', args.setorId))
+ .collect();
+
+ const usuarios: Array<{
+ _id: Id<'usuarios'>;
+ _creationTime: number;
+ nome: string;
+ email: string;
+ }> = [];
+
+ // Para cada funcionário do setor, buscar o usuário correspondente
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (!funcionario) continue;
+
+ // Buscar usuário por email
+ const usuariosList = await ctx.db.query('usuarios').collect();
+ const usuario = usuariosList.find((u) => u.email === funcionario.email);
+
+ if (usuario && !usuarios.find((u) => u._id === usuario._id)) {
+ usuarios.push({
+ _id: usuario._id,
+ _creationTime: usuario._creationTime,
+ nome: usuario.nome,
+ email: usuario.email
+ });
+ }
+ }
+
+ return usuarios;
+ }
+});
+
diff --git a/packages/backend/convex/permissoesAcoes.ts b/packages/backend/convex/permissoesAcoes.ts
index d92e1d9..02cac86 100644
--- a/packages/backend/convex/permissoesAcoes.ts
+++ b/packages/backend/convex/permissoesAcoes.ts
@@ -295,6 +295,106 @@ const PERMISSOES_BASE = {
recurso: 'gestao_pessoas',
acao: 'ver',
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;
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index c0563fd..905319f 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -120,6 +120,31 @@ export const reportStatus = v.union(
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;
+
+// Status de instâncias de fluxo
+export const flowInstanceStatus = v.union(
+ v.literal("active"),
+ v.literal("completed"),
+ v.literal("cancelled")
+);
+export type FlowInstanceStatus = Infer;
+
+// 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;
+
export const situacaoContrato = v.union(
v.literal("em_execucao"),
v.literal("rescendido"),
@@ -128,6 +153,129 @@ export const situacaoContrato = v.union(
);
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({
contratadaId: v.id("empresas"),
objeto: v.string(),
@@ -897,7 +1045,8 @@ export default defineSchema({
v.literal("mencao"),
v.literal("grupo_criado"),
v.literal("adicionado_grupo"),
- v.literal("alerta_seguranca")
+ v.literal("alerta_seguranca"),
+ v.literal("etapa_fluxo_concluida")
),
conversaId: v.optional(v.id("conversas")),
mensagemId: v.optional(v.id("mensagens")),
diff --git a/packages/backend/convex/setores.ts b/packages/backend/convex/setores.ts
new file mode 100644
index 0000000..2b3b8b9
--- /dev/null
+++ b/packages/backend/convex/setores.ts
@@ -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;
+ }
+});
+