feat: Implement order flow management with backend logic, configuration UI, and order timeline display.

This commit is contained in:
2025-12-30 12:30:26 -03:00
parent 5c0e9f0d2e
commit e97bcfbd6a
7 changed files with 2010 additions and 1 deletions

View File

@@ -237,6 +237,19 @@
}
]
},
{
label: 'Configurações',
icon: 'Settings',
link: '/configuracoes',
permission: { recurso: 'pedidos', acao: 'listar' },
submenus: [
{
label: 'Fluxo de Pedidos',
link: '/configuracoes/fluxo-pedidos',
permission: { recurso: 'pedidos', acao: 'listar' }
}
]
},
{
label: 'Painel de TI',
icon: 'Settings',

View File

@@ -0,0 +1,270 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useQuery } from 'convex-svelte';
import { Check, Circle, Clock } from 'lucide-svelte';
interface Props {
pedidoId: Id<'pedidos'>;
}
let { pedidoId }: Props = $props();
const timelineQuery = $derived.by(() => useQuery(api.pedidoFlow.getPedidoTimeline, { pedidoId }));
const timeline = $derived(timelineQuery.data);
const loading = $derived(timelineQuery.isLoading);
// Filtrar etapas que devem aparecer no timeline
const passadoVisivel = $derived(timeline?.passado.filter((item) => item.incluirNoTimeline) ?? []);
const futuroVisivel = $derived(timeline?.futuro.filter((item) => item.incluirNoTimeline) ?? []);
function formatDateShort(timestamp: number): string {
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: 'short'
}).format(new Date(timestamp));
}
</script>
{#if loading}
<div class="timeline-loading">
<div class="loading-spinner"></div>
<span>Carregando timeline...</span>
</div>
{:else if timeline}
<div class="timeline-container">
<div class="timeline">
<!-- Etapas passadas -->
{#each passadoVisivel as item, index (item.etapaId + '-' + item.inicioData)}
{@const isAtual = item.atual}
{@const isConcluida = !item.atual}
<div class="timeline-item" class:atual={isAtual} class:concluida={isConcluida}>
<div class="timeline-marker">
{#if isConcluida}
<div class="marker-icon concluida">
<Check size={14} strokeWidth={3} />
</div>
{:else}
<div class="marker-icon atual">
<Circle size={14} fill="currentColor" />
</div>
{/if}
</div>
<div class="timeline-content">
<div class="etapa-nome">{item.etapaNome}</div>
<div class="etapa-data">{formatDateShort(item.inicioData)}</div>
{#if item.funcionarioNome}
<div class="etapa-funcionario">{item.funcionarioNome}</div>
{/if}
</div>
</div>
{#if index < passadoVisivel.length - 1 || futuroVisivel.length > 0}
<div class="timeline-connector" class:concluida={isConcluida}></div>
{/if}
{/each}
<!-- Etapas futuras (previsão) -->
{#each futuroVisivel as item, index (item.etapaId + '-futuro-' + index)}
<div class="timeline-item futuro">
<div class="timeline-marker">
<div class="marker-icon futuro">
<Clock size={14} />
</div>
</div>
<div class="timeline-content">
<div class="etapa-nome">{item.etapaNome}</div>
<div class="etapa-data previsao">
<span class="previsao-label">Prev.</span>
{formatDateShort(item.dataPrevisao)}
</div>
</div>
</div>
{#if index < futuroVisivel.length - 1}
<div class="timeline-connector futuro"></div>
{/if}
{/each}
</div>
</div>
{:else}
<div class="timeline-empty">
<p>Nenhuma etapa registrada ainda.</p>
</div>
{/if}
<style>
.timeline-container {
width: 100%;
overflow-x: auto;
padding: 1rem 0;
}
.timeline {
display: flex;
align-items: flex-start;
gap: 0;
min-width: max-content;
padding: 0 1rem;
}
.timeline-item {
display: flex;
flex-direction: column;
align-items: center;
min-width: 120px;
max-width: 150px;
}
.timeline-marker {
position: relative;
z-index: 1;
}
.marker-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.marker-icon.concluida {
background: linear-gradient(135deg, #10b981, #059669);
color: white;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.4);
}
.marker-icon.atual {
background: linear-gradient(135deg, #3b82f6, #2563eb);
color: white;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
animation: pulse 2s infinite;
}
.marker-icon.futuro {
background: rgba(148, 163, 184, 0.2);
color: #94a3b8;
border: 2px dashed #cbd5e1;
}
@keyframes pulse {
0%,
100% {
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
50% {
box-shadow: 0 2px 16px rgba(59, 130, 246, 0.6);
}
}
.timeline-content {
margin-top: 0.75rem;
text-align: center;
}
.etapa-nome {
font-weight: 600;
font-size: 0.875rem;
color: var(--color-text-primary, #1e293b);
line-height: 1.3;
word-wrap: break-word;
}
.timeline-item.futuro .etapa-nome {
color: #94a3b8;
}
.etapa-data {
font-size: 0.75rem;
color: #64748b;
margin-top: 0.25rem;
}
.etapa-data.previsao {
font-style: italic;
}
.previsao-label {
font-size: 0.625rem;
text-transform: uppercase;
color: #94a3b8;
display: block;
}
.etapa-funcionario {
font-size: 0.6875rem;
color: #3b82f6;
margin-top: 0.25rem;
font-weight: 500;
}
.timeline-connector {
flex-shrink: 0;
width: 40px;
height: 2px;
background: #e2e8f0;
margin-top: 15px;
position: relative;
}
.timeline-connector.concluida {
background: linear-gradient(90deg, #10b981, #10b981);
}
.timeline-connector.futuro {
background: repeating-linear-gradient(
90deg,
#cbd5e1 0px,
#cbd5e1 4px,
transparent 4px,
transparent 8px
);
}
.timeline-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 2rem;
color: #64748b;
}
.loading-spinner {
width: 20px;
height: 20px;
border: 2px solid #e2e8f0;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.timeline-empty {
text-align: center;
padding: 2rem;
color: #94a3b8;
}
/* Responsividade */
@media (max-width: 640px) {
.timeline-item {
min-width: 100px;
max-width: 120px;
}
.timeline-connector {
width: 24px;
}
.etapa-nome {
font-size: 0.8125rem;
}
}
</style>

View File

@@ -0,0 +1,823 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte';
import { toast } from 'svelte-sonner';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
import {
Plus,
Edit,
Trash2,
ArrowRight,
GripVertical,
Clock,
Building2,
Star,
X,
Workflow,
Layers
} from 'lucide-svelte';
const client = useConvexClient();
// Queries
const etapasQuery = $derived.by(() => useQuery(api.pedidoFlow.listEtapas, {}));
const transicoesQuery = $derived.by(() => useQuery(api.pedidoFlow.listTransicoes, {}));
const setoresQuery = $derived.by(() => useQuery(api.setores.list, {}));
const etapas = $derived(etapasQuery.data ?? []);
const transicoes = $derived(transicoesQuery.data ?? []);
const setores = $derived(setoresQuery.data ?? []);
const loading = $derived(etapasQuery.isLoading || transicoesQuery.isLoading);
// Estado do formulário de etapa
let showEtapaModal = $state(false);
let editingEtapaId = $state<Id<'pedidoFluxoEtapa'> | null>(null);
let etapaForm = $state({
nome: '',
codigo: '',
descricao: '',
setorId: '' as string,
tempoEstimadoDias: '' as string,
incluirNoTimeline: true
});
let savingEtapa = $state(false);
// Estado do formulário de transição
let showTransicaoModal = $state(false);
let transicaoForm = $state({
etapaOrigemId: '' as string,
etapaDestinoId: '' as string
});
let savingTransicao = $state(false);
// Confirmation Modal
let confirmModal = $state({
open: false,
title: '',
message: '',
confirmText: 'Confirmar',
cancelText: 'Cancelar',
isDestructive: false,
onConfirm: async () => {}
});
function openConfirm(
title: string,
message: string,
onConfirm: () => Promise<void> | void,
options: {
confirmText?: string;
cancelText?: string;
isDestructive?: boolean;
} = {}
) {
confirmModal.title = title;
confirmModal.message = message;
confirmModal.confirmText = options.confirmText || 'Confirmar';
confirmModal.cancelText = options.cancelText || 'Cancelar';
confirmModal.isDestructive = options.isDestructive || false;
confirmModal.onConfirm = async () => {
try {
await onConfirm();
} catch (e) {
toast.error('Erro: ' + (e as Error).message);
}
};
confirmModal.open = true;
}
// Funções de Etapa
function openNewEtapa() {
editingEtapaId = null;
etapaForm = {
nome: '',
codigo: '',
descricao: '',
setorId: '',
tempoEstimadoDias: '',
incluirNoTimeline: true
};
showEtapaModal = true;
}
function openEditEtapa(etapa: (typeof etapas)[number]) {
editingEtapaId = etapa._id;
etapaForm = {
nome: etapa.nome,
codigo: etapa.codigo,
descricao: etapa.descricao ?? '',
setorId: etapa.setorId ?? '',
tempoEstimadoDias: etapa.tempoEstimadoDias?.toString() ?? '',
incluirNoTimeline: etapa.incluirNoTimeline
};
showEtapaModal = true;
}
function closeEtapaModal() {
showEtapaModal = false;
editingEtapaId = null;
}
async function handleSaveEtapa() {
if (!etapaForm.nome.trim() || !etapaForm.codigo.trim()) {
toast.error('Nome e código são obrigatórios');
return;
}
savingEtapa = true;
try {
const tempoEstimado = etapaForm.tempoEstimadoDias
? parseInt(etapaForm.tempoEstimadoDias, 10)
: undefined;
if (editingEtapaId) {
await client.mutation(api.pedidoFlow.updateEtapa, {
id: editingEtapaId,
nome: etapaForm.nome.trim(),
codigo: etapaForm.codigo.trim(),
descricao: etapaForm.descricao.trim() || undefined,
setorId: etapaForm.setorId ? (etapaForm.setorId as Id<'setores'>) : undefined,
tempoEstimadoDias: tempoEstimado,
incluirNoTimeline: etapaForm.incluirNoTimeline
});
toast.success('Etapa atualizada com sucesso!');
} else {
await client.mutation(api.pedidoFlow.createEtapa, {
nome: etapaForm.nome.trim(),
codigo: etapaForm.codigo.trim(),
descricao: etapaForm.descricao.trim() || undefined,
setorId: etapaForm.setorId ? (etapaForm.setorId as Id<'setores'>) : undefined,
tempoEstimadoDias: tempoEstimado,
incluirNoTimeline: etapaForm.incluirNoTimeline
});
toast.success('Etapa criada com sucesso!');
}
closeEtapaModal();
} catch (e) {
toast.error('Erro ao salvar etapa: ' + (e as Error).message);
} finally {
savingEtapa = false;
}
}
function handleDeleteEtapa(etapa: (typeof etapas)[number]) {
openConfirm(
'Excluir Etapa',
`Tem certeza que deseja excluir a etapa "${etapa.nome}"?`,
async () => {
await client.mutation(api.pedidoFlow.deleteEtapa, { id: etapa._id });
toast.success('Etapa excluída com sucesso!');
},
{ isDestructive: true, confirmText: 'Excluir' }
);
}
// Funções de Transição
function openNewTransicao() {
transicaoForm = {
etapaOrigemId: '',
etapaDestinoId: ''
};
showTransicaoModal = true;
}
function closeTransicaoModal() {
showTransicaoModal = false;
}
async function handleSaveTransicao() {
if (!transicaoForm.etapaOrigemId || !transicaoForm.etapaDestinoId) {
toast.error('Selecione as etapas de origem e destino');
return;
}
savingTransicao = true;
try {
await client.mutation(api.pedidoFlow.createTransicao, {
etapaOrigemId: transicaoForm.etapaOrigemId as Id<'pedidoFluxoEtapa'>,
etapaDestinoId: transicaoForm.etapaDestinoId as Id<'pedidoFluxoEtapa'>
});
toast.success('Transição criada com sucesso!');
closeTransicaoModal();
} catch (e) {
toast.error('Erro ao criar transição: ' + (e as Error).message);
} finally {
savingTransicao = false;
}
}
function handleDeleteTransicao(transicao: (typeof transicoes)[number]) {
openConfirm(
'Excluir Transição',
`Tem certeza que deseja excluir a transição "${transicao.etapaOrigemNome}" → "${transicao.etapaDestinoNome}"?`,
async () => {
await client.mutation(api.pedidoFlow.deleteTransicao, { id: transicao._id });
toast.success('Transição excluída com sucesso!');
},
{ isDestructive: true, confirmText: 'Excluir' }
);
}
async function handleSetPadrao(transicaoId: Id<'pedidoFluxoTransicao'>) {
try {
await client.mutation(api.pedidoFlow.setTransicaoPadrao, { id: transicaoId });
toast.success('Transição definida como padrão!');
} catch (e) {
toast.error('Erro: ' + (e as Error).message);
}
}
// Gerar código a partir do nome
function generateCodigo(nome: string): string {
return nome
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9\s]/g, '')
.replace(/\s+/g, '_')
.substring(0, 30);
}
function handleNomeChange() {
if (!editingEtapaId) {
etapaForm.codigo = generateCodigo(etapaForm.nome);
}
}
</script>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Configurações', href: '/configuracoes' },
{ label: 'Fluxo de Pedidos', href: '/configuracoes/fluxo-pedidos' }
]}
/>
<div class="mb-8 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h1 class="text-base-content text-3xl font-extrabold tracking-tight">Fluxo de Pedidos</h1>
<p class="text-base-content/60 text-lg">
Gerencie as etapas e as transições automáticas do sistema
</p>
</div>
<div class="flex gap-2">
<button class="btn btn-primary shadow-md" onclick={openNewEtapa}>
<Plus size={20} />
Nova Etapa
</button>
</div>
</div>
{#if loading}
<div class="flex flex-col items-center justify-center gap-4 py-24">
<span class="loading loading-infinity loading-lg text-primary"></span>
<span class="text-base-content/60 animate-pulse font-medium">Sincronizando fluxo...</span>
</div>
{:else}
<!-- Stats Section -->
<div class="stats bg-base-100 border-base-200 mb-8 w-full overflow-hidden border shadow-lg">
<div class="stat">
<div class="stat-figure text-primary">
<Layers size={32} />
</div>
<div class="stat-title text-xs font-semibold tracking-wider uppercase opacity-60">
Etapas Ativas
</div>
<div class="stat-value text-primary">{etapas.length}</div>
<div class="stat-desc font-medium">Status do workflow</div>
</div>
<div class="stat">
<div class="stat-figure text-secondary">
<Workflow size={32} />
</div>
<div class="stat-title text-xs font-semibold tracking-wider uppercase opacity-60">
Transições
</div>
<div class="stat-value text-secondary">{transicoes.length}</div>
<div class="stat-desc font-medium">Caminhos possíveis</div>
</div>
<div class="stat">
<div class="stat-figure text-accent">
<Star size={32} />
</div>
<div class="stat-title text-xs font-semibold tracking-wider uppercase opacity-60">
Transições Padrão
</div>
<div class="stat-value text-accent">{transicoes.filter((t) => t.isPadrao).length}</div>
<div class="stat-desc font-medium">Avanços automáticos</div>
</div>
</div>
<div class="grid gap-8 lg:grid-cols-2">
<!-- Seção de Etapas -->
<div class="flex flex-col gap-4">
<div class="border-base-200 flex items-center gap-2 border-b pb-2">
<Layers size={20} class="text-primary" />
<h2 class="text-xl font-bold">Etapas Disponíveis</h2>
</div>
{#if etapas.length === 0}
<div class="card bg-base-100 border-base-300 border border-dashed">
<div class="card-body items-center py-12 text-center">
<div class="bg-base-200 mb-4 rounded-full p-4">
<Clock size={40} class="text-base-content/40" />
</div>
<h3 class="text-lg font-bold">Sem etapas</h3>
<p class="text-base-content/60 mb-4 text-sm">
Nenhuma etapa configurada para o fluxo de pedidos.
</p>
<button class="btn btn-primary btn-sm btn-outline px-8" onclick={openNewEtapa}>
Começar Agora
</button>
</div>
</div>
{:else}
<div class="grid gap-3">
{#each etapas as etapa (etapa._id)}
<div
class={[
'group card bg-base-100 border shadow-sm transition-all duration-300 hover:shadow-md',
etapa.incluirNoTimeline
? 'border-base-200 hover:border-primary/30'
: 'border-base-200 opacity-80'
]}
>
<div class="card-body flex-row items-center gap-4 p-4">
<div
class="text-base-content/20 group-hover:text-primary/40 flex-none cursor-grab transition-colors"
>
<GripVertical size={20} />
</div>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<h3 class="text-base leading-tight font-bold">{etapa.nome}</h3>
{#if !etapa.incluirNoTimeline}
<div class="badge badge-sm badge-ghost border-base-300 bg-base-200/50">
Oculto
</div>
{/if}
</div>
<div class="mt-1 flex flex-wrap items-center gap-x-4 gap-y-1">
<span
class="bg-base-200 text-base-content/70 rounded px-2 py-0.5 font-mono text-[10px]"
>
{etapa.codigo}
</span>
{#if etapa.tempoEstimadoDias}
<div class="text-secondary flex items-center gap-1 text-xs font-medium">
<Clock size={12} />
{etapa.tempoEstimadoDias}d
</div>
{/if}
{#if etapa.setorNome}
<div class="text-accent flex items-center gap-1 text-xs font-medium">
<Building2 size={12} />
{etapa.setorNome}
</div>
{/if}
</div>
</div>
<div class="flex flex-none items-center gap-1">
<button
class="btn btn-circle btn-ghost btn-sm hover:bg-primary/10 hover:text-primary transition-colors"
onclick={() => openEditEtapa(etapa)}
title="Editar etapa"
>
<Edit size={16} />
</button>
<button
class="btn btn-circle btn-ghost btn-sm text-base-content/30 hover:bg-error/10 hover:text-error transition-colors"
onclick={() => handleDeleteEtapa(etapa)}
title="Excluir etapa"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Seção de Transições -->
<div class="flex flex-col gap-4">
<div class="border-base-200 flex items-center justify-between border-b pb-2">
<div class="flex items-center gap-2">
<Workflow size={20} class="text-secondary" />
<h2 class="text-xl font-bold">Lógica de Transição</h2>
</div>
<button
class="btn btn-secondary btn-xs btn-outline rounded-full"
onclick={openNewTransicao}
disabled={etapas.length < 2}
>
Nova Lógica
</button>
</div>
{#if transicoes.length === 0}
<div class="card bg-base-100 border-base-300 border border-dashed">
<div class="card-body items-center py-12 text-center">
<div class="bg-base-200 text-base-content/40 mb-4 rounded-full p-4">
<ArrowRight size={40} />
</div>
<h3 class="text-lg font-bold">Nenhuma conexão</h3>
<p class="text-base-content/60 mb-4 px-8 text-sm">
Define os caminhos que um pedido pode tomar entre as etapas.
</p>
{#if etapas.length >= 2}
<button
class="btn btn-secondary btn-sm btn-outline px-8"
onclick={openNewTransicao}
>
Configurar Conexão
</button>
{:else}
<div class="badge badge-warning py-3 text-xs">Adicione etapas primeiro</div>
{/if}
</div>
</div>
{:else}
<div class="grid gap-3">
{#each transicoes as transicao (transicao._id)}
<div
class={[
'group card bg-base-100 border shadow-sm transition-all duration-300 hover:shadow-md',
transicao.isPadrao
? 'border-accent/40 bg-accent/5'
: 'border-base-200 hover:border-secondary/30'
]}
>
<div class="card-body flex-row items-center justify-between gap-4 p-4">
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex flex-col">
<span
class="text-base-content/40 mb-0.5 text-[10px] font-black tracking-widest uppercase"
>Origem</span
>
<span class="truncate text-sm font-bold md:text-base"
>{transicao.etapaOrigemNome}</span
>
</div>
<div
class="text-base-content/30 group-hover:text-secondary/50 flex flex-col items-center px-1 transition-colors"
>
<ArrowRight size={20} />
</div>
<div class="flex flex-col">
<span
class="text-base-content/40 mb-0.5 text-end text-[10px] font-black tracking-widest uppercase"
>Destino</span
>
<span class="truncate text-end text-sm font-bold md:text-base"
>{transicao.etapaDestinoNome}</span
>
</div>
</div>
<div class="flex items-center gap-2">
{#if transicao.isPadrao}
<div
class="badge badge-accent badge-sm animate-appearance gap-1 px-3 py-3 font-black shadow-sm"
>
<Star size={12} fill="currentColor" />
PADRÃO
</div>
{:else}
<button
class="btn btn-ghost btn-circle btn-sm text-base-content/30 hover:text-accent hover:bg-accent/10 opacity-0 transition-all group-hover:opacity-100"
onclick={() => handleSetPadrao(transicao._id)}
title="Marcar como avanço padrão"
>
<Star size={16} />
</button>
{/if}
<div class="divider divider-horizontal mx-0 h-6 opacity-30"></div>
<button
class="btn btn-ghost btn-circle btn-sm text-base-content/30 hover:text-error hover:bg-error/10 transition-all"
onclick={() => handleDeleteTransicao(transicao)}
title="Excluir transição"
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{/if}
</PageShell>
<!-- Modals -->
<!-- Modal de Etapa -->
{#if showEtapaModal}
<div class="modal modal-open" role="dialog" aria-modal="true">
<button class="modal-backdrop" onclick={closeEtapaModal} aria-label="Fechar modal"></button>
<div class="modal-box border-base-300 max-w-lg overflow-hidden border p-0 shadow-2xl">
<!-- Header -->
<div class="bg-base-200 border-base-300 flex items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-2">
<div class="bg-primary/10 text-primary rounded-lg p-2">
<Layers size={20} />
</div>
<h3 class="text-lg font-black tracking-tight uppercase">
{editingEtapaId ? 'Editar Etapa' : 'Nova Etapa'}
</h3>
</div>
<button class="btn btn-circle btn-ghost btn-sm" onclick={closeEtapaModal}>
<X size={20} />
</button>
</div>
<div class="space-y-5 p-6">
<div class="form-control w-full">
<label class="label pt-0" for="etapa-nome">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Identificação da Etapa</span
>
</label>
<div class="join w-full">
<input
id="etapa-nome"
type="text"
placeholder="Ex: Aguardando Aceite"
class="input input-bordered join-item focus:input-primary w-full font-medium transition-all"
bind:value={etapaForm.nome}
oninput={handleNomeChange}
/>
<div
class="join-item bg-base-200 text-base-content/40 flex items-center border-y border-r px-4"
>
<Layers size={18} />
</div>
</div>
</div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label pt-0" for="etapa-codigo">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Código Referência</span
>
</label>
<input
id="etapa-codigo"
type="text"
placeholder="Ex: aguardando_aceite"
class="input input-bordered focus:input-primary w-full font-mono text-xs"
bind:value={etapaForm.codigo}
/>
</div>
<div class="form-control w-full">
<label class="label pt-0" for="etapa-tempo">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Tempo Estimado (dias)</span
>
</label>
<div
class="input input-bordered focus-within:outline-primary group flex items-center gap-2 overflow-hidden pr-0 pl-4"
>
<Clock
size={16}
class="text-base-content/30 group-focus-within:text-primary transition-colors"
/>
<input
id="etapa-tempo"
type="number"
min="0"
class="ml-1 h-full w-full font-medium focus:outline-none"
placeholder="0"
bind:value={etapaForm.tempoEstimadoDias}
/>
</div>
</div>
</div>
<div class="form-control w-full">
<label class="label pt-0" for="etapa-setor">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Setor Responsável</span
>
</label>
<select
id="etapa-setor"
class="select select-bordered focus:select-primary w-full font-medium"
bind:value={etapaForm.setorId}
>
<option value="">Nenhum</option>
{#each setores as setor (setor._id)}
<option value={setor._id}>{setor.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label pt-0" for="etapa-descricao">
<span
class="label-text text-base-content/60 text-[10px] font-bold tracking-widest uppercase"
>Observações Internas</span
>
</label>
<textarea
id="etapa-descricao"
placeholder="Detalhes sobre esta etapa..."
class="textarea textarea-bordered focus:textarea-primary min-h-[80px] leading-tight font-medium"
bind:value={etapaForm.descricao}
></textarea>
</div>
<div class="bg-base-200/50 border-base-200 rounded-xl border p-4">
<label class="label cursor-pointer justify-start gap-4">
<input
type="checkbox"
class="toggle toggle-primary toggle-sm"
bind:checked={etapaForm.incluirNoTimeline}
/>
<div class="flex flex-col">
<span class="label-text text-sm font-bold">Exibir no Timeline do Pedido</span>
<span class="text-base-content/50 text-[10px] font-black uppercase"
>Histórico Visível</span
>
</div>
</label>
</div>
</div>
<div class="bg-base-200 border-base-300 flex justify-between gap-3 border-t px-6 py-4">
<button class="btn btn-ghost hover:bg-base-300 shadow-sm" onclick={closeEtapaModal}>
Fechar
</button>
<button
class="btn btn-primary px-10 shadow-lg"
onclick={handleSaveEtapa}
disabled={savingEtapa}
>
{#if savingEtapa}
<span class="loading loading-spinner loading-xs"></span>
Gravando
{:else}
Confirmar
{/if}
</button>
</div>
</div>
</div>
{/if}
<!-- Modal de Transição -->
{#if showTransicaoModal}
<div class="modal modal-open" role="dialog" aria-modal="true">
<button class="modal-backdrop" onclick={closeTransicaoModal} aria-label="Fechar modal"></button>
<div class="modal-box border-base-300 max-w-md overflow-hidden border p-0 shadow-2xl">
<div class="bg-base-200 border-base-300 flex items-center justify-between border-b px-6 py-4">
<div class="flex items-center gap-2">
<div class="bg-secondary/10 text-secondary rounded-lg p-2">
<Workflow size={20} />
</div>
<h3 class="text-lg font-black tracking-tight uppercase">Nova Transição</h3>
</div>
<button class="btn btn-circle btn-ghost btn-sm" onclick={closeTransicaoModal}>
<X size={20} />
</button>
</div>
<div class="space-y-8 p-8 pb-4">
<div class="relative">
<div class="form-control">
<label class="label pt-0" for="transicao-origem">
<span
class="label-text text-base-content/50 text-[10px] font-black tracking-widest uppercase"
>Fluxo de Saída</span
>
</label>
<select
id="transicao-origem"
class="select select-bordered select-lg focus:select-secondary w-full font-bold shadow-sm"
bind:value={transicaoForm.etapaOrigemId}
>
<option value="">Selecione origem...</option>
{#each etapas as etapa (etapa._id)}
<option value={etapa._id}>{etapa.nome}</option>
{/each}
</select>
</div>
<div class="absolute -bottom-6 left-1/2 z-10 -translate-x-1/2">
<div
class="bg-secondary text-secondary-content border-base-100 ring-secondary/20 scale-110 rounded-full border-2 p-2 shadow-lg ring-4"
>
<ArrowRight size={20} class="rotate-90 md:rotate-0" />
</div>
</div>
</div>
<div class="form-control">
<label class="label pt-4" for="transicao-destino">
<span
class="label-text text-base-content/50 w-full text-end text-[10px] font-black tracking-widest uppercase"
>Etapa de Chegada</span
>
</label>
<select
id="transicao-destino"
class="select select-bordered select-lg focus:select-secondary w-full font-bold shadow-sm"
bind:value={transicaoForm.etapaDestinoId}
>
<option value="">Selecione destino...</option>
{#each etapas as etapa (etapa._id)}
<option value={etapa._id} disabled={etapa._id === transicaoForm.etapaOrigemId}>
{etapa.nome}
</option>
{/each}
</select>
</div>
<div
class="alert alert-info bg-info/5 border-info/20 mt-4 flex items-start gap-3 rounded-xl"
>
<div class="bg-info text-info-content mt-0.5 rounded-full p-1">
<Star size={10} fill="currentColor" />
</div>
<div class="text-[11px] leading-snug font-medium">
A primeira conexão criada para cada etapa será o <span class="font-bold underline"
>caminho preferencial</span
> (padrão) do sistema.
</div>
</div>
</div>
<div class="bg-base-200 mt-4 flex flex-col gap-2 px-6 py-6">
<button
class="btn btn-secondary btn-block shadow-lg"
onclick={handleSaveTransicao}
disabled={savingTransicao}
>
{#if savingTransicao}
<span class="loading loading-spinner loading-sm"></span>
Processando...
{:else}
Criar Transição
{/if}
</button>
<button class="btn btn-ghost btn-block btn-sm" onclick={closeTransicaoModal}>
Descartar
</button>
</div>
</div>
</div>
{/if}
<ConfirmationModal
bind:open={confirmModal.open}
title={confirmModal.title}
message={confirmModal.message}
confirmText={confirmModal.confirmText}
cancelText={confirmModal.cancelText}
isDestructive={confirmModal.isDestructive}
onConfirm={confirmModal.onConfirm}
onClose={() => (confirmModal.open = false)}
/>
<style>
/* Animating custom elements */
.animate-appearance {
animation: appear 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes appear {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* Force cursor style for modal backdrop */
:global(.modal-backdrop) {
cursor: default !important;
}
</style>

View File

@@ -8,6 +8,7 @@
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import PedidoTimeline from '$lib/components/pedidos/PedidoTimeline.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import {
AlertTriangle,
@@ -1355,6 +1356,14 @@
</div></GlassCard
>
<!-- Timeline do Pedido -->
<GlassCard class="mb-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Timeline do Pedido</h3>
</div>
<PedidoTimeline {pedidoId} />
</GlassCard>
<!-- Documentos do Pedido -->
<GlassCard class="mb-6" bodyClass="p-0">
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">