From f8d9c17f63b70ab7e9f9030cac032010c22880cb Mon Sep 17 00:00:00 2001
From: killer-cf
Date: Tue, 25 Nov 2025 00:21:35 -0300
Subject: [PATCH] feat: add Svelte DnD action and enhance flow management
features
- Added "svelte-dnd-action" dependency to facilitate drag-and-drop functionality.
- Introduced new "Fluxos de Trabalho" section in the dashboard for managing workflow templates and instances.
- Updated permission handling for sectors and flow templates in the backend.
- Enhanced schema definitions to support flow templates, instances, and associated documents.
- Improved UI components to include new workflow management features across various dashboard pages.
---
apps/web/package.json | 1 +
.../configuracoes/setores/+page.svelte | 397 ++++++
.../routes/(dashboard)/fluxos/+page.svelte | 433 +++++++
.../fluxos/[id]/editor/+page.svelte | 599 +++++++++
.../fluxos/instancias/+page.svelte | 373 ++++++
.../fluxos/instancias/[id]/+page.svelte | 717 +++++++++++
.../(dashboard)/licitacoes/+page.svelte | 19 +-
.../programas-esportivos/+page.svelte | 19 +-
.../src/routes/(dashboard)/ti/+page.svelte | 22 +-
bun.lock | 5 +
package.json | 1 +
packages/backend/convex/_generated/api.d.ts | 4 +
packages/backend/convex/flows.ts | 1102 +++++++++++++++++
packages/backend/convex/permissoesAcoes.ts | 100 ++
packages/backend/convex/schema.ts | 108 +-
packages/backend/convex/setores.ts | 178 +++
16 files changed, 4073 insertions(+), 5 deletions(-)
create mode 100644 apps/web/src/routes/(dashboard)/configuracoes/setores/+page.svelte
create mode 100644 apps/web/src/routes/(dashboard)/fluxos/+page.svelte
create mode 100644 apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
create mode 100644 apps/web/src/routes/(dashboard)/fluxos/instancias/+page.svelte
create mode 100644 apps/web/src/routes/(dashboard)/fluxos/instancias/[id]/+page.svelte
create mode 100644 packages/backend/convex/flows.ts
create mode 100644 packages/backend/convex/setores.ts
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/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..0ad027c
--- /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]/editor/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
new file mode 100644
index 0000000..c7afb26
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/fluxos/[id]/editor/+page.svelte
@@ -0,0 +1,599 @@
+
+
+
+
+
+
+
+
+
+
+
+ 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}
+
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..a60ec04
--- /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)/fluxos/instancias/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/fluxos/instancias/[id]/+page.svelte
new file mode 100644
index 0000000..05e9150
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/fluxos/instancias/[id]/+page.svelte
@@ -0,0 +1,717 @@
+
+
+
+ {#if instanceQuery.isLoading}
+
+
+
+ {:else if !instanceQuery.data}
+
+
+
+
+
Instância não encontrada
+
Voltar para lista
+
+ {:else}
+ {@const instance = instanceQuery.data.instance}
+ {@const steps = instanceQuery.data.steps}
+ {@const statusBadge = getInstanceStatusBadge(instance.status)}
+
+
+
+
+
+
+
+
+
+
+
+ {instance.templateName ?? 'Fluxo'}
+
+
+
+ {instance.targetType}
+ {instance.targetId}
+
+
+
+
+
+ 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 esta instância de 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)/licitacoes/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
index dce4f1c..28865d6 100644
--- a/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/licitacoes/+page.svelte
@@ -1,5 +1,5 @@
@@ -74,6 +74,23 @@
Em breve: gestão de documentos e editais.
+
+
+
+
+
+
+
+
Fluxos de Trabalho
+
+
+ Gerencie templates e instâncias de fluxos de trabalho para contratos e processos.
+
+
+
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)/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..3fa731f
--- /dev/null
+++ b/packages/backend/convex/flows.ts
@@ -0,0 +1,1102 @@
+import { query, mutation } from './_generated/server';
+import { v } from 'convex/values';
+import { getCurrentUserFunction } from './auth';
+import type { Id, Doc } from './_generated/dataModel';
+import { flowTemplateStatus, flowInstanceStatus, flowInstanceStepStatus } from './schema';
+
+// ============================================
+// 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
+// ============================================
+
+/**
+ * Listar instâncias de fluxo
+ */
+export const listInstances = query({
+ args: {
+ status: v.optional(flowInstanceStatus),
+ targetType: v.optional(v.string()),
+ 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()),
+ targetType: v.string(),
+ targetId: v.string(),
+ 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) => {
+ 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 {
+ instances = await ctx.db.query('flowInstances').order('desc').collect();
+ }
+
+ // Filtrar por targetType se especificado
+ const filteredInstances = args.targetType
+ ? instances.filter((i) => i.targetType === args.targetType)
+ : instances;
+
+ const result: Array<{
+ _id: Id<'flowInstances'>;
+ _creationTime: number;
+ flowTemplateId: Id<'flowTemplates'>;
+ templateName: string | undefined;
+ targetType: string;
+ targetId: string;
+ 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 filteredInstances) {
+ 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,
+ targetType: instance.targetType,
+ targetId: instance.targetId,
+ 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()),
+ targetType: v.string(),
+ targetId: v.string(),
+ 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'))
+ }),
+ 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()),
+ dueDate: v.optional(v.number()),
+ position: 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 instance = await ctx.db.get(args.id);
+ if (!instance) return null;
+
+ 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;
+ dueDate: number | undefined;
+ position: 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
+ });
+ }
+
+ 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,
+ dueDate: step.dueDate,
+ position: flowStep?.position ?? 0,
+ documents: docsWithUploader
+ });
+ }
+
+ // Ordenar por position
+ stepsWithDetails.sort((a, b) => a.position - b.position);
+
+ return {
+ instance: {
+ ...instance,
+ templateName: template?.name,
+ managerName: manager?.nome
+ },
+ steps: stepsWithDetails
+ };
+ }
+});
+
+/**
+ * Instanciar um fluxo
+ */
+export const instantiateFlow = mutation({
+ args: {
+ flowTemplateId: v.id('flowTemplates'),
+ targetType: v.string(),
+ targetId: v.string(),
+ 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,
+ targetType: args.targetType,
+ targetId: args.targetId,
+ 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 });
+ }
+
+ 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];
+
+ if (nextStep) {
+ // Atualizar currentStepId para o próximo passo
+ await ctx.db.patch(step.flowInstanceId, { currentStepId: nextStep.stepId });
+ } else {
+ // Todos os passos concluídos, marcar instância como completada
+ await ctx.db.patch(step.flowInstanceId, {
+ status: 'completed',
+ finishedAt: now,
+ currentStepId: undefined
+ });
+ }
+
+ 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();
+ }
+
+ await ctx.db.patch(args.instanceStepId, updates);
+ 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');
+ }
+
+ 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 });
+ 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;
+ }
+});
+
+// ============================================
+// 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();
+ }
+});
+
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..3fc5702 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,85 @@ 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"]),
+
+ // 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"),
+ targetType: v.string(), // ex: 'contrato', 'projeto'
+ targetId: v.string(), // ID genérico do alvo
+ 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_targetType_and_targetId", ["targetType", "targetId"])
+ .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()),
+ 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"]),
+
contratos: defineTable({
contratadaId: v.id("empresas"),
objeto: v.string(),
@@ -210,6 +314,7 @@ export default defineSchema({
simboloId: v.id("simbolos"),
simboloTipo: simboloTipo,
gestorId: v.optional(v.id("usuarios")),
+ setorId: v.optional(v.id("setores")), // Setor do funcionário
statusFerias: v.optional(
v.union(v.literal("ativo"), v.literal("em_ferias"))
),
@@ -349,7 +454,8 @@ export default defineSchema({
.index("by_simboloTipo", ["simboloTipo"])
.index("by_cpf", ["cpf"])
.index("by_rg", ["rg"])
- .index("by_gestor", ["gestorId"]),
+ .index("by_gestor", ["gestorId"])
+ .index("by_setor", ["setorId"]),
atestados: defineTable({
funcionarioId: v.id("funcionarios"),
diff --git a/packages/backend/convex/setores.ts b/packages/backend/convex/setores.ts
new file mode 100644
index 0000000..f0d31b0
--- /dev/null
+++ b/packages/backend/convex/setores.ts
@@ -0,0 +1,178 @@
+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;
+ }
+});
+
+/**
+ * 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('funcionarios')
+ .withIndex('by_setor', (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;
+ }
+});
+