Compare commits
3 Commits
first-depl
...
feat-novo-
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a6d9069c9 | |||
| e97bcfbd6a | |||
| 5c0e9f0d2e |
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
trigger: glob
|
trigger: glob
|
||||||
globs: **/*.svelte.ts,**/*.svelte
|
globs: *.svelte.ts,*.svelte
|
||||||
---
|
---
|
||||||
|
|
||||||
# Convex + Svelte Best Practices
|
# Convex + Svelte Best Practices
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
trigger: glob
|
trigger: glob
|
||||||
description: Regras de tipagem para queries e mutations do Convex
|
description: Regras de tipagem para queries e mutations do Convex
|
||||||
globs: **/*.svelte.ts,**/*.svelte
|
globs: *.svelte.ts,*.svelte
|
||||||
---
|
---
|
||||||
|
|
||||||
# Regras de Tipagem do Convex
|
# Regras de Tipagem do Convex
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
---
|
---
|
||||||
trigger: always_on
|
trigger: glob
|
||||||
|
globs: *.svelte.ts,*.svelte
|
||||||
---
|
---
|
||||||
|
|
||||||
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
You are able to use the Svelte MCP server, where you have access to comprehensive Svelte 5 and SvelteKit documentation. Here's how to use the available tools effectively:
|
||||||
|
|||||||
@@ -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',
|
label: 'Painel de TI',
|
||||||
icon: 'Settings',
|
icon: 'Settings',
|
||||||
|
|||||||
270
apps/web/src/lib/components/pedidos/PedidoTimeline.svelte
Normal file
270
apps/web/src/lib/components/pedidos/PedidoTimeline.svelte
Normal 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>
|
||||||
@@ -0,0 +1,819 @@
|
|||||||
|
<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-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-error 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-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-sm btn-error 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-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-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 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-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>
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
|
import ConfirmationModal from '$lib/components/ConfirmationModal.svelte';
|
||||||
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
import PageShell from '$lib/components/layout/PageShell.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 GlassCard from '$lib/components/ui/GlassCard.svelte';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -1355,6 +1356,14 @@
|
|||||||
</div></GlassCard
|
</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 -->
|
<!-- Documentos do Pedido -->
|
||||||
<GlassCard class="mb-6" bodyClass="p-0">
|
<GlassCard class="mb-6" bodyClass="p-0">
|
||||||
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
<div class="border-base-300 flex items-center justify-between border-b px-6 py-4">
|
||||||
|
|||||||
4
packages/backend/convex/_generated/api.d.ts
vendored
4
packages/backend/convex/_generated/api.d.ts
vendored
@@ -37,6 +37,7 @@ import type * as contratos from "../contratos.js";
|
|||||||
import type * as crons from "../crons.js";
|
import type * as crons from "../crons.js";
|
||||||
import type * as cursos from "../cursos.js";
|
import type * as cursos from "../cursos.js";
|
||||||
import type * as dashboard from "../dashboard.js";
|
import type * as dashboard from "../dashboard.js";
|
||||||
|
import type * as debug from "../debug.js";
|
||||||
import type * as documentos from "../documentos.js";
|
import type * as documentos from "../documentos.js";
|
||||||
import type * as email from "../email.js";
|
import type * as email from "../email.js";
|
||||||
import type * as empresas from "../empresas.js";
|
import type * as empresas from "../empresas.js";
|
||||||
@@ -55,6 +56,7 @@ import type * as logsLogin from "../logsLogin.js";
|
|||||||
import type * as menu from "../menu.js";
|
import type * as menu from "../menu.js";
|
||||||
import type * as monitoramento from "../monitoramento.js";
|
import type * as monitoramento from "../monitoramento.js";
|
||||||
import type * as objetos from "../objetos.js";
|
import type * as objetos from "../objetos.js";
|
||||||
|
import type * as pedidoFlow from "../pedidoFlow.js";
|
||||||
import type * as pedidos from "../pedidos.js";
|
import type * as pedidos from "../pedidos.js";
|
||||||
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
||||||
import type * as planejamentos from "../planejamentos.js";
|
import type * as planejamentos from "../planejamentos.js";
|
||||||
@@ -137,6 +139,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
crons: typeof crons;
|
crons: typeof crons;
|
||||||
cursos: typeof cursos;
|
cursos: typeof cursos;
|
||||||
dashboard: typeof dashboard;
|
dashboard: typeof dashboard;
|
||||||
|
debug: typeof debug;
|
||||||
documentos: typeof documentos;
|
documentos: typeof documentos;
|
||||||
email: typeof email;
|
email: typeof email;
|
||||||
empresas: typeof empresas;
|
empresas: typeof empresas;
|
||||||
@@ -155,6 +158,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
menu: typeof menu;
|
menu: typeof menu;
|
||||||
monitoramento: typeof monitoramento;
|
monitoramento: typeof monitoramento;
|
||||||
objetos: typeof objetos;
|
objetos: typeof objetos;
|
||||||
|
pedidoFlow: typeof pedidoFlow;
|
||||||
pedidos: typeof pedidos;
|
pedidos: typeof pedidos;
|
||||||
permissoesAcoes: typeof permissoesAcoes;
|
permissoesAcoes: typeof permissoesAcoes;
|
||||||
planejamentos: typeof planejamentos;
|
planejamentos: typeof planejamentos;
|
||||||
|
|||||||
23
packages/backend/convex/debug.ts
Normal file
23
packages/backend/convex/debug.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { query } from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const inspectOrder = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const pedido = await ctx.db.get(args.pedidoId);
|
||||||
|
const historicoEtapas = await ctx.db
|
||||||
|
.query('pedidoEtapasHistorico')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const etapas = await ctx.db.query('pedidoFluxoEtapa').collect();
|
||||||
|
const transicoes = await ctx.db.query('pedidoFluxoTransicao').collect();
|
||||||
|
|
||||||
|
return {
|
||||||
|
pedido,
|
||||||
|
historicoEtapas,
|
||||||
|
etapasConfiguradas: etapas,
|
||||||
|
transicoesConfiguradas: transicoes
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
859
packages/backend/convex/pedidoFlow.ts
Normal file
859
packages/backend/convex/pedidoFlow.ts
Normal file
@@ -0,0 +1,859 @@
|
|||||||
|
import { v } from 'convex/values';
|
||||||
|
import type { Id } from './_generated/dataModel';
|
||||||
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
|
||||||
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
|
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
||||||
|
const identity = await ctx.auth.getUserIdentity();
|
||||||
|
if (!identity) {
|
||||||
|
throw new Error('Usuário não autenticado');
|
||||||
|
}
|
||||||
|
const usuario = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.filter((q) => q.eq(q.field('email'), identity.email))
|
||||||
|
.first();
|
||||||
|
if (!usuario) {
|
||||||
|
throw new Error('Usuário não encontrado');
|
||||||
|
}
|
||||||
|
return usuario;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function iniciarFluxoPedidoInternal(
|
||||||
|
ctx: MutationCtx,
|
||||||
|
pedidoId: Id<'pedidos'>,
|
||||||
|
usuarioId: Id<'usuarios'>
|
||||||
|
) {
|
||||||
|
const pedido = await ctx.db.get(pedidoId);
|
||||||
|
if (!pedido) {
|
||||||
|
throw new Error('Pedido não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já tem histórico
|
||||||
|
const historicoExistente = await ctx.db
|
||||||
|
.query('pedidoEtapasHistorico')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (historicoExistente) {
|
||||||
|
return historicoExistente._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar primeira etapa do fluxo
|
||||||
|
const primeiraEtapa = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').first();
|
||||||
|
|
||||||
|
if (!primeiraEtapa) {
|
||||||
|
console.warn('Nenhuma etapa configurada no fluxo. Timeline ficará vazia.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Criar primeiro registro
|
||||||
|
const historicoId = await ctx.db.insert('pedidoEtapasHistorico', {
|
||||||
|
pedidoId,
|
||||||
|
etapaId: primeiraEtapa._id,
|
||||||
|
inicioData: now,
|
||||||
|
atual: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar no histórico
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId,
|
||||||
|
usuarioId,
|
||||||
|
acao: 'inicio_fluxo',
|
||||||
|
detalhes: JSON.stringify({
|
||||||
|
etapa: primeiraEtapa.codigo,
|
||||||
|
etapaNome: primeiraEtapa.nome
|
||||||
|
}),
|
||||||
|
data: now
|
||||||
|
});
|
||||||
|
|
||||||
|
return historicoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getEtapaAtualDoPedido(ctx: QueryCtx | MutationCtx, pedidoId: Id<'pedidos'>) {
|
||||||
|
const etapaAtual = await ctx.db
|
||||||
|
.query('pedidoEtapasHistorico')
|
||||||
|
.withIndex('by_pedidoId_atual', (q) => q.eq('pedidoId', pedidoId).eq('atual', true))
|
||||||
|
.first();
|
||||||
|
return etapaAtual;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ETAPAS - QUERIES ==========
|
||||||
|
|
||||||
|
export const listEtapas = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoFluxoEtapa'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
nome: v.string(),
|
||||||
|
codigo: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
setorId: v.optional(v.id('setores')),
|
||||||
|
setorNome: v.optional(v.string()),
|
||||||
|
tempoEstimadoDias: v.optional(v.number()),
|
||||||
|
incluirNoTimeline: v.boolean(),
|
||||||
|
ordem: v.number(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const etapas = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').collect();
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
etapas.map(async (etapa) => {
|
||||||
|
let setorNome: string | undefined;
|
||||||
|
if (etapa.setorId) {
|
||||||
|
const setor = await ctx.db.get(etapa.setorId);
|
||||||
|
setorNome = setor?.nome;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...etapa,
|
||||||
|
setorNome
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getEtapa = query({
|
||||||
|
args: { id: v.id('pedidoFluxoEtapa') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoFluxoEtapa'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
nome: v.string(),
|
||||||
|
codigo: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
setorId: v.optional(v.id('setores')),
|
||||||
|
tempoEstimadoDias: v.optional(v.number()),
|
||||||
|
incluirNoTimeline: v.boolean(),
|
||||||
|
ordem: v.number(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.get(args.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getEtapaByCodigo = query({
|
||||||
|
args: { codigo: v.string() },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoFluxoEtapa'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
nome: v.string(),
|
||||||
|
codigo: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
setorId: v.optional(v.id('setores')),
|
||||||
|
tempoEstimadoDias: v.optional(v.number()),
|
||||||
|
incluirNoTimeline: v.boolean(),
|
||||||
|
ordem: v.number(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('pedidoFluxoEtapa')
|
||||||
|
.withIndex('by_codigo', (q) => q.eq('codigo', args.codigo))
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== ETAPAS - MUTATIONS ==========
|
||||||
|
|
||||||
|
export const createEtapa = mutation({
|
||||||
|
args: {
|
||||||
|
nome: v.string(),
|
||||||
|
codigo: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
setorId: v.optional(v.id('setores')),
|
||||||
|
tempoEstimadoDias: v.optional(v.number()),
|
||||||
|
incluirNoTimeline: v.boolean()
|
||||||
|
},
|
||||||
|
returns: v.id('pedidoFluxoEtapa'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
// Verificar se já existe etapa com este código
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query('pedidoFluxoEtapa')
|
||||||
|
.withIndex('by_codigo', (q) => q.eq('codigo', args.codigo))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
throw new Error(`Já existe uma etapa com o código "${args.codigo}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Obter a maior ordem atual
|
||||||
|
const todasEtapas = await ctx.db.query('pedidoFluxoEtapa').collect();
|
||||||
|
const maiorOrdem = todasEtapas.reduce((max, e) => Math.max(max, e.ordem), 0);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
return await ctx.db.insert('pedidoFluxoEtapa', {
|
||||||
|
nome: args.nome,
|
||||||
|
codigo: args.codigo,
|
||||||
|
descricao: args.descricao,
|
||||||
|
setorId: args.setorId,
|
||||||
|
tempoEstimadoDias: args.tempoEstimadoDias,
|
||||||
|
incluirNoTimeline: args.incluirNoTimeline,
|
||||||
|
ordem: maiorOrdem + 1,
|
||||||
|
criadoEm: now,
|
||||||
|
atualizadoEm: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateEtapa = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('pedidoFluxoEtapa'),
|
||||||
|
nome: v.optional(v.string()),
|
||||||
|
codigo: v.optional(v.string()),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
setorId: v.optional(v.id('setores')),
|
||||||
|
tempoEstimadoDias: v.optional(v.number()),
|
||||||
|
incluirNoTimeline: v.optional(v.boolean()),
|
||||||
|
ordem: v.optional(v.number())
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const etapa = await ctx.db.get(args.id);
|
||||||
|
if (!etapa) {
|
||||||
|
throw new Error('Etapa não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const codigo = args.codigo;
|
||||||
|
|
||||||
|
// Se estiver mudando o código, verificar duplicidade
|
||||||
|
if (codigo && codigo !== etapa.codigo) {
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query('pedidoFluxoEtapa')
|
||||||
|
.withIndex('by_codigo', (q) => q.eq('codigo', codigo))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
throw new Error(`Já existe uma etapa com o código "${args.codigo}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
...(args.nome !== undefined && { nome: args.nome }),
|
||||||
|
...(args.codigo !== undefined && { codigo: args.codigo }),
|
||||||
|
...(args.descricao !== undefined && { descricao: args.descricao }),
|
||||||
|
...(args.setorId !== undefined && { setorId: args.setorId }),
|
||||||
|
...(args.tempoEstimadoDias !== undefined && { tempoEstimadoDias: args.tempoEstimadoDias }),
|
||||||
|
...(args.incluirNoTimeline !== undefined && { incluirNoTimeline: args.incluirNoTimeline }),
|
||||||
|
...(args.ordem !== undefined && { ordem: args.ordem }),
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteEtapa = mutation({
|
||||||
|
args: { id: v.id('pedidoFluxoEtapa') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const etapa = await ctx.db.get(args.id);
|
||||||
|
if (!etapa) {
|
||||||
|
throw new Error('Etapa não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se há transições usando esta etapa
|
||||||
|
const transicoesOrigem = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (transicoesOrigem.length > 0) {
|
||||||
|
throw new Error('Não é possível excluir: esta etapa possui transições de saída');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se há transições de destino
|
||||||
|
const transicoesDestino = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.filter((q) => q.eq(q.field('etapaDestinoId'), args.id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if (transicoesDestino.length > 0) {
|
||||||
|
throw new Error('Não é possível excluir: esta etapa é destino de outras transições');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se há histórico usando esta etapa
|
||||||
|
const historico = await ctx.db
|
||||||
|
.query('pedidoEtapasHistorico')
|
||||||
|
.withIndex('by_etapaId', (q) => q.eq('etapaId', args.id))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (historico) {
|
||||||
|
throw new Error('Não é possível excluir: esta etapa já foi utilizada em pedidos');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const reordenarEtapas = mutation({
|
||||||
|
args: {
|
||||||
|
ordens: v.array(
|
||||||
|
v.object({
|
||||||
|
id: v.id('pedidoFluxoEtapa'),
|
||||||
|
ordem: v.number()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
for (const item of args.ordens) {
|
||||||
|
await ctx.db.patch(item.id, { ordem: item.ordem, atualizadoEm: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== TRANSIÇÕES - QUERIES ==========
|
||||||
|
|
||||||
|
export const listTransicoes = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoFluxoTransicao'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
etapaOrigemId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaOrigemNome: v.string(),
|
||||||
|
etapaDestinoId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaDestinoNome: v.string(),
|
||||||
|
isPadrao: v.boolean(),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const transicoes = await ctx.db.query('pedidoFluxoTransicao').collect();
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
transicoes.map(async (t) => {
|
||||||
|
const origem = await ctx.db.get(t.etapaOrigemId);
|
||||||
|
const destino = await ctx.db.get(t.etapaDestinoId);
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
etapaOrigemNome: origem?.nome ?? 'Desconhecido',
|
||||||
|
etapaDestinoNome: destino?.nome ?? 'Desconhecido'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getProximasEtapas = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoFluxoEtapa'),
|
||||||
|
nome: v.string(),
|
||||||
|
codigo: v.string(),
|
||||||
|
isPadrao: v.boolean()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId);
|
||||||
|
|
||||||
|
if (!etapaAtualHistorico) {
|
||||||
|
// Pedido não tem etapa, retornar a primeira etapa do fluxo
|
||||||
|
const primeiraEtapa = await ctx.db.query('pedidoFluxoEtapa').withIndex('by_ordem').first();
|
||||||
|
|
||||||
|
if (!primeiraEtapa) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
_id: primeiraEtapa._id,
|
||||||
|
nome: primeiraEtapa.nome,
|
||||||
|
codigo: primeiraEtapa.codigo,
|
||||||
|
isPadrao: true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar transições a partir da etapa atual
|
||||||
|
const transicoes = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', etapaAtualHistorico.etapaId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
transicoes.map(async (t) => {
|
||||||
|
const etapa = await ctx.db.get(t.etapaDestinoId);
|
||||||
|
if (!etapa) return null;
|
||||||
|
return {
|
||||||
|
_id: etapa._id,
|
||||||
|
nome: etapa.nome,
|
||||||
|
codigo: etapa.codigo,
|
||||||
|
isPadrao: t.isPadrao
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result.filter((r): r is NonNullable<typeof r> => r !== null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== TRANSIÇÕES - MUTATIONS ==========
|
||||||
|
|
||||||
|
export const createTransicao = mutation({
|
||||||
|
args: {
|
||||||
|
etapaOrigemId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaDestinoId: v.id('pedidoFluxoEtapa')
|
||||||
|
},
|
||||||
|
returns: v.id('pedidoFluxoTransicao'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
if (args.etapaOrigemId === args.etapaDestinoId) {
|
||||||
|
throw new Error('A etapa de origem e destino não podem ser iguais');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se etapas existem
|
||||||
|
const origem = await ctx.db.get(args.etapaOrigemId);
|
||||||
|
const destino = await ctx.db.get(args.etapaDestinoId);
|
||||||
|
|
||||||
|
if (!origem) throw new Error('Etapa de origem não encontrada');
|
||||||
|
if (!destino) throw new Error('Etapa de destino não encontrada');
|
||||||
|
|
||||||
|
// Verificar se já existe esta transição
|
||||||
|
const existente = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.etapaOrigemId))
|
||||||
|
.filter((q) => q.eq(q.field('etapaDestinoId'), args.etapaDestinoId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existente) {
|
||||||
|
throw new Error('Esta transição já existe');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar se já existe alguma transição de saída para esta origem
|
||||||
|
const transicoesExistentes = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', args.etapaOrigemId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Se for a primeira transição, ela é a padrão
|
||||||
|
const isPadrao = transicoesExistentes.length === 0;
|
||||||
|
|
||||||
|
return await ctx.db.insert('pedidoFluxoTransicao', {
|
||||||
|
etapaOrigemId: args.etapaOrigemId,
|
||||||
|
etapaDestinoId: args.etapaDestinoId,
|
||||||
|
isPadrao,
|
||||||
|
criadoEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const deleteTransicao = mutation({
|
||||||
|
args: { id: v.id('pedidoFluxoTransicao') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const transicao = await ctx.db.get(args.id);
|
||||||
|
if (!transicao) {
|
||||||
|
throw new Error('Transição não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
|
||||||
|
// Se era a transição padrão, definir outra como padrão
|
||||||
|
if (transicao.isPadrao) {
|
||||||
|
const outraTransicao = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', transicao.etapaOrigemId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (outraTransicao) {
|
||||||
|
await ctx.db.patch(outraTransicao._id, { isPadrao: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const setTransicaoPadrao = mutation({
|
||||||
|
args: { id: v.id('pedidoFluxoTransicao') },
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const transicao = await ctx.db.get(args.id);
|
||||||
|
if (!transicao) {
|
||||||
|
throw new Error('Transição não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remover isPadrao de todas as transições da mesma origem
|
||||||
|
const todasTransicoes = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', transicao.etapaOrigemId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const t of todasTransicoes) {
|
||||||
|
if (t._id !== args.id && t.isPadrao) {
|
||||||
|
await ctx.db.patch(t._id, { isPadrao: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Definir esta como padrão
|
||||||
|
await ctx.db.patch(args.id, { isPadrao: true });
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== HISTÓRICO E TIMELINE ==========
|
||||||
|
|
||||||
|
export const getEtapasHistorico = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoEtapasHistorico'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
etapaId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaNome: v.string(),
|
||||||
|
etapaCodigo: v.string(),
|
||||||
|
inicioData: v.number(),
|
||||||
|
fimData: v.optional(v.number()),
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios')),
|
||||||
|
funcionarioNome: v.optional(v.string()),
|
||||||
|
atual: v.boolean()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const historico = await ctx.db
|
||||||
|
.query('pedidoEtapasHistorico')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Ordenar por inicioData
|
||||||
|
historico.sort((a, b) => a.inicioData - b.inicioData);
|
||||||
|
|
||||||
|
const result = await Promise.all(
|
||||||
|
historico.map(async (h) => {
|
||||||
|
const etapa = await ctx.db.get(h.etapaId);
|
||||||
|
let funcionarioNome: string | undefined;
|
||||||
|
if (h.funcionarioId) {
|
||||||
|
const funcionario = await ctx.db.get(h.funcionarioId);
|
||||||
|
funcionarioNome = funcionario?.nome;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
etapaNome: etapa?.nome ?? 'Desconhecido',
|
||||||
|
etapaCodigo: etapa?.codigo ?? 'desconhecido',
|
||||||
|
funcionarioNome
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getPedidoTimeline = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
returns: v.object({
|
||||||
|
passado: v.array(
|
||||||
|
v.object({
|
||||||
|
etapaId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaNome: v.string(),
|
||||||
|
etapaCodigo: v.string(),
|
||||||
|
inicioData: v.number(),
|
||||||
|
fimData: v.optional(v.number()),
|
||||||
|
funcionarioNome: v.optional(v.string()),
|
||||||
|
atual: v.boolean(),
|
||||||
|
incluirNoTimeline: v.boolean()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
futuro: v.array(
|
||||||
|
v.object({
|
||||||
|
etapaId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaNome: v.string(),
|
||||||
|
etapaCodigo: v.string(),
|
||||||
|
dataPrevisao: v.number(),
|
||||||
|
incluirNoTimeline: v.boolean()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
// Buscar histórico passado
|
||||||
|
const historico = await ctx.db
|
||||||
|
.query('pedidoEtapasHistorico')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
historico.sort((a, b) => a.inicioData - b.inicioData);
|
||||||
|
|
||||||
|
const passado = await Promise.all(
|
||||||
|
historico.map(async (h) => {
|
||||||
|
const etapa = await ctx.db.get(h.etapaId);
|
||||||
|
let funcionarioNome: string | undefined;
|
||||||
|
if (h.funcionarioId) {
|
||||||
|
const funcionario = await ctx.db.get(h.funcionarioId);
|
||||||
|
funcionarioNome = funcionario?.nome;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
etapaId: h.etapaId,
|
||||||
|
etapaNome: etapa?.nome ?? 'Desconhecido',
|
||||||
|
etapaCodigo: etapa?.codigo ?? 'desconhecido',
|
||||||
|
inicioData: h.inicioData,
|
||||||
|
fimData: h.fimData,
|
||||||
|
funcionarioNome,
|
||||||
|
atual: h.atual,
|
||||||
|
incluirNoTimeline: etapa?.incluirNoTimeline ?? true
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calcular previsão futura usando transições padrão
|
||||||
|
const futuro: {
|
||||||
|
etapaId: Id<'pedidoFluxoEtapa'>;
|
||||||
|
etapaNome: string;
|
||||||
|
etapaCodigo: string;
|
||||||
|
dataPrevisao: number;
|
||||||
|
incluirNoTimeline: boolean;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
const etapaAtualHistorico = historico.find((h) => h.atual);
|
||||||
|
if (etapaAtualHistorico) {
|
||||||
|
let etapaAtualId = etapaAtualHistorico.etapaId;
|
||||||
|
let dataPrevisao = Date.now();
|
||||||
|
|
||||||
|
// Calcular tempo decorrido na etapa atual
|
||||||
|
const etapaAtual = await ctx.db.get(etapaAtualId);
|
||||||
|
if (etapaAtual?.tempoEstimadoDias) {
|
||||||
|
dataPrevisao += etapaAtual.tempoEstimadoDias * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seguir transições padrão até não ter mais
|
||||||
|
const visitadas = new Set<string>();
|
||||||
|
let iteracoes = 0;
|
||||||
|
const MAX_ITERACOES = 20; // Evitar loop infinito
|
||||||
|
|
||||||
|
while (iteracoes < MAX_ITERACOES) {
|
||||||
|
iteracoes++;
|
||||||
|
|
||||||
|
if (visitadas.has(etapaAtualId)) break;
|
||||||
|
visitadas.add(etapaAtualId);
|
||||||
|
|
||||||
|
// Buscar transição padrão
|
||||||
|
const transicaoPadrao = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId_isPadrao', (q) =>
|
||||||
|
q.eq('etapaOrigemId', etapaAtualId).eq('isPadrao', true)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!transicaoPadrao) break;
|
||||||
|
|
||||||
|
const proximaEtapa = await ctx.db.get(transicaoPadrao.etapaDestinoId);
|
||||||
|
if (!proximaEtapa) break;
|
||||||
|
|
||||||
|
futuro.push({
|
||||||
|
etapaId: proximaEtapa._id,
|
||||||
|
etapaNome: proximaEtapa.nome,
|
||||||
|
etapaCodigo: proximaEtapa.codigo,
|
||||||
|
dataPrevisao,
|
||||||
|
incluirNoTimeline: proximaEtapa.incluirNoTimeline
|
||||||
|
});
|
||||||
|
|
||||||
|
// Atualizar para próxima iteração
|
||||||
|
if (proximaEtapa.tempoEstimadoDias) {
|
||||||
|
dataPrevisao += proximaEtapa.tempoEstimadoDias * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
etapaAtualId = proximaEtapa._id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { passado, futuro };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== MUDANÇA DE ETAPA ==========
|
||||||
|
|
||||||
|
export const mudarEtapa = mutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
novaEtapaId: v.id('pedidoFluxoEtapa'),
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios'))
|
||||||
|
},
|
||||||
|
returns: v.id('pedidoEtapasHistorico'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const pedido = await ctx.db.get(args.pedidoId);
|
||||||
|
if (!pedido) {
|
||||||
|
throw new Error('Pedido não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
const novaEtapa = await ctx.db.get(args.novaEtapaId);
|
||||||
|
if (!novaEtapa) {
|
||||||
|
throw new Error('Etapa não encontrada');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Buscar etapa atual
|
||||||
|
const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId);
|
||||||
|
|
||||||
|
if (etapaAtualHistorico) {
|
||||||
|
// Validar se a transição é permitida
|
||||||
|
const transicaoPermitida = await ctx.db
|
||||||
|
.query('pedidoFluxoTransicao')
|
||||||
|
.withIndex('by_etapaOrigemId', (q) => q.eq('etapaOrigemId', etapaAtualHistorico.etapaId))
|
||||||
|
.filter((q) => q.eq(q.field('etapaDestinoId'), args.novaEtapaId))
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!transicaoPermitida) {
|
||||||
|
throw new Error('Transição não permitida para esta etapa');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalizar etapa atual
|
||||||
|
await ctx.db.patch(etapaAtualHistorico._id, {
|
||||||
|
atual: false,
|
||||||
|
fimData: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Criar novo registro de etapa
|
||||||
|
const novoHistoricoId = await ctx.db.insert('pedidoEtapasHistorico', {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
etapaId: args.novaEtapaId,
|
||||||
|
inicioData: now,
|
||||||
|
funcionarioId: args.funcionarioId,
|
||||||
|
atual: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Registrar no histórico de pedidos
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
usuarioId: usuario._id,
|
||||||
|
acao: 'alteracao_etapa',
|
||||||
|
detalhes: JSON.stringify({
|
||||||
|
novaEtapa: novaEtapa.codigo,
|
||||||
|
novaEtapaNome: novaEtapa.nome,
|
||||||
|
funcionarioId: args.funcionarioId
|
||||||
|
}),
|
||||||
|
data: now
|
||||||
|
});
|
||||||
|
|
||||||
|
return novoHistoricoId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const iniciarFluxoPedido = mutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos')
|
||||||
|
},
|
||||||
|
returns: v.union(v.id('pedidoEtapasHistorico'), v.null()),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const usuario = await getUsuarioAutenticado(ctx);
|
||||||
|
return await iniciarFluxoPedidoInternal(ctx, args.pedidoId, usuario._id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const atribuirFuncionario = mutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
funcionarioId: v.id('funcionarios')
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const etapaAtual = await getEtapaAtualDoPedido(ctx, args.pedidoId);
|
||||||
|
if (!etapaAtual) {
|
||||||
|
throw new Error('Pedido não possui etapa atual');
|
||||||
|
}
|
||||||
|
|
||||||
|
const funcionario = await ctx.db.get(args.funcionarioId);
|
||||||
|
if (!funcionario) {
|
||||||
|
throw new Error('Funcionário não encontrado');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.patch(etapaAtual._id, {
|
||||||
|
funcionarioId: args.funcionarioId
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== QUERY PARA OBTER ETAPA ATUAL ==========
|
||||||
|
|
||||||
|
export const getEtapaAtual = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoEtapasHistorico'),
|
||||||
|
etapaId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaNome: v.string(),
|
||||||
|
etapaCodigo: v.string(),
|
||||||
|
setorId: v.optional(v.id('setores')),
|
||||||
|
setorNome: v.optional(v.string()),
|
||||||
|
inicioData: v.number(),
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios')),
|
||||||
|
funcionarioNome: v.optional(v.string())
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const etapaAtualHistorico = await getEtapaAtualDoPedido(ctx, args.pedidoId);
|
||||||
|
if (!etapaAtualHistorico) return null;
|
||||||
|
|
||||||
|
const etapa = await ctx.db.get(etapaAtualHistorico.etapaId);
|
||||||
|
if (!etapa) return null;
|
||||||
|
|
||||||
|
let setorNome: string | undefined;
|
||||||
|
if (etapa.setorId) {
|
||||||
|
const setor = await ctx.db.get(etapa.setorId);
|
||||||
|
setorNome = setor?.nome;
|
||||||
|
}
|
||||||
|
|
||||||
|
let funcionarioNome: string | undefined;
|
||||||
|
if (etapaAtualHistorico.funcionarioId) {
|
||||||
|
const funcionario = await ctx.db.get(etapaAtualHistorico.funcionarioId);
|
||||||
|
funcionarioNome = funcionario?.nome;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
_id: etapaAtualHistorico._id,
|
||||||
|
etapaId: etapa._id,
|
||||||
|
etapaNome: etapa.nome,
|
||||||
|
etapaCodigo: etapa.codigo,
|
||||||
|
setorId: etapa.setorId,
|
||||||
|
setorNome,
|
||||||
|
inicioData: etapaAtualHistorico.inicioData,
|
||||||
|
funcionarioId: etapaAtualHistorico.funcionarioId,
|
||||||
|
funcionarioNome
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import type { Doc, Id } from './_generated/dataModel';
|
|||||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { internalMutation, mutation, query } from './_generated/server';
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import { iniciarFluxoPedidoInternal } from './pedidoFlow';
|
||||||
import { getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
|
import { getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
|
||||||
|
|
||||||
// ========== HELPERS ==========
|
// ========== HELPERS ==========
|
||||||
@@ -1292,6 +1293,9 @@ export const create = mutation({
|
|||||||
data: Date.now()
|
data: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 5. Iniciar Fluxo se houver etapas
|
||||||
|
await iniciarFluxoPedidoInternal(ctx, pedidoId, user._id);
|
||||||
|
|
||||||
return pedidoId;
|
return pedidoId;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -2079,6 +2083,9 @@ export const enviarParaAceite = mutation({
|
|||||||
data: Date.now()
|
data: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Garantir que o fluxo foi iniciado
|
||||||
|
await iniciarFluxoPedidoInternal(ctx, args.pedidoId, user._id);
|
||||||
|
|
||||||
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
|
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
|
||||||
pedidoId: args.pedidoId,
|
pedidoId: args.pedidoId,
|
||||||
oldStatus,
|
oldStatus,
|
||||||
|
|||||||
@@ -112,5 +112,46 @@ export const pedidosTables = {
|
|||||||
})
|
})
|
||||||
.index('by_requestId', ['requestId'])
|
.index('by_requestId', ['requestId'])
|
||||||
.index('by_pedidoId', ['pedidoId'])
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
.index('by_criadoPor', ['criadoPor'])
|
.index('by_criadoPor', ['criadoPor']),
|
||||||
|
|
||||||
|
// ========== FLUXO DE PEDIDOS ==========
|
||||||
|
|
||||||
|
// Configuração das etapas do fluxo de pedidos (dinâmico)
|
||||||
|
pedidoFluxoEtapa: defineTable({
|
||||||
|
nome: v.string(), // Nome da etapa (ex: "Rascunho", "Aguardando Aceite")
|
||||||
|
codigo: v.string(), // Código único (ex: "rascunho", "aguardando_aceite")
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
setorId: v.optional(v.id('setores')), // Setor responsável por esta etapa
|
||||||
|
tempoEstimadoDias: v.optional(v.number()), // Tempo estimado em dias
|
||||||
|
incluirNoTimeline: v.boolean(), // Se false, não aparece no timeline (ex: rascunho)
|
||||||
|
ordem: v.number(), // Ordem de exibição
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_codigo', ['codigo'])
|
||||||
|
.index('by_ordem', ['ordem']),
|
||||||
|
|
||||||
|
// Transições possíveis entre etapas
|
||||||
|
pedidoFluxoTransicao: defineTable({
|
||||||
|
etapaOrigemId: v.id('pedidoFluxoEtapa'),
|
||||||
|
etapaDestinoId: v.id('pedidoFluxoEtapa'),
|
||||||
|
isPadrao: v.boolean(), // Se é a transição padrão quando há múltiplas opções
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_etapaOrigemId', ['etapaOrigemId'])
|
||||||
|
.index('by_etapaOrigemId_isPadrao', ['etapaOrigemId', 'isPadrao']),
|
||||||
|
|
||||||
|
// Histórico de etapas do pedido
|
||||||
|
pedidoEtapasHistorico: defineTable({
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
etapaId: v.id('pedidoFluxoEtapa'),
|
||||||
|
inicioData: v.number(),
|
||||||
|
fimData: v.optional(v.number()),
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios')),
|
||||||
|
atual: v.boolean()
|
||||||
|
})
|
||||||
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
|
.index('by_pedidoId_atual', ['pedidoId', 'atual'])
|
||||||
|
.index('by_etapaId', ['etapaId'])
|
||||||
|
.index('by_funcionarioId', ['funcionarioId'])
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user