-
-
-
-
-
Fluxos de Trabalho
+
+
+
+
+
+
+
+
+
+
+ Fluxos de Trabalho
+
+
Gerencie templates e fluxos de trabalho para contratos e processos
-
- Gerencie templates e instâncias de fluxos de trabalho para contratos e processos.
-
-
+
+
+
+
diff --git a/apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
new file mode 100644
index 0000000..a78a8bd
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/+page.svelte
@@ -0,0 +1,365 @@
+
+
+
+
+
+
+
+
+
+
+
+ Fluxos de Trabalho
+
+
+ Acompanhe e gerencie os fluxos de trabalho. Visualize o progresso,
+ documentos e responsáveis de cada etapa.
+
+
+
+
+
+ Todos os status
+ Em Andamento
+ Concluído
+ Cancelado
+
+
+
+
+
+
+
+ Novo Fluxo
+
+
+
+
+
+
+
+
+ {#if instancesQuery.isLoading}
+
+
+
+ {:else if !instancesQuery.data || instancesQuery.data.length === 0}
+
+
+
+
+
Nenhum fluxo encontrado
+
+ {statusFilter ? 'Não há fluxos com este status.' : 'Clique em "Novo Fluxo" para iniciar um fluxo.'}
+
+
+ {:else}
+
+
+
+
+ Template
+ Contrato
+ Gerente
+ Progresso
+ Status
+ Iniciado em
+ Ações
+
+
+
+ {#each instancesQuery.data as instance (instance._id)}
+ {@const statusBadge = getStatusBadge(instance.status)}
+ {@const progressPercent = getProgressPercentage(instance.progress.completed, instance.progress.total)}
+
+
+ {instance.templateName ?? 'Template desconhecido'}
+
+
+ {#if instance.contratoId}
+ {instance.contratoId}
+ {:else}
+ -
+ {/if}
+
+ {instance.managerName ?? '-'}
+
+
+
+
+ {instance.progress.completed}/{instance.progress.total}
+
+
+
+
+ {statusBadge.label}
+
+ {formatDate(instance.startedAt)}
+
+
+
+
+
+
+ Ver
+
+
+
+ {/each}
+
+
+
+ {/if}
+
+
+
+
+{#if showCreateModal}
+
+
+
Novo Fluxo de Trabalho
+
+ {#if createError}
+
+ {/if}
+
+
+
+
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
new file mode 100644
index 0000000..f8fd33e
--- /dev/null
+++ b/apps/web/src/routes/(dashboard)/licitacoes/fluxos/[id]/+page.svelte
@@ -0,0 +1,1251 @@
+
+
+
+ {#if instanceQuery.isLoading}
+
+
+
+ {:else if !instanceQuery.data}
+
+
+
+
+
Fluxo não encontrado
+
Voltar para lista
+
+ {:else}
+ {@const instance = instanceQuery.data.instance}
+ {@const steps = instanceQuery.data.steps}
+ {@const statusBadge = getInstanceStatusBadge(instance.status)}
+
+
+
+
+
+
+
+
{
+ if (typeof window !== 'undefined' && window.history.length > 1) {
+ window.history.back();
+ } else {
+ goto(resolve('/licitacoes/fluxos'));
+ }
+ }}
+ aria-label="Voltar para página anterior"
+ >
+
+
+
+ Voltar
+
+
{statusBadge.label}
+
+
+
+
+
+ {instance.templateName ?? 'Fluxo'}
+
+
+ {#if instance.contratoId}
+
+ Contrato
+ {instance.contratoId}
+
+ {/if}
+
+
+
+
+
+
Gerente: {instance.managerName ?? '-'}
+
+
+
+
+
+
+
+
+
+
+
+ Iniciado: {formatDate(instance.startedAt)}
+
+
+
+
+
+
+
+ {#if instance.status === 'active'}
+
+ showCancelModal = true}>
+
+
+
+ Cancelar Fluxo
+
+
+ {/if}
+
+
+
+
+
+ {#if processingError}
+
+
+
+
+
{processingError}
+
processingError = null}>Fechar
+
+ {/if}
+
+
+
+ Timeline do Fluxo
+
+
+ {#each steps as step, index (step._id)}
+ {@const stepStatus = getStatusBadge(step.status)}
+ {@const isCurrent = isStepCurrent(step._id)}
+ {@const overdue = step.status !== 'completed' && isOverdue(step.dueDate)}
+ {@const subEtapasQuery = useQuery(api.flows.listarSubEtapas, () => ({ flowInstanceStepId: step._id }))}
+ {@const subEtapas = subEtapasQuery.data}
+ {@const subEtapasCount = subEtapas?.length ?? 0}
+ {@const subEtapasCompleted = subEtapas?.filter((s: { status: string }) => s.status === 'completed').length ?? 0}
+
+
+
+ {#if index < steps.length - 1}
+
+ {/if}
+
+
+
+ {#if step.status === 'completed'}
+
+
+
+ {:else if step.status === 'blocked'}
+
+
+
+ {:else}
+
{index + 1}
+ {/if}
+
+
+
+
+
+
+
+
+
{step.stepName}
+ {stepStatus.label}
+
+
+ {#if step.stepDescription}
+
{step.stepDescription}
+ {/if}
+
+
+
+
+
+ {step.setorNome ?? 'Setor não definido'}
+
+ {#if step.assignedToName}
+
+
+
+
+ {step.assignedToName}
+
+ {/if}
+ {#if step.dueDate}
+
+
+
+
+ Prazo: {formatDate(step.dueDate)}
+
+ {/if}
+
+
+
+
+ {#if instance.status === 'active'}
+
+ {#if step.status === 'pending'}
+
+ handleStartStep(step._id)}
+ disabled={isProcessing}
+ >
+ Iniciar
+
+
+ {:else if step.status === 'in_progress'}
+
+ handleCompleteStep(step._id)}
+ disabled={isProcessing}
+ >
+ Concluir
+
+ handleBlockStep(step._id)}
+ disabled={isProcessing}
+ >
+ Bloquear
+
+
+ {:else if step.status === 'blocked'}
+
+ handleStartStep(step._id)}
+ disabled={isProcessing}
+ >
+ Desbloquear
+
+
+ {/if}
+
+
+ openReassignModal(step)}
+ aria-label="Reatribuir responsável"
+ title="Reatribuir responsável"
+ >
+
+
+
+
+
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Sub-etapas
+ {#if subEtapasCount > 0}
+
+ {subEtapasCompleted} / {subEtapasCount} concluídas
+
+ {/if}
+
+ {#if instance.status === 'active'}
+
openSubEtapaModal(step._id)}
+ aria-label="Adicionar sub-etapa"
+ >
+
+
+
+ Adicionar
+
+ {/if}
+
+
+ {#if subEtapasQuery.isLoading}
+
+
+
+ {:else if subEtapas && subEtapas.length > 0}
+
+ {#each subEtapas as subEtapa (subEtapa._id)}
+
+
+
+
{subEtapa.name}
+
+ {subEtapa.status === 'completed' ? 'Concluída' : subEtapa.status === 'in_progress' ? 'Em Andamento' : subEtapa.status === 'blocked' ? 'Bloqueada' : 'Pendente'}
+
+
+ {#if subEtapa.description}
+
{subEtapa.description}
+ {/if}
+
+
+ {#if instance.status === 'active'}
+
handleAtualizarStatusSubEtapa(subEtapa._id, e.currentTarget.value as 'pending' | 'in_progress' | 'completed' | 'blocked')}
+ >
+ Pendente
+ Em Andamento
+ Concluída
+ Bloqueada
+
+
handleDeletarSubEtapa(subEtapa._id)}
+ aria-label="Deletar sub-etapa"
+ >
+
+
+
+
+ {/if}
+
+
+ {/each}
+
+ {:else}
+
+ Nenhuma sub-etapa adicionada
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Documentos
+ {#if step.documents && step.documents.length > 0}
+
{step.documents.length}
+ {/if}
+
+ {#if instance.status === 'active'}
+
+ openUploadModal(step)}
+ aria-label="Upload de documento"
+ >
+
+
+
+ Enviar
+
+
+ {/if}
+
+
+ {#if step.documents && step.documents.length > 0}
+
+ {#each step.documents as doc (doc._id)}
+
+
+
+
+
+ {#if instance.status === 'active'}
+
+ handleDeleteDocument(doc._id)}
+ aria-label="Excluir documento {doc.name}"
+ >
+
+
+
+
+
+ {/if}
+
+ {/each}
+
+ {:else}
+
+ Nenhum documento anexado
+
+ {/if}
+
+
+
+
+
+
+
+
+
+
Notas e Comentários
+ {#if step.notes}
+
Atualizado
+ {/if}
+
+
openNotesModal(step)}
+ aria-label="Editar notas"
+ >
+
+
+
+ {step.notes ? 'Editar' : 'Adicionar'}
+
+
+
+ {#if step.notes}
+
+
{step.notes}
+ {#if step.notesUpdatedByName || step.notesUpdatedAt}
+
+
+
+
+ {#if step.notesUpdatedByName}
+
Atualizado por {step.notesUpdatedByName}
+ {/if}
+ {#if step.notesUpdatedAt}
+
•
+
{formatDate(step.notesUpdatedAt)}
+ {/if}
+
+ {/if}
+
+ {:else}
+
+ Nenhuma nota ou comentário adicionado
+
+ {/if}
+
+
+
+ {#if step.startedAt || step.finishedAt}
+
+ {#if step.startedAt}
+
+
+
+
+
+ Iniciado: {formatDate(step.startedAt)}
+
+ {/if}
+ {#if step.finishedAt}
+
+
+
+
+ Concluído: {formatDate(step.finishedAt)}
+
+ {/if}
+
+ {/if}
+
+
+ {/each}
+
+
+ {/if}
+
+
+ {#if showAlterarGestorModal}
+
+
+
Alterar Gestor do Fluxo
+
+ Selecione o novo gestor responsável por este fluxo
+
+
+ {#if alterarGestorError}
+
+
+
+
+
{alterarGestorError}
+
+ {/if}
+
+
+
+ Novo Gestor
+
+
+ Selecione um usuário
+ {#if usuariosQuery.data}
+ {#each usuariosQuery.data as usuario (usuario._id)}
+ {usuario.nome}
+ {/each}
+ {/if}
+
+
+
+
+
+ Cancelar
+
+
+ {#if isAlterandoGestor}
+
+ {/if}
+ Alterar
+
+
+
+
+
+ {/if}
+
+
+ {#if showSubEtapaModal && stepIdParaSubEtapas}
+
+
+
Nova Sub-etapa
+
+ {#if subEtapaError}
+
+
+
+
+
{subEtapaError}
+
+ {/if}
+
+
+
+
+
+ {/if}
+
+
+ {#if toastMessage}
+
+
+
{toastMessage}
+
(toastMessage = null)}
+ aria-label="Fechar notificação"
+ >
+
+
+
+
+
+
+ {/if}
+
+
+
+{#if showReassignModal && stepToReassign}
+
+
+
Reatribuir Responsável
+
+ Selecione o novo responsável pelo passo {stepToReassign.stepName}
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Reatribuir
+
+
+
+
+
+{/if}
+
+
+{#if showNotesModal && stepForNotes}
+
+
+
Notas do Passo
+
+ Adicione ou edite notas para o passo {stepForNotes.stepName}
+
+
+
+
+ Notas
+
+
+
+
+
+
+ Cancelar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Salvar
+
+
+
+
+
+{/if}
+
+
+{#if showUploadModal && stepForUpload}
+
+
+
Upload de Documento
+
+ Anexe um documento ao passo {stepForUpload.stepName}
+
+
+
+
+ Arquivo
+
+
+
+
+ {#if uploadFile}
+
+ Arquivo selecionado: {uploadFile.name}
+
+ {/if}
+
+
+
+ Cancelar
+
+
+ {#if isUploading}
+
+ {/if}
+ Enviar
+
+
+
+
+
+{/if}
+
+
+{#if showCancelModal}
+
+
+
Cancelar Fluxo
+
+ Tem certeza que deseja cancelar este fluxo?
+
+
+ Esta ação não pode ser desfeita. Todos os passos pendentes serão marcados como cancelados.
+
+
+
+ showCancelModal = false} disabled={isProcessing}>
+ Voltar
+
+
+ {#if isProcessing}
+
+ {/if}
+ Cancelar Fluxo
+
+
+
+
showCancelModal = false} aria-label="Fechar modal">
+
+{/if}
+
diff --git a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte
index 515fd6a..ca05422 100644
--- a/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte
+++ b/apps/web/src/routes/(dashboard)/recursos-humanos/funcionarios/+page.svelte
@@ -1,23 +1,42 @@
-
+
-
+
@@ -118,7 +186,7 @@
-
+
-
+
Nome
CPF
@@ -277,7 +345,7 @@
{:else}
- {#each filtered as f}
+ {#each filtered as f (f._id)}
{f.nome}
{f.cpf}
@@ -314,20 +382,28 @@
class="dropdown-content menu bg-base-100 rounded-box border-base-300 z-20 w-52 border p-2 shadow-xl"
>
-
+
Ver Detalhes
-
+
Editar
-
+
Ver Documentos
+
+ openSetoresModal(f._id, f.nome)}
+ class="hover:bg-primary/10"
+ >
+ Atribuir Setores
+
+
openPrintModal(f._id)} class="hover:bg-primary/10">
Imprimir Ficha
@@ -347,7 +423,7 @@
-
+
Exibindo {filtered.length} de {list.length} funcionário(s)
@@ -359,4 +435,85 @@
onClose={() => (funcionarioParaImprimir = null)}
/>
{/if}
+
+
+ {#if showSetoresModal && funcionarioParaSetores}
+
+
+
Atribuir Setores
+
+ Selecione os setores para {funcionarioParaSetores.nome}
+
+
+ {#if setoresError}
+
+ {/if}
+
+
+ {#if todosSetoresQuery.isLoading}
+
+
+
+ {:else if todosSetoresQuery.data && todosSetoresQuery.data.length > 0}
+
+ {#each todosSetoresQuery.data as setor (setor._id)}
+ {@const isSelected = setoresSelecionados.includes(setor._id)}
+
+ toggleSetor(setor._id)}
+ aria-label="Selecionar setor {setor.nome}"
+ />
+
+
{setor.nome}
+
Sigla: {setor.sigla}
+
+
+ {/each}
+
+ {:else}
+
+
Nenhum setor cadastrado
+
+ {/if}
+
+
+
+
+ Cancelar
+
+
+ {#if isSavingSetores}
+
+ {/if}
+ Salvar
+
+
+
+
+
+ {/if}
diff --git a/packages/backend/convex/flows.ts b/packages/backend/convex/flows.ts
index 3fa731f..6a8e8cd 100644
--- a/packages/backend/convex/flows.ts
+++ b/packages/backend/convex/flows.ts
@@ -1,9 +1,163 @@
import { query, mutation } from './_generated/server';
+import { internal } from './_generated/api';
import { v } from 'convex/values';
import { getCurrentUserFunction } from './auth';
import type { Id, Doc } from './_generated/dataModel';
+import type { MutationCtx, QueryCtx } from './_generated/server';
import { flowTemplateStatus, flowInstanceStatus, flowInstanceStepStatus } from './schema';
+// ============================================
+// HELPER FUNCTIONS
+// ============================================
+
+/**
+ * Criar notificações para todos os funcionários de um setor
+ */
+async function criarNotificacaoParaSetor(
+ ctx: MutationCtx,
+ setorId: Id<'setores'>,
+ titulo: string,
+ descricao: string
+): Promise {
+ // Buscar funcionários do setor
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', setorId))
+ .collect();
+
+ // Para cada funcionário, buscar usuário correspondente e criar notificação
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (!funcionario) continue;
+
+ // Buscar usuário por email
+ const usuarios = await ctx.db.query('usuarios').collect();
+ const usuario = usuarios.find((u: Doc<'usuarios'>) => u.email === funcionario.email);
+
+ if (usuario) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: usuario._id,
+ tipo: 'etapa_fluxo_concluida',
+ titulo,
+ descricao,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+ }
+}
+
+/**
+ * Obter a etapa anterior de um passo
+ */
+async function obterEtapaAnterior(
+ ctx: MutationCtx | QueryCtx,
+ instanceStepId: Id<'flowInstanceSteps'>
+): Promise | null> {
+ const step = await ctx.db.get(instanceStepId);
+ if (!step) {
+ return null;
+ }
+
+ // Buscar todos os passos da instância
+ const allSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', step.flowInstanceId))
+ .collect();
+
+ // Obter posições de cada passo
+ const stepsWithPosition: Array<{ step: Doc<'flowInstanceSteps'>; position: number }> = [];
+ for (const s of allSteps) {
+ const flowStep = await ctx.db.get(s.flowStepId);
+ if (flowStep) {
+ stepsWithPosition.push({ step: s, position: flowStep.position });
+ }
+ }
+
+ // Ordenar por posição
+ stepsWithPosition.sort((a, b) => a.position - b.position);
+
+ // Encontrar posição do passo atual
+ const currentIndex = stepsWithPosition.findIndex((s) => s.step._id === instanceStepId);
+
+ // Se for o primeiro passo, não há etapa anterior
+ if (currentIndex <= 0) {
+ return null;
+ }
+
+ // Retornar etapa anterior
+ return stepsWithPosition[currentIndex - 1].step;
+}
+
+/**
+ * Verificar se usuário pertence a algum setor do fluxo
+ */
+async function usuarioPertenceAAlgumSetorDoFluxo(
+ ctx: QueryCtx | MutationCtx,
+ usuarioId: Id<'usuarios'>,
+ flowInstanceId: Id<'flowInstances'>
+): Promise {
+ // Buscar usuário
+ const usuario = await ctx.db.get(usuarioId);
+ if (!usuario) {
+ return false;
+ }
+
+ // Buscar funcionário por email
+ const funcionarios = await ctx.db.query('funcionarios').collect();
+ const funcionario = funcionarios.find((f) => f.email === usuario.email);
+ if (!funcionario) {
+ return false;
+ }
+
+ // Buscar setores do funcionário
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionario._id))
+ .collect();
+
+ const setoresDoFuncionario = new Set>();
+ for (const relacao of funcionarioSetores) {
+ setoresDoFuncionario.add(relacao.setorId);
+ }
+
+ // Buscar todos os passos do fluxo
+ const instanceSteps = await ctx.db
+ .query('flowInstanceSteps')
+ .withIndex('by_flowInstanceId', (q) => q.eq('flowInstanceId', flowInstanceId))
+ .collect();
+
+ // Extrair setores do fluxo
+ const setoresDoFluxo = new Set>();
+ for (const step of instanceSteps) {
+ setoresDoFluxo.add(step.setorId);
+ }
+
+ // Verificar interseção
+ for (const setorId of setoresDoFuncionario) {
+ if (setoresDoFluxo.has(setorId)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Verificar se usuário tem permissão para ver todas as instâncias de fluxo
+ */
+async function verificarPermissaoVerTodasFluxos(ctx: QueryCtx | MutationCtx): Promise {
+ try {
+ await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
+ recurso: 'fluxos_instancias',
+ acao: 'listar'
+ });
+ return true;
+ } catch {
+ return false;
+ }
+}
+
// ============================================
// FLOW TEMPLATES - CRUD
// ============================================
@@ -421,13 +575,84 @@ export const deleteStep = mutation({
// FLOW INSTANCES
// ============================================
+/**
+ * Verificar permissões do usuário para um fluxo
+ */
+export const verificarPermissoesFluxo = query({
+ args: { flowInstanceId: v.id('flowInstances') },
+ returns: v.object({
+ podeAtribuir: v.boolean(),
+ podeEditar: v.boolean(),
+ podeVer: v.boolean(),
+ eCriador: v.boolean(),
+ eGestor: v.boolean()
+ }),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return {
+ podeAtribuir: false,
+ podeEditar: false,
+ podeVer: false,
+ eCriador: false,
+ eGestor: false
+ };
+ }
+
+ const instance = await ctx.db.get(args.flowInstanceId);
+ if (!instance) {
+ return {
+ podeAtribuir: false,
+ podeEditar: false,
+ podeVer: false,
+ eCriador: false,
+ eGestor: false
+ };
+ }
+
+ // Verificar se é gestor
+ const eGestor = instance.managerId === usuario._id;
+
+ // Verificar se é criador do template
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const eCriador = template?.createdBy === usuario._id;
+
+ // Verificar permissão de ver todas
+ const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
+
+ // Verificar se pertence a setor do fluxo
+ const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
+ ctx,
+ usuario._id,
+ args.flowInstanceId
+ );
+
+ // Pode ver se: tem permissão, é gestor, é criador, ou pertence ao setor
+ const podeVer = temPermissaoVerTodas || eGestor || eCriador || pertenceAoSetor;
+
+ // Pode editar se: é criador ou tem permissão de ver todas
+ const podeEditar = eCriador || temPermissaoVerTodas;
+
+ // Pode atribuir se: é criador, é gestor, ou tem permissão de ver todas
+ const podeAtribuir = eCriador || eGestor || temPermissaoVerTodas;
+
+ return {
+ podeAtribuir,
+ podeEditar,
+ podeVer,
+ eCriador,
+ eGestor
+ };
+ }
+});
+
/**
* Listar instâncias de fluxo
*/
export const listInstances = query({
args: {
status: v.optional(flowInstanceStatus),
- targetType: v.optional(v.string()),
+ contratoId: v.optional(v.id('contratos')),
managerId: v.optional(v.id('usuarios'))
},
returns: v.array(
@@ -436,8 +661,7 @@ export const listInstances = query({
_creationTime: v.number(),
flowTemplateId: v.id('flowTemplates'),
templateName: v.optional(v.string()),
- targetType: v.string(),
- targetId: v.string(),
+ contratoId: v.optional(v.id('contratos')),
managerId: v.id('usuarios'),
managerName: v.optional(v.string()),
status: flowInstanceStatus,
@@ -452,6 +676,14 @@ export const listInstances = query({
})
),
handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return [];
+ }
+
+ // Verificar se usuário tem permissão para ver todas
+ const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
+
let instances;
if (args.status) {
@@ -466,22 +698,22 @@ export const listInstances = query({
.withIndex('by_managerId', (q) => q.eq('managerId', args.managerId!))
.order('desc')
.collect();
+ } else if (args.contratoId) {
+ instances = await ctx.db
+ .query('flowInstances')
+ .withIndex('by_contratoId', (q) => q.eq('contratoId', args.contratoId!))
+ .order('desc')
+ .collect();
} else {
instances = await ctx.db.query('flowInstances').order('desc').collect();
}
- // 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;
+ contratoId: Id<'contratos'> | undefined;
managerId: Id<'usuarios'>;
managerName: string | undefined;
status: Doc<'flowInstances'>['status'];
@@ -492,7 +724,22 @@ export const listInstances = query({
progress: { completed: number; total: number };
}> = [];
- for (const instance of filteredInstances) {
+ for (const instance of instances) {
+ // Se não tem permissão de ver todas, verificar se usuário pertence a algum setor do fluxo
+ if (!temPermissaoVerTodas) {
+ const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
+ ctx,
+ usuario._id,
+ instance._id
+ );
+ if (!pertenceAoSetor) {
+ // Também verificar se é o manager
+ if (instance.managerId !== usuario._id) {
+ continue; // Pular esta instância
+ }
+ }
+ }
+
const template = await ctx.db.get(instance.flowTemplateId);
const manager = await ctx.db.get(instance.managerId);
@@ -519,8 +766,7 @@ export const listInstances = query({
_creationTime: instance._creationTime,
flowTemplateId: instance.flowTemplateId,
templateName: template?.name,
- targetType: instance.targetType,
- targetId: instance.targetId,
+ contratoId: instance.contratoId,
managerId: instance.managerId,
managerName: manager?.nome,
status: instance.status,
@@ -551,14 +797,14 @@ export const getInstanceWithSteps = query({
_creationTime: v.number(),
flowTemplateId: v.id('flowTemplates'),
templateName: v.optional(v.string()),
- targetType: v.string(),
- targetId: v.string(),
+ contratoId: v.optional(v.id('contratos')),
managerId: v.id('usuarios'),
managerName: v.optional(v.string()),
status: flowInstanceStatus,
startedAt: v.number(),
finishedAt: v.optional(v.number()),
- currentStepId: v.optional(v.id('flowInstanceSteps'))
+ currentStepId: v.optional(v.id('flowInstanceSteps')),
+ prazoTotalDias: v.number()
}),
steps: v.array(
v.object({
@@ -576,8 +822,12 @@ export const getInstanceWithSteps = query({
startedAt: v.optional(v.number()),
finishedAt: v.optional(v.number()),
notes: v.optional(v.string()),
+ notesUpdatedBy: v.optional(v.id('usuarios')),
+ notesUpdatedByName: v.optional(v.string()),
+ notesUpdatedAt: v.optional(v.number()),
dueDate: v.optional(v.number()),
position: v.number(),
+ expectedDuration: v.number(),
documents: v.array(
v.object({
_id: v.id('flowInstanceDocuments'),
@@ -592,9 +842,30 @@ export const getInstanceWithSteps = query({
v.null()
),
handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return null;
+ }
+
const instance = await ctx.db.get(args.id);
if (!instance) return null;
+ // Verificar permissão de visualização
+ const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
+
+ if (!temPermissaoVerTodas) {
+ // Verificar se usuário pertence a algum setor do fluxo ou é o manager
+ const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
+ ctx,
+ usuario._id,
+ instance._id
+ );
+
+ if (!pertenceAoSetor && instance.managerId !== usuario._id) {
+ return null; // Usuário não tem acesso
+ }
+ }
+
const template = await ctx.db.get(instance.flowTemplateId);
const manager = await ctx.db.get(instance.managerId);
@@ -622,6 +893,7 @@ export const getInstanceWithSteps = query({
notes: string | undefined;
dueDate: number | undefined;
position: number;
+ expectedDuration: number;
documents: Array<{
_id: Id<'flowInstanceDocuments'>;
name: string;
@@ -672,8 +944,12 @@ export const getInstanceWithSteps = query({
startedAt: step.startedAt,
finishedAt: step.finishedAt,
notes: step.notes,
+ notesUpdatedBy: step.notesUpdatedBy,
+ notesUpdatedByName: step.notesUpdatedBy ? (await ctx.db.get(step.notesUpdatedBy))?.nome : undefined,
+ notesUpdatedAt: step.notesUpdatedAt,
dueDate: step.dueDate,
position: flowStep?.position ?? 0,
+ expectedDuration: flowStep?.expectedDuration ?? 0,
documents: docsWithUploader
});
}
@@ -681,13 +957,52 @@ export const getInstanceWithSteps = query({
// Ordenar por position
stepsWithDetails.sort((a, b) => a.position - b.position);
+ // Calcular prazo total do fluxo (soma de expectedDuration de todas as etapas)
+ const prazoTotalDias = stepsWithDetails.reduce((sum, step) => sum + step.expectedDuration, 0);
+
return {
instance: {
- ...instance,
+ _id: instance._id,
+ _creationTime: instance._creationTime,
+ flowTemplateId: instance.flowTemplateId,
templateName: template?.name,
- managerName: manager?.nome
+ contratoId: instance.contratoId,
+ managerId: instance.managerId,
+ managerName: manager?.nome,
+ status: instance.status,
+ startedAt: instance.startedAt,
+ finishedAt: instance.finishedAt,
+ currentStepId: instance.currentStepId,
+ prazoTotalDias
},
- steps: stepsWithDetails
+ steps: stepsWithDetails as Array<{
+ _id: Id<'flowInstanceSteps'>;
+ _creationTime: number;
+ flowInstanceId: Id<'flowInstances'>;
+ flowStepId: Id<'flowSteps'>;
+ stepName: string;
+ stepDescription: string | undefined;
+ setorId: Id<'setores'>;
+ setorNome: string | undefined;
+ assignedToId: Id<'usuarios'> | undefined;
+ assignedToName: string | undefined;
+ status: Doc<'flowInstanceSteps'>['status'];
+ startedAt: number | undefined;
+ finishedAt: number | undefined;
+ notes: string | undefined;
+ notesUpdatedBy: Id<'usuarios'> | undefined;
+ notesUpdatedByName: string | undefined;
+ notesUpdatedAt: number | undefined;
+ dueDate: number | undefined;
+ position: number;
+ expectedDuration: number;
+ documents: Array<{
+ _id: Id<'flowInstanceDocuments'>;
+ name: string;
+ uploadedAt: number;
+ uploadedByName: string | undefined;
+ }>;
+ }>
};
}
});
@@ -698,8 +1013,7 @@ export const getInstanceWithSteps = query({
export const instantiateFlow = mutation({
args: {
flowTemplateId: v.id('flowTemplates'),
- targetType: v.string(),
- targetId: v.string(),
+ contratoId: v.optional(v.id('contratos')),
managerId: v.id('usuarios')
},
returns: v.id('flowInstances'),
@@ -736,8 +1050,7 @@ export const instantiateFlow = mutation({
// Criar a instância
const instanceId = await ctx.db.insert('flowInstances', {
flowTemplateId: args.flowTemplateId,
- targetType: args.targetType,
- targetId: args.targetId,
+ contratoId: args.contratoId,
managerId: args.managerId,
status: 'active',
startedAt: now
@@ -771,6 +1084,18 @@ export const instantiateFlow = mutation({
await ctx.db.patch(instanceId, { currentStepId: firstStepId });
}
+ // Notificar gestor sobre criação do fluxo
+ if (args.managerId) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: args.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Novo Fluxo Criado',
+ descricao: `Um novo fluxo "${template?.name ?? 'Fluxo'}" foi criado e você foi designado como gestor.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+
return instanceId;
}
});
@@ -836,9 +1161,46 @@ export const completeStep = mutation({
const currentPosition = flowSteps.findIndex((s) => s.stepId === args.instanceStepId);
const nextStep = flowSteps[currentPosition + 1];
+ // Obter informações do template e do passo para as notificações
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const flowStep = await ctx.db.get(step.flowStepId);
+ const setorAtual = await ctx.db.get(step.setorId);
+
+ // Criar notificações para o setor do passo concluído
+ if (setorAtual && flowStep) {
+ const tituloSetorAtual = 'Etapa de Fluxo Concluída';
+ const descricaoSetorAtual = `A etapa "${flowStep.name}" do fluxo "${template?.name ?? 'Fluxo'}" foi concluída.`;
+ await criarNotificacaoParaSetor(ctx, step.setorId, tituloSetorAtual, descricaoSetorAtual);
+ }
+
+ // Notificar gestor sobre conclusão da etapa
+ if (instance.managerId) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: instance.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Etapa de Fluxo Concluída',
+ descricao: `A etapa "${flowStep?.name ?? 'Etapa'}" do fluxo "${template?.name ?? 'Fluxo'}" foi concluída.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
+
if (nextStep) {
// Atualizar currentStepId para o próximo passo
await ctx.db.patch(step.flowInstanceId, { currentStepId: nextStep.stepId });
+
+ // Criar notificações para o setor do próximo passo
+ const nextStepData = await ctx.db.get(nextStep.stepId);
+ if (nextStepData) {
+ const nextFlowStep = await ctx.db.get(nextStepData.flowStepId);
+ const nextSetor = await ctx.db.get(nextStepData.setorId);
+
+ if (nextSetor && nextFlowStep) {
+ const tituloProximoSetor = 'Nova Etapa de Fluxo Disponível';
+ const descricaoProximoSetor = `A etapa "${nextFlowStep.name}" do fluxo "${template?.name ?? 'Fluxo'}" está pronta para ser iniciada.`;
+ await criarNotificacaoParaSetor(ctx, nextStepData.setorId, tituloProximoSetor, descricaoProximoSetor);
+ }
+ }
} else {
// Todos os passos concluídos, marcar instância como completada
await ctx.db.patch(step.flowInstanceId, {
@@ -846,6 +1208,18 @@ export const completeStep = mutation({
finishedAt: now,
currentStepId: undefined
});
+
+ // Notificar gestor sobre conclusão do fluxo
+ if (instance.managerId) {
+ await ctx.db.insert('notificacoes', {
+ usuarioId: instance.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Fluxo Concluído',
+ descricao: `O fluxo "${template?.name ?? 'Fluxo'}" foi concluído com sucesso.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
}
return null;
@@ -876,6 +1250,21 @@ export const updateStepStatus = mutation({
if (args.status === 'in_progress' && !step.startedAt) {
updates.startedAt = Date.now();
+
+ // Notificar gestor sobre início da etapa
+ const instance = await ctx.db.get(step.flowInstanceId);
+ if (instance?.managerId) {
+ const flowStep = await ctx.db.get(step.flowStepId);
+ const template = await ctx.db.get(instance.flowTemplateId);
+ await ctx.db.insert('notificacoes', {
+ usuarioId: instance.managerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Etapa de Fluxo Iniciada',
+ descricao: `A etapa "${flowStep?.name ?? 'Etapa'}" do fluxo "${template?.name ?? 'Fluxo'}" foi iniciada.`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+ }
}
await ctx.db.patch(args.instanceStepId, updates);
@@ -883,6 +1272,61 @@ export const updateStepStatus = mutation({
}
});
+/**
+ * Alterar gestor do fluxo
+ */
+export const alterarGestorFluxo = mutation({
+ args: {
+ flowInstanceId: v.id('flowInstances'),
+ novoManagerId: v.id('usuarios')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const instance = await ctx.db.get(args.flowInstanceId);
+ if (!instance) {
+ throw new Error('Fluxo não encontrado');
+ }
+
+ // Verificar se usuário atual é gestor ou tem permissão
+ const eGestor = instance.managerId === usuario._id;
+ const temPermissao = await verificarPermissaoVerTodasFluxos(ctx);
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const eCriador = template?.createdBy === usuario._id;
+
+ if (!eGestor && !temPermissao && !eCriador) {
+ throw new Error('Somente o gestor atual, criador do template ou usuário com permissão pode alterar o gestor');
+ }
+
+ // Verificar se novo gestor existe
+ const novoGestor = await ctx.db.get(args.novoManagerId);
+ if (!novoGestor) {
+ throw new Error('Usuário não encontrado');
+ }
+
+ // Atualizar gestor
+ await ctx.db.patch(args.flowInstanceId, {
+ managerId: args.novoManagerId
+ });
+
+ // Criar notificação para novo gestor
+ await ctx.db.insert('notificacoes', {
+ usuarioId: args.novoManagerId,
+ tipo: 'etapa_fluxo_concluida',
+ titulo: 'Você foi designado como gestor de um fluxo',
+ descricao: `Você foi designado como gestor do fluxo "${template?.name ?? 'Fluxo'}"`,
+ lida: false,
+ criadaEm: Date.now()
+ });
+
+ return null;
+ }
+});
+
/**
* Reatribuir responsável de um passo
*/
@@ -909,6 +1353,65 @@ export const reassignStep = mutation({
throw new Error('Usuário não encontrado');
}
+ // Verificar se o usuário atual pode atribuir esta etapa
+ const instance = await ctx.db.get(step.flowInstanceId);
+ if (!instance) {
+ throw new Error('Instância não encontrada');
+ }
+
+ // Verificar se é criador do template
+ const template = await ctx.db.get(instance.flowTemplateId);
+ const eCriador = template?.createdBy === usuario._id;
+
+ // Se for criador, permitir atribuição
+ if (!eCriador) {
+ // Se não for criador, verificar regra normal
+ const etapaAnterior = await obterEtapaAnterior(ctx, args.instanceStepId);
+
+ if (etapaAnterior) {
+ // Se há etapa anterior, verificar se o usuário atual é a pessoa atribuída
+ if (etapaAnterior.assignedToId) {
+ if (etapaAnterior.assignedToId !== usuario._id) {
+ throw new Error('Somente a pessoa da etapa anterior pode atribuir esta etapa');
+ }
+ }
+ // Se a etapa anterior não tem atribuição, permitir (vai para o setor)
+ } else {
+ // Se não há etapa anterior (primeiro passo), verificar se é o manager
+ // Se não for o manager, verificar permissão
+ if (instance.managerId !== usuario._id) {
+ const temPermissao = await verificarPermissaoVerTodasFluxos(ctx);
+ if (!temPermissao) {
+ throw new Error('Somente o gerente do fluxo ou pessoa da etapa anterior pode atribuir esta etapa');
+ }
+ }
+ }
+ }
+
+ // Verificar se o funcionário atribuído pertence ao setor do passo
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', step.setorId))
+ .collect();
+
+ // Buscar funcionários vinculados a este setor
+ const funcionariosDoSetor = [];
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (funcionario) {
+ funcionariosDoSetor.push(funcionario);
+ }
+ }
+
+ // Verificar se o usuário atribuído corresponde a um funcionário do setor
+ const funcionarioDoUsuario = funcionariosDoSetor.find(
+ (f) => f.email === assignee.email
+ );
+
+ if (!funcionarioDoUsuario) {
+ throw new Error('O funcionário atribuído não pertence ao setor deste passo');
+ }
+
await ctx.db.patch(args.instanceStepId, { assignedToId: args.assignedToId });
return null;
}
@@ -934,7 +1437,11 @@ export const updateStepNotes = mutation({
throw new Error('Passo não encontrado');
}
- await ctx.db.patch(args.instanceStepId, { notes: args.notes });
+ await ctx.db.patch(args.instanceStepId, {
+ notes: args.notes,
+ notesUpdatedBy: usuario._id,
+ notesUpdatedAt: Date.now()
+ });
return null;
}
});
@@ -969,6 +1476,504 @@ export const cancelInstance = mutation({
}
});
+// ============================================
+// SUB-ETAPAS
+// ============================================
+
+/**
+ * Listar sub-etapas de um step
+ */
+export const listarSubEtapas = query({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps'))
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowSubSteps'),
+ _creationTime: v.number(),
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ name: v.string(),
+ description: v.optional(v.string()),
+ status: v.union(
+ v.literal('pending'),
+ v.literal('in_progress'),
+ v.literal('completed'),
+ v.literal('blocked')
+ ),
+ position: v.number(),
+ createdBy: v.id('usuarios'),
+ createdByName: v.optional(v.string())
+ })
+ ),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return [];
+ }
+
+ let subEtapas;
+ if (args.flowStepId) {
+ subEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowStepId', (q) => q.eq('flowStepId', args.flowStepId))
+ .collect();
+ } else if (args.flowInstanceStepId) {
+ subEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+ } else {
+ return [];
+ }
+
+ // Ordenar por position
+ subEtapas.sort((a, b) => a.position - b.position);
+
+ // Adicionar nome do criador
+ const subEtapasComCriador = await Promise.all(
+ subEtapas.map(async (subEtapa) => {
+ const criador = await ctx.db.get(subEtapa.createdBy);
+ return {
+ _id: subEtapa._id,
+ _creationTime: subEtapa._creationTime,
+ flowStepId: subEtapa.flowStepId,
+ flowInstanceStepId: subEtapa.flowInstanceStepId,
+ name: subEtapa.name,
+ description: subEtapa.description,
+ status: subEtapa.status,
+ position: subEtapa.position,
+ createdBy: subEtapa.createdBy,
+ createdByName: criador?.nome
+ };
+ })
+ );
+
+ return subEtapasComCriador;
+ }
+});
+
+/**
+ * Criar sub-etapa
+ */
+export const criarSubEtapa = mutation({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ name: v.string(),
+ description: v.optional(v.string())
+ },
+ returns: v.id('flowSubSteps'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ if (!args.flowStepId && !args.flowInstanceStepId) {
+ throw new Error('É necessário fornecer flowStepId ou flowInstanceStepId');
+ }
+
+ // Verificar se o step existe
+ if (args.flowStepId) {
+ const flowStep = await ctx.db.get(args.flowStepId);
+ if (!flowStep) {
+ throw new Error('Passo do template não encontrado');
+ }
+ }
+
+ if (args.flowInstanceStepId) {
+ const flowInstanceStep = await ctx.db.get(args.flowInstanceStepId);
+ if (!flowInstanceStep) {
+ throw new Error('Passo da instância não encontrado');
+ }
+ }
+
+ // Obter posição máxima
+ let maxPosition = 0;
+ if (args.flowStepId) {
+ const existingSubEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowStepId', (q) => q.eq('flowStepId', args.flowStepId))
+ .collect();
+ if (existingSubEtapas.length > 0) {
+ maxPosition = Math.max(...existingSubEtapas.map((s) => s.position));
+ }
+ } else if (args.flowInstanceStepId) {
+ const existingSubEtapas = await ctx.db
+ .query('flowSubSteps')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+ if (existingSubEtapas.length > 0) {
+ maxPosition = Math.max(...existingSubEtapas.map((s) => s.position));
+ }
+ }
+
+ const subEtapaId = await ctx.db.insert('flowSubSteps', {
+ flowStepId: args.flowStepId,
+ flowInstanceStepId: args.flowInstanceStepId,
+ name: args.name,
+ description: args.description,
+ status: 'pending',
+ position: maxPosition + 1,
+ createdBy: usuario._id,
+ createdAt: Date.now()
+ });
+
+ return subEtapaId;
+ }
+});
+
+/**
+ * Atualizar sub-etapa
+ */
+export const atualizarSubEtapa = mutation({
+ args: {
+ subEtapaId: v.id('flowSubSteps'),
+ name: v.optional(v.string()),
+ description: v.optional(v.string()),
+ status: v.optional(
+ v.union(
+ v.literal('pending'),
+ v.literal('in_progress'),
+ v.literal('completed'),
+ v.literal('blocked')
+ )
+ )
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const subEtapa = await ctx.db.get(args.subEtapaId);
+ if (!subEtapa) {
+ throw new Error('Sub-etapa não encontrada');
+ }
+
+ const updates: Partial> = {};
+ if (args.name !== undefined) {
+ updates.name = args.name;
+ }
+ if (args.description !== undefined) {
+ updates.description = args.description;
+ }
+ if (args.status !== undefined) {
+ updates.status = args.status;
+ }
+
+ await ctx.db.patch(args.subEtapaId, updates);
+ return null;
+ }
+});
+
+/**
+ * Deletar sub-etapa
+ */
+export const deletarSubEtapa = mutation({
+ args: { subEtapaId: v.id('flowSubSteps') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const subEtapa = await ctx.db.get(args.subEtapaId);
+ if (!subEtapa) {
+ throw new Error('Sub-etapa não encontrada');
+ }
+
+ await ctx.db.delete(args.subEtapaId);
+ return null;
+ }
+});
+
+/**
+ * Reordenar sub-etapas
+ */
+export const reordenarSubEtapas = mutation({
+ args: {
+ subEtapaIds: v.array(v.id('flowSubSteps'))
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // Atualizar posições
+ for (let i = 0; i < args.subEtapaIds.length; i++) {
+ await ctx.db.patch(args.subEtapaIds[i], { position: i + 1 });
+ }
+
+ return null;
+ }
+});
+
+// ============================================
+// NOTAS
+// ============================================
+
+/**
+ * Listar notas de um step ou sub-etapa
+ */
+export const listarNotas = query({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ flowSubStepId: v.optional(v.id('flowSubSteps'))
+ },
+ returns: v.array(
+ v.object({
+ _id: v.id('flowStepNotes'),
+ _creationTime: v.number(),
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ flowSubStepId: v.optional(v.id('flowSubSteps')),
+ texto: v.string(),
+ criadoPor: v.id('usuarios'),
+ criadoPorNome: v.optional(v.string()),
+ criadoEm: v.number(),
+ arquivos: v.array(
+ v.object({
+ storageId: v.id('_storage'),
+ name: v.string()
+ })
+ )
+ })
+ ),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ return [];
+ }
+
+ let notas;
+ if (args.flowStepId) {
+ notas = await ctx.db
+ .query('flowStepNotes')
+ .withIndex('by_flowStepId', (q) => q.eq('flowStepId', args.flowStepId))
+ .collect();
+ } else if (args.flowInstanceStepId) {
+ notas = await ctx.db
+ .query('flowStepNotes')
+ .withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
+ .collect();
+ } else if (args.flowSubStepId) {
+ notas = await ctx.db
+ .query('flowStepNotes')
+ .withIndex('by_flowSubStepId', (q) => q.eq('flowSubStepId', args.flowSubStepId))
+ .collect();
+ } else {
+ return [];
+ }
+
+ // Ordenar por data de criação (mais recente primeiro)
+ notas.sort((a, b) => b.criadoEm - a.criadoEm);
+
+ // Adicionar nome do criador e informações dos arquivos
+ const notasComDetalhes = await Promise.all(
+ notas.map(async (nota) => {
+ const criador = await ctx.db.get(nota.criadoPor);
+
+ // Obter informações dos arquivos
+ const arquivosComNome = await Promise.all(
+ nota.arquivos.map(async (storageId) => {
+ // Buscar documento que referencia este storageId
+ // Como não temos uma tabela direta, vamos buscar nos flowInstanceDocuments
+ const documentos = await ctx.db
+ .query('flowInstanceDocuments')
+ .collect();
+ const documento = documentos.find((d) => d.storageId === storageId);
+
+ return {
+ storageId,
+ name: documento?.name ?? 'Arquivo'
+ };
+ })
+ );
+
+ return {
+ _id: nota._id,
+ _creationTime: nota._creationTime,
+ flowStepId: nota.flowStepId,
+ flowInstanceStepId: nota.flowInstanceStepId,
+ flowSubStepId: nota.flowSubStepId,
+ texto: nota.texto,
+ criadoPor: nota.criadoPor,
+ criadoPorNome: criador?.nome,
+ criadoEm: nota.criadoEm,
+ arquivos: arquivosComNome
+ };
+ })
+ );
+
+ return notasComDetalhes;
+ }
+});
+
+/**
+ * Adicionar nota
+ */
+export const adicionarNota = mutation({
+ args: {
+ flowStepId: v.optional(v.id('flowSteps')),
+ flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
+ flowSubStepId: v.optional(v.id('flowSubSteps')),
+ texto: v.string(),
+ arquivos: v.optional(v.array(v.id('_storage')))
+ },
+ returns: v.id('flowStepNotes'),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ if (!args.flowStepId && !args.flowInstanceStepId && !args.flowSubStepId) {
+ throw new Error('É necessário fornecer flowStepId, flowInstanceStepId ou flowSubStepId');
+ }
+
+ const notaId = await ctx.db.insert('flowStepNotes', {
+ flowStepId: args.flowStepId,
+ flowInstanceStepId: args.flowInstanceStepId,
+ flowSubStepId: args.flowSubStepId,
+ texto: args.texto,
+ criadoPor: usuario._id,
+ criadoEm: Date.now(),
+ arquivos: args.arquivos ?? []
+ });
+
+ return notaId;
+ }
+});
+
+/**
+ * Atualizar nota
+ */
+export const atualizarNota = mutation({
+ args: {
+ notaId: v.id('flowStepNotes'),
+ texto: v.string()
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode editá-la');
+ }
+
+ await ctx.db.patch(args.notaId, { texto: args.texto });
+ return null;
+ }
+});
+
+/**
+ * Deletar nota
+ */
+export const deletarNota = mutation({
+ args: { notaId: v.id('flowStepNotes') },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode deletá-la');
+ }
+
+ await ctx.db.delete(args.notaId);
+ return null;
+ }
+});
+
+/**
+ * Adicionar arquivo a uma nota
+ */
+export const adicionarArquivoNota = mutation({
+ args: {
+ notaId: v.id('flowStepNotes'),
+ storageId: v.id('_storage')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode adicionar arquivos');
+ }
+
+ // Adicionar arquivo à lista
+ const novosArquivos = [...nota.arquivos, args.storageId];
+ await ctx.db.patch(args.notaId, { arquivos: novosArquivos });
+ return null;
+ }
+});
+
+/**
+ * Remover arquivo de uma nota
+ */
+export const removerArquivoNota = mutation({
+ args: {
+ notaId: v.id('flowStepNotes'),
+ storageId: v.id('_storage')
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ const nota = await ctx.db.get(args.notaId);
+ if (!nota) {
+ throw new Error('Nota não encontrada');
+ }
+
+ // Verificar se o usuário é o criador
+ if (nota.criadoPor !== usuario._id) {
+ throw new Error('Somente o criador da nota pode remover arquivos');
+ }
+
+ // Remover arquivo da lista
+ const novosArquivos = nota.arquivos.filter((id) => id !== args.storageId);
+ await ctx.db.patch(args.notaId, { arquivos: novosArquivos });
+ return null;
+ }
+});
+
// ============================================
// FLOW DOCUMENTS
// ============================================
@@ -1100,3 +2105,53 @@ export const generateUploadUrl = mutation({
}
});
+/**
+ * Obter usuários que podem ser atribuídos a um passo (baseado no setor)
+ */
+export const getUsuariosBySetorForAssignment = query({
+ args: { setorId: v.id('setores') },
+ returns: v.array(
+ v.object({
+ _id: v.id('usuarios'),
+ _creationTime: v.number(),
+ nome: v.string(),
+ email: v.string()
+ })
+ ),
+ handler: async (ctx, args) => {
+ // Buscar funcionários do setor
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', args.setorId))
+ .collect();
+
+ const usuarios: Array<{
+ _id: Id<'usuarios'>;
+ _creationTime: number;
+ nome: string;
+ email: string;
+ }> = [];
+
+ // Para cada funcionário do setor, buscar o usuário correspondente
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (!funcionario) continue;
+
+ // Buscar usuário por email
+ const usuariosList = await ctx.db.query('usuarios').collect();
+ const usuario = usuariosList.find((u) => u.email === funcionario.email);
+
+ if (usuario && !usuarios.find((u) => u._id === usuario._id)) {
+ usuarios.push({
+ _id: usuario._id,
+ _creationTime: usuario._creationTime,
+ nome: usuario.nome,
+ email: usuario.email
+ });
+ }
+ }
+
+ return usuarios;
+ }
+});
+
diff --git a/packages/backend/convex/schema.ts b/packages/backend/convex/schema.ts
index 3fc5702..905319f 100644
--- a/packages/backend/convex/schema.ts
+++ b/packages/backend/convex/schema.ts
@@ -163,6 +163,16 @@ export default defineSchema({
.index("by_nome", ["nome"])
.index("by_sigla", ["sigla"]),
+ // Relação muitos-para-muitos entre funcionários e setores
+ funcionarioSetores: defineTable({
+ funcionarioId: v.id("funcionarios"),
+ setorId: v.id("setores"),
+ createdAt: v.number(),
+ })
+ .index("by_funcionarioId", ["funcionarioId"])
+ .index("by_setorId", ["setorId"])
+ .index("by_funcionarioId_and_setorId", ["funcionarioId", "setorId"]),
+
// Templates de fluxo
flowTemplates: defineTable({
name: v.string(),
@@ -191,8 +201,7 @@ export default defineSchema({
// Instâncias de fluxo
flowInstances: defineTable({
flowTemplateId: v.id("flowTemplates"),
- targetType: v.string(), // ex: 'contrato', 'projeto'
- targetId: v.string(), // ID genérico do alvo
+ contratoId: v.optional(v.id("contratos")),
managerId: v.id("usuarios"),
status: flowInstanceStatus,
startedAt: v.number(),
@@ -200,7 +209,7 @@ export default defineSchema({
currentStepId: v.optional(v.id("flowInstanceSteps")),
})
.index("by_flowTemplateId", ["flowTemplateId"])
- .index("by_targetType_and_targetId", ["targetType", "targetId"])
+ .index("by_contratoId", ["contratoId"])
.index("by_managerId", ["managerId"])
.index("by_status", ["status"]),
@@ -214,6 +223,8 @@ export default defineSchema({
startedAt: v.optional(v.number()),
finishedAt: v.optional(v.number()),
notes: v.optional(v.string()),
+ notesUpdatedBy: v.optional(v.id("usuarios")),
+ notesUpdatedAt: v.optional(v.number()),
dueDate: v.optional(v.number()),
})
.index("by_flowInstanceId", ["flowInstanceId"])
@@ -232,6 +243,39 @@ export default defineSchema({
.index("by_flowInstanceStepId", ["flowInstanceStepId"])
.index("by_uploadedById", ["uploadedById"]),
+ // Sub-etapas de fluxo (para templates e instâncias)
+ flowSubSteps: defineTable({
+ flowStepId: v.optional(v.id("flowSteps")), // Para templates
+ flowInstanceStepId: v.optional(v.id("flowInstanceSteps")), // Para instâncias
+ name: v.string(),
+ description: v.optional(v.string()),
+ status: v.union(
+ v.literal("pending"),
+ v.literal("in_progress"),
+ v.literal("completed"),
+ v.literal("blocked")
+ ),
+ position: v.number(),
+ createdBy: v.id("usuarios"),
+ createdAt: v.number(),
+ })
+ .index("by_flowStepId", ["flowStepId"])
+ .index("by_flowInstanceStepId", ["flowInstanceStepId"]),
+
+ // Notas de steps e sub-etapas
+ flowStepNotes: defineTable({
+ flowStepId: v.optional(v.id("flowSteps")),
+ flowInstanceStepId: v.optional(v.id("flowInstanceSteps")),
+ flowSubStepId: v.optional(v.id("flowSubSteps")),
+ texto: v.string(),
+ criadoPor: v.id("usuarios"),
+ criadoEm: v.number(),
+ arquivos: v.array(v.id("_storage")),
+ })
+ .index("by_flowStepId", ["flowStepId"])
+ .index("by_flowInstanceStepId", ["flowInstanceStepId"])
+ .index("by_flowSubStepId", ["flowSubStepId"]),
+
contratos: defineTable({
contratadaId: v.id("empresas"),
objeto: v.string(),
@@ -314,7 +358,6 @@ 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"))
),
@@ -454,8 +497,7 @@ export default defineSchema({
.index("by_simboloTipo", ["simboloTipo"])
.index("by_cpf", ["cpf"])
.index("by_rg", ["rg"])
- .index("by_gestor", ["gestorId"])
- .index("by_setor", ["setorId"]),
+ .index("by_gestor", ["gestorId"]),
atestados: defineTable({
funcionarioId: v.id("funcionarios"),
@@ -1003,7 +1045,8 @@ export default defineSchema({
v.literal("mencao"),
v.literal("grupo_criado"),
v.literal("adicionado_grupo"),
- v.literal("alerta_seguranca")
+ v.literal("alerta_seguranca"),
+ v.literal("etapa_fluxo_concluida")
),
conversaId: v.optional(v.id("conversas")),
mensagemId: v.optional(v.id("mensagens")),
diff --git a/packages/backend/convex/setores.ts b/packages/backend/convex/setores.ts
index f0d31b0..2b3b8b9 100644
--- a/packages/backend/convex/setores.ts
+++ b/packages/backend/convex/setores.ts
@@ -136,6 +136,146 @@ export const update = mutation({
}
});
+/**
+ * Obter funcionários de um setor específico
+ */
+export const getFuncionariosBySetor = query({
+ args: { setorId: v.id('setores') },
+ returns: v.array(
+ v.object({
+ _id: v.id('funcionarios'),
+ _creationTime: v.number(),
+ nome: v.string(),
+ matricula: v.optional(v.string()),
+ email: v.string(),
+ cpf: v.string()
+ })
+ ),
+ handler: async (ctx, args) => {
+ // Buscar todas as relações funcionarioSetores para este setor
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', args.setorId))
+ .collect();
+
+ // Buscar os funcionários correspondentes
+ const funcionarios = [];
+ for (const relacao of funcionarioSetores) {
+ const funcionario = await ctx.db.get(relacao.funcionarioId);
+ if (funcionario) {
+ funcionarios.push({
+ _id: funcionario._id,
+ _creationTime: funcionario._creationTime,
+ nome: funcionario.nome,
+ matricula: funcionario.matricula,
+ email: funcionario.email,
+ cpf: funcionario.cpf
+ });
+ }
+ }
+
+ return funcionarios;
+ }
+});
+
+/**
+ * Obter setores de um funcionário
+ */
+export const getSetoresByFuncionario = query({
+ args: { funcionarioId: v.id('funcionarios') },
+ returns: v.array(
+ v.object({
+ _id: v.id('setores'),
+ _creationTime: v.number(),
+ nome: v.string(),
+ sigla: v.string(),
+ criadoPor: v.id('usuarios'),
+ createdAt: v.number()
+ })
+ ),
+ handler: async (ctx, args) => {
+ // Buscar todas as relações funcionarioSetores para este funcionário
+ const funcionarioSetores = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
+ .collect();
+
+ // Buscar os setores correspondentes
+ const setores = [];
+ for (const relacao of funcionarioSetores) {
+ const setor = await ctx.db.get(relacao.setorId);
+ if (setor) {
+ setores.push(setor);
+ }
+ }
+
+ return setores;
+ }
+});
+
+/**
+ * Atualizar setores de um funcionário
+ */
+export const atualizarSetoresFuncionario = mutation({
+ args: {
+ funcionarioId: v.id('funcionarios'),
+ setorIds: v.array(v.id('setores'))
+ },
+ returns: v.null(),
+ handler: async (ctx, args) => {
+ const usuario = await getCurrentUserFunction(ctx);
+ if (!usuario) {
+ throw new Error('Usuário não autenticado');
+ }
+
+ // Verificar se o funcionário existe
+ const funcionario = await ctx.db.get(args.funcionarioId);
+ if (!funcionario) {
+ throw new Error('Funcionário não encontrado');
+ }
+
+ // Verificar se todos os setores existem
+ for (const setorId of args.setorIds) {
+ const setor = await ctx.db.get(setorId);
+ if (!setor) {
+ throw new Error(`Setor ${setorId} não encontrado`);
+ }
+ }
+
+ // Remover todas as relações existentes do funcionário
+ const funcionarioSetoresExistentes = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId))
+ .collect();
+
+ for (const relacao of funcionarioSetoresExistentes) {
+ await ctx.db.delete(relacao._id);
+ }
+
+ // Criar novas relações para os setores selecionados
+ const now = Date.now();
+ for (const setorId of args.setorIds) {
+ // Verificar se já existe relação (evitar duplicatas)
+ const existe = await ctx.db
+ .query('funcionarioSetores')
+ .withIndex('by_funcionarioId_and_setorId', (q) =>
+ q.eq('funcionarioId', args.funcionarioId).eq('setorId', setorId)
+ )
+ .first();
+
+ if (!existe) {
+ await ctx.db.insert('funcionarioSetores', {
+ funcionarioId: args.funcionarioId,
+ setorId,
+ createdAt: now
+ });
+ }
+ }
+
+ return null;
+ }
+});
+
/**
* Excluir um setor
*/
@@ -155,8 +295,8 @@ export const remove = mutation({
// Verificar se há funcionários vinculados
const funcionariosVinculados = await ctx.db
- .query('funcionarios')
- .withIndex('by_setor', (q) => q.eq('setorId', args.id))
+ .query('funcionarioSetores')
+ .withIndex('by_setorId', (q) => q.eq('setorId', args.id))
.first();
if (funcionariosVinculados) {
throw new Error('Não é possível excluir um setor com funcionários vinculados');
From daee99191cf21a6ec3c3f3dc9ad61d1e92c738b6 Mon Sep 17 00:00:00 2001
From: killer-cf
Date: Wed, 26 Nov 2025 10:21:13 -0300
Subject: [PATCH 4/4] feat: extend getInstanceWithSteps query to include notes
metadata
- Added new fields for tracking who updated notes, their names, and the timestamp of the update.
- Refactored the retrieval of the updater's name to improve code clarity and efficiency.
- Enhanced the data structure returned by the query to support additional notes-related information.
---
.github/workflows/deploy.yml | 30 +++++++++++++++++
Dockerfile | 58 ++++++++++++++++++++++++++++++++
packages/backend/convex/flows.ts | 6 +++-
3 files changed, 93 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/deploy.yml
create mode 100644 Dockerfile
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..0a40e7d
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,30 @@
+name: Build Docker images
+
+on:
+ push:
+ branches: ["main"]
+
+jobs:
+ build-and-push-dockerfile-image:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v2
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
+ password: ${{ secrets.DOCKERHUB_TOKEN }} # Make sure to add the secrets in your repository in -> Settings -> Secrets (Actions) -> New repository secret
+
+ - name: Build and push Docker image
+ uses: docker/build-push-action@v4
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ # Make sure to replace with your own namespace and repository
+ tags: |
+ namespace/example:latest
+ platforms: linux/amd64
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..0e0d48d
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,58 @@
+# Use the official Bun image
+FROM oven/bun:1 AS base
+
+# Set the working directory inside the container
+WORKDIR /usr/src/app
+
+# Create a non-root user for security
+RUN addgroup --system --gid 1001 sveltekit
+RUN adduser --system --uid 1001 sveltekit
+
+# Copy package.json and bun.lockb (if available)
+COPY package.json bun.lockb* ./
+
+# Install dependencies (including dev dependencies for build)
+RUN bun install --frozen-lockfile
+
+# Copy the source code
+COPY . .
+
+# Prepare SvelteKit and build the application
+RUN bun run prepare
+RUN bun run build
+RUN bun run db:migrate
+
+# Production stage
+FROM oven/bun:1-slim AS production
+
+# Set working directory
+WORKDIR /usr/src/app
+
+# Create non-root user
+RUN addgroup --system --gid 1001 sveltekit
+RUN adduser --system --uid 1001 sveltekit
+
+# Copy built application from base stage
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/build ./build
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/package.json ./package.json
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/node_modules ./node_modules
+
+# Copy any additional files needed for runtime
+COPY --from=base --chown=sveltekit:sveltekit /usr/src/app/static ./static
+
+# Switch to non-root user
+USER sveltekit
+
+# Expose the port that the app runs on
+EXPOSE 5173
+
+# Set environment variables
+ENV NODE_ENV=production
+ENV PORT=5173
+
+# Health check
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
+ CMD bun --version || exit 1
+
+# Start the application
+CMD ["bun", "./build/index.js"]
diff --git a/packages/backend/convex/flows.ts b/packages/backend/convex/flows.ts
index 6a8e8cd..0749140 100644
--- a/packages/backend/convex/flows.ts
+++ b/packages/backend/convex/flows.ts
@@ -891,6 +891,9 @@ export const getInstanceWithSteps = query({
startedAt: number | undefined;
finishedAt: number | undefined;
notes: string | undefined;
+ notesUpdatedBy: Id<'usuarios'> | undefined;
+ notesUpdatedByName: string | undefined;
+ notesUpdatedAt: number | undefined;
dueDate: number | undefined;
position: number;
expectedDuration: number;
@@ -929,6 +932,7 @@ export const getInstanceWithSteps = query({
});
}
+ const notesUpdater = step.notesUpdatedBy ? await ctx.db.get(step.notesUpdatedBy) : null;
stepsWithDetails.push({
_id: step._id,
_creationTime: step._creationTime,
@@ -945,7 +949,7 @@ export const getInstanceWithSteps = query({
finishedAt: step.finishedAt,
notes: step.notes,
notesUpdatedBy: step.notesUpdatedBy,
- notesUpdatedByName: step.notesUpdatedBy ? (await ctx.db.get(step.notesUpdatedBy))?.nome : undefined,
+ notesUpdatedByName: notesUpdater?.nome,
notesUpdatedAt: step.notesUpdatedAt,
dueDate: step.dueDate,
position: flowStep?.position ?? 0,