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.
This commit is contained in:
2025-11-25 00:21:35 -03:00
parent 409872352c
commit f8d9c17f63
16 changed files with 4073 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: