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

+
+
+ + + +
+
+
+ + +
+ {#if setoresQuery.isLoading} +
+ +
+ {:else if !setoresQuery.data || setoresQuery.data.length === 0} +
+ + + +

Nenhum setor cadastrado

+

Clique em "Novo Setor" para criar o primeiro setor.

+
+ {:else} +
+ + + + + + + + + + + {#each setoresQuery.data as setor (setor._id)} + + + + + + + {/each} + +
SiglaNomeCriado emAções
+ + {setor.sigla} + + {setor.nome}{formatDate(setor.createdAt)} +
+ + + + + + +
+
+
+ {/if} +
+
+ + +{#if showModal} + +{/if} + + +{#if showDeleteModal && setorToDelete} + +{/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. +

+
+
+ + + + + + +
+
+
+ + +
+ {#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)} + +
+ +
+ + + + + Editar + +
+
+
+ {/each} +
+ {/if} +
+ + +
+ + + Ver Instâncias de Fluxo + +
+
+ + +{#if showCreateModal} + +{/if} + + +{#if showDeleteModal && templateToDelete} + +{/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'} + + {:else if templateQuery.data?.status === 'published'} + Publicado + {:else if templateQuery.data?.status === 'archived'} + Arquivado + {/if} +
+
+
+ + +
+ +
+
+

Passos do Fluxo

+ +
+ + {#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' : ''} + +
+
+
+ + +
+
+
+
+ {/each} +
+ {/if} +
+ + + +
+
+ + +{#if showNewStepModal} + +{/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 @@ + + +
+ +
+
+
+
+
+
+ + + Templates + + + Execução + +
+

+ Instâncias de Fluxo +

+

+ Acompanhe e gerencie as execuções de fluxos de trabalho. Visualize o progresso, + documentos e responsáveis de cada etapa. +

+
+
+ + + + + + +
+
+
+ + +
+ {#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} +
+ + + + + + + + + + + + + + {#each instancesQuery.data as instance (instance._id)} + {@const statusBadge = getStatusBadge(instance.status)} + {@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)} + + + + + + + + + + {/each} + +
TemplateAlvoGerenteProgressoStatusIniciado emAções
+
{instance.templateName ?? 'Template desconhecido'}
+
+
+ {instance.targetType} + {instance.targetId} +
+
{instance.managerName ?? '-'} +
+ + + {instance.progress.completed}/{instance.progress.total} + +
+
+ {statusBadge.label} + {formatDate(instance.startedAt)} + + + Ver + +
+
+ {/if} +
+
+ + +{#if showCreateModal} + +{/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)} + + +
+
+
+
+
+ + + Voltar + + {statusBadge.label} +
+ +
+
+

+ {instance.templateName ?? 'Fluxo'} +

+
+
+ {instance.targetType} + {instance.targetId} +
+
+ + Gerente: {instance.managerName ?? '-'} +
+
+ + Iniciado: {formatDate(instance.startedAt)} +
+
+
+ + {#if instance.status === 'active'} + + + + {/if} +
+
+
+ + + {#if processingError} +
+ + {processingError} + +
+ {/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'} + + + + {:else if step.status === 'in_progress'} + + + + + {:else if step.status === 'blocked'} + + + + {/if} + + + + + + + + + + +
+ {/if} +
+ + + {#if step.notes} +
+

{step.notes}

+
+ {/if} + + + {#if step.documents && step.documents.length > 0} +
+

Documentos

+
+ {#each step.documents as doc (doc._id)} +
+ + {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} + +{/if} + + +{#if showNotesModal && stepForNotes} + +{/if} + + +{#if showUploadModal && stepForUpload} + +{/if} + + +{#if showCancelModal} + +{/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; + } +}); +