Compare commits

..

15 Commits

Author SHA1 Message Date
0c7412c764 feat: implement filtering and PDF/Excel report generation for planejamentos. 2025-12-18 17:31:55 -03:00
0a4be24655 feat: Add planning cloning functionality to list and detail pages with backend support. 2025-12-18 16:57:52 -03:00
011a867aac refactor: update fluxo instance management by enhancing the creation modal and improving the sidebar navigation structure, ensuring better user experience and code maintainability 2025-12-18 15:52:57 -03:00
d3a4e5db8f refactor: update sidebar submenu link for 'Meus Processos' to improve navigation structure and maintain consistency in URL paths 2025-12-18 14:52:38 -03:00
f008610b26 refactor: enhance pedidos UI by integrating reusable components for layout and styling, improving code maintainability and user experience across various pages 2025-12-18 12:02:57 -03:00
230be4db61 refactor: simplify table header styling in compras pages by removing unnecessary classes, enhancing code clarity and maintainability 2025-12-18 10:56:03 -03:00
94373c6b94 refactor: simplify pedidos item management by removing modalidade from item configuration and validation, ensuring all items use the same ata while enhancing code clarity and maintainability 2025-12-18 08:48:40 -03:00
69914170bf feat: enhance pedidos functionality by adding new submenu options for creating and planning orders, improving user navigation and access control in the sidebar; also implement URL-based prefill for adding items, ensuring a smoother user experience when creating pedidos 2025-12-17 21:42:35 -03:00
551a2fed00 refactor: streamline authentication logic in dashboard and login routes by removing unnecessary error handling and improving user session validation, enhancing code clarity and maintainability 2025-12-17 11:28:08 -03:00
9072619e26 feat: enhance ata management by adding dataProrrogacao field and updating related logic for effective date handling, improving data integrity and user experience in pedidos 2025-12-17 10:39:33 -03:00
fbf00c824e feat: add DFD number management to pedidos, including editing functionality and validation for sending to acceptance, enhancing data integrity and user feedback 2025-12-16 14:58:35 -03:00
f90b27648f feat: enhance ata and objeto management by adding configuration options for quantity limits and usage tracking, improving data integrity and user feedback in pedidos 2025-12-16 14:20:31 -03:00
fd2669aa4f feat: implement advanced filtering and reporting features for pedidos, including status selection, date range filtering, and export options for PDF and XLSX formats 2025-12-15 15:37:57 -03:00
c7b4ea15bd feat: add filtering functionality for empresas in dashboard, allowing users to search by name or CNPJ, and enhance UI with clear feedback for no results 2025-12-15 14:34:09 -03:00
a5ad843b3e feat: implement filtering and document management features in dashboard components, enhancing user experience with improved query capabilities and UI for managing documents in pedidos and compras 2025-12-15 14:29:30 -03:00
47 changed files with 8252 additions and 3260 deletions

View File

@@ -1,7 +1,5 @@
--- ---
trigger: model_decision trigger: always_on
description: whenever you're working with Svelte files
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:

View File

@@ -108,11 +108,26 @@
link: '/pedidos', link: '/pedidos',
permission: { recurso: 'pedidos', acao: 'listar' }, permission: { recurso: 'pedidos', acao: 'listar' },
submenus: [ submenus: [
{
label: 'Novo Pedido',
link: '/pedidos/novo',
permission: { recurso: 'pedidos', acao: 'criar' }
},
{
label: 'Planejamentos',
link: '/pedidos/planejamento',
permission: { recurso: 'pedidos', acao: 'listar' }
},
{ {
label: 'Meus Pedidos', label: 'Meus Pedidos',
link: '/pedidos', link: '/pedidos',
permission: { recurso: 'pedidos', acao: 'listar' }, permission: { recurso: 'pedidos', acao: 'listar' },
excludePaths: ['/pedidos/aceite', '/pedidos/minhas-analises'] excludePaths: [
'/pedidos/aceite',
'/pedidos/minhas-analises',
'/pedidos/novo',
'/pedidos/planejamento'
]
}, },
{ {
label: 'Pedidos para Aceite', label: 'Pedidos para Aceite',
@@ -158,8 +173,9 @@
submenus: [ submenus: [
{ {
label: 'Meus Processos', label: 'Meus Processos',
link: '/fluxos/meus-processos', link: '/fluxos',
permission: { recurso: 'fluxos_instancias', acao: 'listar' } permission: { recurso: 'fluxos_instancias', acao: 'listar' },
exact: true
}, },
{ {
label: 'Modelos de Fluxo', label: 'Modelos de Fluxo',

View File

@@ -0,0 +1,29 @@
<script lang="ts">
export type BreadcrumbItem = {
label: string;
href?: string;
};
interface Props {
items: BreadcrumbItem[];
class?: string;
}
let { items, class: className = '' }: Props = $props();
</script>
<div class={['breadcrumbs mb-4 text-sm', className].filter(Boolean)}>
<ul>
{#each items as item (item.label)}
<li>
{#if item.href}
<a href={item.href} class="text-primary hover:underline">{item.label}</a>
{:else}
{item.label}
{/if}
</li>
{/each}
</ul>
</div>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
subtitle?: string;
icon?: Snippet;
actions?: Snippet;
class?: string;
}
let {
title,
subtitle,
icon,
actions,
class: className = ''
}: Props = $props();
</script>
<div class={['mb-6', className].filter(Boolean)}>
<div class="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div class="flex items-center gap-4">
{#if icon}
<div class="bg-primary/10 rounded-xl p-3">
<div class="text-primary [&_svg]:h-8 [&_svg]:w-8">
{@render icon()}
</div>
</div>
{/if}
<div>
<h1 class="text-primary text-3xl font-bold">{title}</h1>
{#if subtitle}
<p class="text-base-content/70">{subtitle}</p>
{/if}
</div>
</div>
{#if actions}
<div class="flex items-center gap-2">
{@render actions()}
</div>
{/if}
</div>
</div>

View File

@@ -0,0 +1,16 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
children?: Snippet;
}
let { class: className = '', children }: Props = $props();
</script>
<main class={['container mx-auto flex max-w-7xl flex-col px-4 py-4', className].filter(Boolean)}>
{@render children?.()}
</main>

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
title: string;
description?: string;
icon?: Snippet;
class?: string;
}
let { title, description, icon, class: className = '' }: Props = $props();
</script>
<div
class={[
'bg-base-100 border-base-300 flex flex-col items-center justify-center rounded-lg border py-12 text-center',
className
].filter(Boolean)}
>
{#if icon}
<div class="bg-base-200 mb-4 rounded-full p-4">
<div class="text-base-content/30 [&_svg]:h-8 [&_svg]:w-8">
{@render icon()}
</div>
</div>
{/if}
<h3 class="text-lg font-semibold">{title}</h3>
{#if description}
<p class="text-base-content/60 mt-1 max-w-sm text-sm">{description}</p>
{/if}
</div>

View File

@@ -0,0 +1,22 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
class?: string;
bodyClass?: string;
children?: Snippet;
}
let { class: className = '', bodyClass = '', children }: Props = $props();
</script>
<div
class={[
'card bg-base-100/90 border-base-300 border shadow-xl backdrop-blur-sm',
className
].filter(Boolean)}
>
<div class={['card-body', bodyClass].filter(Boolean)}>
{@render children?.()}
</div>
</div>

View File

@@ -0,0 +1,18 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
interface Props {
class?: string;
bodyClass?: string;
children?: Snippet;
}
let { class: className = '', bodyClass = '', children }: Props = $props();
</script>
<GlassCard class={className} bodyClass={['p-0', bodyClass].filter(Boolean).join(' ')}>
<div class="overflow-x-auto">
{@render children?.()}
</div>
</GlassCard>

View File

@@ -0,0 +1,377 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { FunctionReturnType } from 'convex/server';
import ExcelJS from 'exceljs';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
export type RelatorioPedidosData = FunctionReturnType<typeof api.pedidos.gerarRelatorio>;
function formatDateTime(ts: number | undefined): string {
if (!ts) return '';
return new Date(ts).toLocaleString('pt-BR');
}
function argb(hex: string): { argb: string } {
return { argb: hex.replace('#', '').toUpperCase().padStart(8, 'FF') };
}
function statusLabel(status: string): string {
switch (status) {
case 'em_rascunho':
return 'Rascunho';
case 'aguardando_aceite':
return 'Aguardando Aceite';
case 'em_analise':
return 'Em Análise';
case 'precisa_ajustes':
return 'Precisa de Ajustes';
case 'concluido':
return 'Concluído';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function applyHeaderRowStyle(row: ExcelJS.Row) {
row.height = 22;
row.eachCell((cell) => {
cell.font = { bold: true, size: 11, color: argb('#FFFFFF') };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#2980B9') };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = {
top: { style: 'thin', color: argb('#000000') },
bottom: { style: 'thin', color: argb('#000000') },
left: { style: 'thin', color: argb('#000000') },
right: { style: 'thin', color: argb('#000000') }
};
});
}
function applyZebraRowStyle(row: ExcelJS.Row, isEven: boolean) {
row.eachCell((cell) => {
cell.font = { size: 10, color: argb('#000000') };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: argb(isEven ? '#F8F9FA' : '#FFFFFF')
};
cell.border = {
top: { style: 'thin', color: argb('#E0E0E0') },
bottom: { style: 'thin', color: argb('#E0E0E0') },
left: { style: 'thin', color: argb('#E0E0E0') },
right: { style: 'thin', color: argb('#E0E0E0') }
};
});
}
async function tryLoadLogoBuffer(): Promise<ArrayBuffer | null> {
try {
const response = await fetch(logoGovPE);
if (response.ok) return await response.arrayBuffer();
} catch {
// ignore
}
// Fallback via canvas (mesmo padrão usado em outras telas)
try {
const logoImg = new Image();
logoImg.crossOrigin = 'anonymous';
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000);
});
const canvas = document.createElement('canvas');
canvas.width = logoImg.width;
canvas.height = logoImg.height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(logoImg, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Falha ao converter imagem'))),
'image/png'
);
});
return await blob.arrayBuffer();
} catch {
return null;
}
}
function addTitleRow(
worksheet: ExcelJS.Worksheet,
title: string,
columnsCount: number,
workbook: ExcelJS.Workbook,
logoBuffer: ArrayBuffer | null
) {
// Evitar merge sobreposto quando há poucas colunas (ex.: Resumo tem 2 colunas)
if (columnsCount <= 0) return;
worksheet.getRow(1).height = 60;
// Reservar espaço para logo apenas quando há 3+ colunas (A1:B1)
if (columnsCount >= 3) {
worksheet.mergeCells(1, 1, 1, 2);
}
const logoCell = worksheet.getCell(1, 1);
logoCell.alignment = { vertical: 'middle', horizontal: 'left' };
if (columnsCount >= 3) {
logoCell.border = { right: { style: 'thin', color: argb('#E0E0E0') } };
}
if (logoBuffer) {
type WorkbookWithImage = { addImage(image: { buffer: Uint8Array; extension: string }): number };
const workbookWithImage = workbook as unknown as WorkbookWithImage;
const logoId = workbookWithImage.addImage({
buffer: new Uint8Array(logoBuffer),
extension: 'png'
});
worksheet.addImage(logoId, { tl: { col: 0, row: 0 }, ext: { width: 140, height: 55 } });
}
// Título
if (columnsCount === 1) {
const titleCell = worksheet.getCell(1, 1);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
return;
}
if (columnsCount === 2) {
// Sem merge para não colidir com A1:B1
const titleCell = worksheet.getCell(1, 2);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
return;
}
// 3+ colunas: mescla C1 até última coluna para o título
worksheet.mergeCells(1, 3, 1, columnsCount);
const titleCell = worksheet.getCell(1, 3);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
}
function downloadExcel(buffer: ArrayBuffer, fileName: string) {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
window.URL.revokeObjectURL(url);
}
export async function exportarRelatorioPedidosXLSX(relatorio: RelatorioPedidosData): Promise<void> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'SGSE - Sistema de Gerenciamento';
workbook.created = new Date();
const logoBuffer = await tryLoadLogoBuffer();
// ===== Aba: Resumo =====
{
const ws = workbook.addWorksheet('Resumo');
ws.columns = [
{ header: 'Campo', key: 'campo', width: 35 },
{ header: 'Valor', key: 'valor', width: 30 }
];
addTitleRow(ws, 'RELATÓRIO DE PEDIDOS', 2, workbook, logoBuffer);
// Cabeçalho da tabela em linha 2
const headerRow = ws.getRow(2);
headerRow.values = ['Campo', 'Valor'];
applyHeaderRowStyle(headerRow);
const rows: Array<{ campo: string; valor: string | number }> = [
{
campo: 'Período início',
valor: relatorio.filtros.periodoInicio
? formatDateTime(relatorio.filtros.periodoInicio)
: ''
},
{
campo: 'Período fim',
valor: relatorio.filtros.periodoFim ? formatDateTime(relatorio.filtros.periodoFim) : ''
},
{ campo: 'Número SEI (filtro)', valor: relatorio.filtros.numeroSei ?? '' },
{
campo: 'Status (filtro)',
valor: relatorio.filtros.statuses?.map(statusLabel).join(', ') ?? ''
},
{ campo: 'Total de pedidos', valor: relatorio.resumo.totalPedidos },
{ campo: 'Total de itens', valor: relatorio.resumo.totalItens },
{ campo: 'Total de documentos', valor: relatorio.resumo.totalDocumentos },
{ campo: 'Total valor estimado', valor: relatorio.resumo.totalValorEstimado },
{ campo: 'Total valor real', valor: relatorio.resumo.totalValorReal }
];
rows.forEach((r, idx) => {
const row = ws.addRow({ campo: r.campo, valor: r.valor });
applyZebraRowStyle(row, idx % 2 === 1);
row.getCell(2).alignment = {
vertical: 'middle',
horizontal: typeof r.valor === 'number' ? 'right' : 'left'
};
});
// Seção: Totais por status
ws.addRow({ campo: '', valor: '' });
const sectionRow = ws.addRow({ campo: 'Totais por status', valor: '' });
sectionRow.font = { bold: true, size: 11, color: argb('#2980B9') };
sectionRow.alignment = { vertical: 'middle', horizontal: 'left' };
relatorio.resumo.totalPorStatus.forEach((s, idx) => {
const row = ws.addRow({ campo: statusLabel(s.status), valor: s.count });
applyZebraRowStyle(row, idx % 2 === 1);
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
});
// Formatos numéricos
ws.getColumn(2).numFmt = '#,##0.00';
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
}
// ===== Aba: Pedidos =====
{
const ws = workbook.addWorksheet('Pedidos');
ws.columns = [
{ header: 'Nº SEI', key: 'numeroSei', width: 28 },
{ header: 'Status', key: 'status', width: 18 },
{ header: 'Criado por', key: 'criadoPor', width: 28 },
{ header: 'Aceito por', key: 'aceitoPor', width: 28 },
{ header: 'Criado em', key: 'criadoEm', width: 20 },
{ header: 'Concluído em', key: 'concluidoEm', width: 20 },
{ header: 'Itens', key: 'itens', width: 10 },
{ header: 'Docs', key: 'docs', width: 10 },
{ header: 'Estimado (R$)', key: 'estimado', width: 16 },
{ header: 'Real (R$)', key: 'real', width: 16 }
];
addTitleRow(ws, 'RELATÓRIO DE PEDIDOS — LISTA', ws.columns.length, workbook, logoBuffer);
const headerRow = ws.getRow(2);
headerRow.values = ws.columns.map((c) => c.header as string);
applyHeaderRowStyle(headerRow);
relatorio.pedidos.forEach((p, idx) => {
const row = ws.addRow({
numeroSei: p.numeroSei ?? '',
status: statusLabel(p.status),
criadoPor: p.criadoPorNome,
aceitoPor: p.aceitoPorNome ?? '',
criadoEm: p.criadoEm ? new Date(p.criadoEm) : null,
concluidoEm: p.concluidoEm ? new Date(p.concluidoEm) : null,
itens: p.itensCount,
docs: p.documentosCount,
estimado: p.valorEstimadoTotal,
real: p.valorRealTotal
});
applyZebraRowStyle(row, idx % 2 === 1);
// Alinhamentos
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(6).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(7).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(9).alignment = { vertical: 'middle', horizontal: 'right' };
row.getCell(10).alignment = { vertical: 'middle', horizontal: 'right' };
// Destaque por status
const statusCell = row.getCell(2);
if (p.status === 'concluido') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#D4EDDA') };
statusCell.font = { size: 10, color: argb('#155724') };
} else if (p.status === 'cancelado') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#F8D7DA') };
statusCell.font = { size: 10, color: argb('#721C24') };
} else if (p.status === 'aguardando_aceite') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#FFF3CD') };
statusCell.font = { size: 10, color: argb('#856404') };
}
});
// Formatos
ws.getColumn(5).numFmt = 'dd/mm/yyyy hh:mm';
ws.getColumn(6).numFmt = 'dd/mm/yyyy hh:mm';
ws.getColumn(9).numFmt = '"R$" #,##0.00';
ws.getColumn(10).numFmt = '"R$" #,##0.00';
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
ws.autoFilter = {
from: { row: 2, column: 1 },
to: { row: 2, column: ws.columns.length }
};
}
// ===== Aba: Itens =====
{
const ws = workbook.addWorksheet('Itens');
ws.columns = [
{ header: 'Pedido Nº SEI', key: 'pedidoNumeroSei', width: 28 },
{ header: 'Pedido Status', key: 'pedidoStatus', width: 18 },
{ header: 'Objeto', key: 'objeto', width: 45 },
{ header: 'Modalidade', key: 'modalidade', width: 16 },
{ header: 'Qtd', key: 'qtd', width: 10 },
{ header: 'Estimado (texto)', key: 'estimadoTxt', width: 18 },
{ header: 'Real (texto)', key: 'realTxt', width: 18 },
{ header: 'Adicionado por', key: 'adicionadoPor', width: 28 },
{ header: 'Ação', key: 'acao', width: 22 },
{ header: 'Ata', key: 'ata', width: 16 },
{ header: 'Criado em', key: 'criadoEm', width: 20 }
];
addTitleRow(ws, 'RELATÓRIO DE PEDIDOS — ITENS', ws.columns.length, workbook, logoBuffer);
const headerRow = ws.getRow(2);
headerRow.values = ws.columns.map((c) => c.header as string);
applyHeaderRowStyle(headerRow);
relatorio.itens.forEach((i, idx) => {
const row = ws.addRow({
pedidoNumeroSei: i.pedidoNumeroSei ?? '',
pedidoStatus: statusLabel(i.pedidoStatus),
objeto: i.objetoNome ?? String(i.objetoId),
modalidade: i.modalidade,
qtd: i.quantidade,
estimadoTxt: i.valorEstimado,
realTxt: i.valorReal ?? '',
adicionadoPor: i.adicionadoPorNome,
acao: i.acaoNome ?? '',
ata: i.ataNumero ?? '',
criadoEm: i.criadoEm ? new Date(i.criadoEm) : null
});
applyZebraRowStyle(row, idx % 2 === 1);
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(5).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(11).alignment = { vertical: 'middle', horizontal: 'center' };
});
ws.getColumn(11).numFmt = 'dd/mm/yyyy hh:mm';
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
ws.autoFilter = {
from: { row: 2, column: 1 },
to: { row: 2, column: ws.columns.length }
};
}
const nomeArquivo = `relatorio-pedidos-${new Date().toISOString().slice(0, 10)}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
downloadExcel(buffer, nomeArquivo);
}

View File

@@ -0,0 +1,211 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { FunctionReturnType } from 'convex/server';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
export type RelatorioPedidosData = FunctionReturnType<typeof api.pedidos.gerarRelatorio>;
function formatCurrencyBRL(n: number): string {
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function formatDateTime(ts: number | undefined): string {
if (!ts) return '-';
return new Date(ts).toLocaleString('pt-BR');
}
function getPeriodoLabel(filtros: RelatorioPedidosData['filtros']): string {
const inicio = filtros.periodoInicio ? formatDateTime(filtros.periodoInicio) : '—';
const fim = filtros.periodoFim ? formatDateTime(filtros.periodoFim) : '—';
return `${inicio} até ${fim}`;
}
export function gerarRelatorioPedidosPDF(relatorio: RelatorioPedidosData): void {
const doc = new jsPDF({ orientation: 'landscape' });
// Título
doc.setFontSize(18);
doc.setTextColor(102, 126, 234);
doc.text('Relatório de Pedidos', 14, 18);
// Subtítulo
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.text(`Período: ${getPeriodoLabel(relatorio.filtros)}`, 14, 26);
doc.setFontSize(9);
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 32);
let yPos = 40;
// Resumo
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text('Resumo', 14, yPos);
yPos += 6;
autoTable(doc, {
startY: yPos,
head: [['Pedidos', 'Itens', 'Documentos', 'Total Estimado', 'Total Real']],
body: [
[
String(relatorio.resumo.totalPedidos),
String(relatorio.resumo.totalItens),
String(relatorio.resumo.totalDocumentos),
formatCurrencyBRL(relatorio.resumo.totalValorEstimado),
formatCurrencyBRL(relatorio.resumo.totalValorReal)
]
],
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & { lastAutoTable?: { finalY: number } };
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 10;
// Totais por status
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text('Totais por status', 14, yPos);
yPos += 6;
autoTable(doc, {
startY: yPos,
head: [['Status', 'Quantidade']],
body: relatorio.resumo.totalPorStatus.map((s) => [s.status, String(s.count)]),
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 9 }
});
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12;
// Pedidos
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text('Pedidos', 14, yPos);
yPos += 6;
const pedidosBody = relatorio.pedidos.map((p) => [
p.numeroSei ?? '—',
p.status,
p.criadoPorNome,
p.aceitoPorNome ?? '—',
formatDateTime(p.criadoEm),
formatDateTime(p.concluidoEm),
String(p.itensCount),
String(p.documentosCount),
formatCurrencyBRL(p.valorEstimadoTotal),
formatCurrencyBRL(p.valorRealTotal)
]);
autoTable(doc, {
startY: yPos,
head: [
[
'Nº SEI',
'Status',
'Criado por',
'Aceito por',
'Criado em',
'Concluído em',
'Itens',
'Docs',
'Estimado',
'Real'
]
],
body: pedidosBody,
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 7 },
columnStyles: {
0: { cellWidth: 36 },
1: { cellWidth: 26 },
2: { cellWidth: 32 },
3: { cellWidth: 32 },
4: { cellWidth: 30 },
5: { cellWidth: 30 },
6: { cellWidth: 12 },
7: { cellWidth: 12 },
8: { cellWidth: 22 },
9: { cellWidth: 22 }
}
});
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12;
// Itens (limitado para evitar PDF gigantesco)
const itensMax = 1000;
const itensToPrint = relatorio.itens.slice(0, itensMax);
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text(
`Itens (${itensToPrint.length}${relatorio.itens.length > itensMax ? ` de ${relatorio.itens.length}` : ''})`,
14,
yPos
);
yPos += 6;
const itensBody = itensToPrint.map((i) => [
i.pedidoNumeroSei ?? '—',
i.pedidoStatus,
i.objetoNome ?? String(i.objetoId),
i.modalidade,
String(i.quantidade),
i.valorEstimado,
i.valorReal ?? '—',
i.adicionadoPorNome,
formatDateTime(i.criadoEm)
]);
autoTable(doc, {
startY: yPos,
head: [
[
'Nº SEI',
'Status',
'Objeto',
'Modalidade',
'Qtd',
'Estimado',
'Real',
'Adicionado por',
'Criado em'
]
],
body: itensBody,
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 7 },
columnStyles: {
0: { cellWidth: 34 },
1: { cellWidth: 22 },
2: { cellWidth: 55 },
3: { cellWidth: 24 },
4: { cellWidth: 10 },
5: { cellWidth: 22 },
6: { cellWidth: 22 },
7: { cellWidth: 32 },
8: { cellWidth: 28 }
}
});
// Footer com paginação
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 8,
{ align: 'center' }
);
}
const fileName = `relatorio-pedidos-${new Date().toISOString().slice(0, 10)}.pdf`;
doc.save(fileName);
}

View File

@@ -0,0 +1,302 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { FunctionReturnType } from 'convex/server';
import ExcelJS from 'exceljs';
import logoGovPE from '$lib/assets/logo_governo_PE.png';
export type RelatorioPlanejamentosData = FunctionReturnType<
typeof api.planejamentos.gerarRelatorio
>;
function formatDateTime(ts: number | undefined): string {
if (!ts) return '';
return new Date(ts).toLocaleString('pt-BR');
}
function formatDateYMD(ymd: string): string {
const [y, m, d] = ymd.split('-');
if (!y || !m || !d) return ymd;
return `${d}/${m}/${y}`;
}
function argb(hex: string): { argb: string } {
return { argb: hex.replace('#', '').toUpperCase().padStart(8, 'FF') };
}
function statusLabel(status: string): string {
switch (status) {
case 'rascunho':
return 'Rascunho';
case 'gerado':
return 'Gerado';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function applyHeaderRowStyle(row: ExcelJS.Row) {
row.height = 22;
row.eachCell((cell) => {
cell.font = { bold: true, size: 11, color: argb('#FFFFFF') };
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#2980B9') };
cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
cell.border = {
top: { style: 'thin', color: argb('#000000') },
bottom: { style: 'thin', color: argb('#000000') },
left: { style: 'thin', color: argb('#000000') },
right: { style: 'thin', color: argb('#000000') }
};
});
}
function applyZebraRowStyle(row: ExcelJS.Row, isEven: boolean) {
row.eachCell((cell) => {
cell.font = { size: 10, color: argb('#000000') };
cell.fill = {
type: 'pattern',
pattern: 'solid',
fgColor: argb(isEven ? '#F8F9FA' : '#FFFFFF')
};
cell.border = {
top: { style: 'thin', color: argb('#E0E0E0') },
bottom: { style: 'thin', color: argb('#E0E0E0') },
left: { style: 'thin', color: argb('#E0E0E0') },
right: { style: 'thin', color: argb('#E0E0E0') }
};
});
}
async function tryLoadLogoBuffer(): Promise<ArrayBuffer | null> {
try {
const response = await fetch(logoGovPE);
if (response.ok) return await response.arrayBuffer();
} catch {
// ignore
}
try {
const logoImg = new Image();
logoImg.crossOrigin = 'anonymous';
logoImg.src = logoGovPE;
await new Promise<void>((resolve, reject) => {
logoImg.onload = () => resolve();
logoImg.onerror = () => reject();
setTimeout(() => reject(), 3000);
});
const canvas = document.createElement('canvas');
canvas.width = logoImg.width;
canvas.height = logoImg.height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(logoImg, 0, 0);
const blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b) => (b ? resolve(b) : reject(new Error('Falha ao converter imagem'))),
'image/png'
);
});
return await blob.arrayBuffer();
} catch {
return null;
}
}
function addTitleRow(
worksheet: ExcelJS.Worksheet,
title: string,
columnsCount: number,
workbook: ExcelJS.Workbook,
logoBuffer: ArrayBuffer | null
) {
if (columnsCount <= 0) return;
worksheet.getRow(1).height = 60;
if (columnsCount >= 3) {
worksheet.mergeCells(1, 1, 1, 2);
}
const logoCell = worksheet.getCell(1, 1);
logoCell.alignment = { vertical: 'middle', horizontal: 'left' };
if (columnsCount >= 3) {
logoCell.border = { right: { style: 'thin', color: argb('#E0E0E0') } };
}
if (logoBuffer) {
type WorkbookWithImage = { addImage(image: { buffer: Uint8Array; extension: string }): number };
const workbookWithImage = workbook as unknown as WorkbookWithImage;
const logoId = workbookWithImage.addImage({
buffer: new Uint8Array(logoBuffer),
extension: 'png'
});
worksheet.addImage(logoId, { tl: { col: 0, row: 0 }, ext: { width: 140, height: 55 } });
}
if (columnsCount === 1) {
const titleCell = worksheet.getCell(1, 1);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
return;
}
if (columnsCount === 2) {
const titleCell = worksheet.getCell(1, 2);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
return;
}
worksheet.mergeCells(1, 3, 1, columnsCount);
const titleCell = worksheet.getCell(1, 3);
titleCell.value = title;
titleCell.font = { bold: true, size: 18, color: argb('#2980B9') };
titleCell.alignment = { vertical: 'middle', horizontal: 'center' };
}
function downloadExcel(buffer: ArrayBuffer, fileName: string) {
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
window.URL.revokeObjectURL(url);
}
export async function exportarRelatorioPlanejamentosXLSX(
relatorio: RelatorioPlanejamentosData
): Promise<void> {
const workbook = new ExcelJS.Workbook();
workbook.creator = 'SGSE - Sistema de Gerenciamento';
workbook.created = new Date();
const logoBuffer = await tryLoadLogoBuffer();
// ===== Aba: Resumo =====
{
const ws = workbook.addWorksheet('Resumo');
ws.columns = [
{ header: 'Campo', key: 'campo', width: 35 },
{ header: 'Valor', key: 'valor', width: 30 }
];
addTitleRow(ws, 'RELATÓRIO DE PLANEJAMENTOS', 2, workbook, logoBuffer);
const headerRow = ws.getRow(2);
headerRow.values = ['Campo', 'Valor'];
applyHeaderRowStyle(headerRow);
const rows: Array<{ campo: string; valor: string | number }> = [
{
campo: 'Período início',
valor: relatorio.filtros.periodoInicio
? formatDateTime(relatorio.filtros.periodoInicio)
: ''
},
{
campo: 'Período fim',
valor: relatorio.filtros.periodoFim ? formatDateTime(relatorio.filtros.periodoFim) : ''
},
{ campo: 'Texto (filtro)', valor: relatorio.filtros.texto ?? '' },
{
campo: 'Status (filtro)',
valor: relatorio.filtros.statuses?.map(statusLabel).join(', ') ?? ''
},
{ campo: 'Total de planejamentos', valor: relatorio.resumo.totalPlanejamentos },
{ campo: 'Total valor estimado', valor: relatorio.resumo.totalValorEstimado }
];
rows.forEach((r, idx) => {
const row = ws.addRow({ campo: r.campo, valor: r.valor });
applyZebraRowStyle(row, idx % 2 === 1);
row.getCell(2).alignment = {
vertical: 'middle',
horizontal: typeof r.valor === 'number' ? 'right' : 'left'
};
});
ws.addRow({ campo: '', valor: '' });
const sectionRow = ws.addRow({ campo: 'Totais por status', valor: '' });
sectionRow.font = { bold: true, size: 11, color: argb('#2980B9') };
sectionRow.alignment = { vertical: 'middle', horizontal: 'left' };
relatorio.resumo.totalPorStatus.forEach((s, idx) => {
const row = ws.addRow({ campo: statusLabel(s.status), valor: s.count });
applyZebraRowStyle(row, idx % 2 === 1);
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'right' };
});
ws.getColumn(2).numFmt = '#,##0.00';
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
}
// ===== Aba: Planejamentos =====
{
const ws = workbook.addWorksheet('Planejamentos');
ws.columns = [
{ header: 'Título', key: 'titulo', width: 40 },
{ header: 'Status', key: 'status', width: 15 },
{ header: 'Responsável', key: 'responsavel', width: 25 },
{ header: 'Data', key: 'data', width: 15 },
{ header: 'Ação', key: 'acao', width: 25 },
{ header: 'Itens', key: 'itens', width: 10 },
{ header: 'Estimado (R$)', key: 'estimado', width: 18 },
{ header: 'Criado em', key: 'criadoEm', width: 20 }
];
addTitleRow(ws, 'RELATÓRIO DE PLANEJAMENTOS — LISTA', ws.columns.length, workbook, logoBuffer);
const headerRow = ws.getRow(2);
headerRow.values = ws.columns.map((c) => c.header as string);
applyHeaderRowStyle(headerRow);
relatorio.planejamentos.forEach((p, idx) => {
const row = ws.addRow({
titulo: p.titulo,
status: statusLabel(p.status),
responsavel: p.responsavelNome,
data: formatDateYMD(p.data),
acao: p.acaoNome ?? '',
itens: p.itensCount,
estimado: p.valorEstimadoTotal,
criadoEm: new Date(p.criadoEm)
});
applyZebraRowStyle(row, idx % 2 === 1);
row.getCell(2).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(4).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(6).alignment = { vertical: 'middle', horizontal: 'center' };
row.getCell(7).alignment = { vertical: 'middle', horizontal: 'right' };
row.getCell(8).alignment = { vertical: 'middle', horizontal: 'center' };
const statusCell = row.getCell(2);
if (p.status === 'gerado') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#D4EDDA') };
statusCell.font = { size: 10, color: argb('#155724') };
} else if (p.status === 'cancelado') {
statusCell.fill = { type: 'pattern', pattern: 'solid', fgColor: argb('#F8D7DA') };
statusCell.font = { size: 10, color: argb('#721C24') };
}
});
ws.getColumn(7).numFmt = '"R$" #,##0.00';
ws.getColumn(8).numFmt = 'dd/mm/yyyy hh:mm';
ws.views = [{ state: 'frozen', ySplit: 2, topLeftCell: 'A3', activeCell: 'A3' }];
ws.autoFilter = {
from: { row: 2, column: 1 },
to: { row: 2, column: ws.columns.length }
};
}
const nomeArquivo = `relatorio-planejamentos-${new Date().toISOString().slice(0, 10)}.xlsx`;
const buffer = await workbook.xlsx.writeBuffer();
downloadExcel(buffer, nomeArquivo);
}

View File

@@ -0,0 +1,140 @@
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { FunctionReturnType } from 'convex/server';
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
export type RelatorioPlanejamentosData = FunctionReturnType<
typeof api.planejamentos.gerarRelatorio
>;
function formatCurrencyBRL(n: number): string {
return n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function formatDateTime(ts: number | undefined): string {
if (!ts) return '-';
return new Date(ts).toLocaleString('pt-BR');
}
function formatDateYMD(ymd: string): string {
const [y, m, d] = ymd.split('-');
if (!y || !m || !d) return ymd;
return `${d}/${m}/${y}`;
}
function getPeriodoLabel(filtros: RelatorioPlanejamentosData['filtros']): string {
const inicio = filtros.periodoInicio ? formatDateTime(filtros.periodoInicio) : '—';
const fim = filtros.periodoFim ? formatDateTime(filtros.periodoFim) : '—';
return `${inicio} até ${fim}`;
}
export function gerarRelatorioPlanejamentosPDF(relatorio: RelatorioPlanejamentosData): void {
const doc = new jsPDF({ orientation: 'landscape' });
// Título
doc.setFontSize(18);
doc.setTextColor(102, 126, 234);
doc.text('Relatório de Planejamentos', 14, 18);
// Subtítulo
doc.setFontSize(11);
doc.setTextColor(0, 0, 0);
doc.text(`Período: ${getPeriodoLabel(relatorio.filtros)}`, 14, 26);
doc.setFontSize(9);
doc.text(`Gerado em: ${new Date().toLocaleString('pt-BR')}`, 14, 32);
let yPos = 40;
// Resumo
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text('Resumo', 14, yPos);
yPos += 6;
autoTable(doc, {
startY: yPos,
head: [['Planejamentos', 'Total Estimado']],
body: [
[
String(relatorio.resumo.totalPlanejamentos),
formatCurrencyBRL(relatorio.resumo.totalValorEstimado)
]
],
theme: 'striped',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 9 }
});
type JsPDFWithAutoTable = jsPDF & { lastAutoTable?: { finalY: number } };
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 10;
// Totais por status
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text('Totais por status', 14, yPos);
yPos += 6;
autoTable(doc, {
startY: yPos,
head: [['Status', 'Quantidade']],
body: relatorio.resumo.totalPorStatus.map((s) => [s.status, String(s.count)]),
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 9 }
});
yPos = ((doc as JsPDFWithAutoTable).lastAutoTable?.finalY ?? yPos) + 12;
// Planejamentos
doc.setFontSize(12);
doc.setTextColor(102, 126, 234);
doc.text('Planejamentos', 14, yPos);
yPos += 6;
const planejamentosBody = relatorio.planejamentos.map((p) => [
p.titulo,
p.status,
p.responsavelNome,
p.acaoNome ?? '—',
formatDateYMD(p.data),
String(p.itensCount),
formatCurrencyBRL(p.valorEstimadoTotal),
formatDateTime(p.criadoEm)
]);
autoTable(doc, {
startY: yPos,
head: [['Título', 'Status', 'Responsável', 'Ação', 'Data', 'Itens', 'Estimado', 'Criado em']],
body: planejamentosBody,
theme: 'grid',
headStyles: { fillColor: [102, 126, 234] },
styles: { fontSize: 8 },
columnStyles: {
0: { cellWidth: 60 },
1: { cellWidth: 20 },
2: { cellWidth: 40 },
3: { cellWidth: 40 },
4: { cellWidth: 25 },
5: { cellWidth: 15 },
6: { cellWidth: 30 },
7: { cellWidth: 30 }
}
});
// Footer com paginação
const pageCount = doc.getNumberOfPages();
for (let i = 1; i <= pageCount; i++) {
doc.setPage(i);
doc.setFontSize(8);
doc.setTextColor(128, 128, 128);
doc.text(
`SGSE - Sistema de Gerenciamento de Secretaria | Página ${i} de ${pageCount}`,
doc.internal.pageSize.getWidth() / 2,
doc.internal.pageSize.getHeight() - 8,
{ align: 'center' }
);
}
const fileName = `relatorio-planejamentos-${new Date().toISOString().slice(0, 10)}.pdf`;
doc.save(fileName);
}

View File

@@ -1,21 +1,17 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit'; import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { error, redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
export const load = async ({ locals, url }) => { export const load = async ({ locals, url }) => {
if (!locals.token) { if (!locals.token) {
throw redirect(302, '/login?redirect=' + url.pathname); throw redirect(302, '/login?redirect=' + url.pathname);
} }
try {
const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (!currentUser) { const client = createConvexHttpClient({ token: locals.token });
throw redirect(302, '/login?redirect=' + url.pathname); const currentUser = await client.query(api.auth.getCurrentUser);
}
return { currentUser }; if (!currentUser) {
} catch { throw redirect(302, '/login?redirect=' + url.pathname);
return error(500, 'Ops! Ocorreu um erro, tente novamente mais tarde.');
} }
return { currentUser };
}; };

View File

@@ -4,11 +4,26 @@
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte'; import { Pencil, Plus, Trash2, X, Search, Check, FileText } from 'lucide-svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { formatarDataBR } from '$lib/utils/datas';
const client = useConvexClient(); const client = useConvexClient();
// Reactive queries // Reactive queries
const atasQuery = useQuery(api.atas.list, {}); // Filtros (listagem)
let filtroPeriodoInicio = $state('');
let filtroPeriodoFim = $state('');
let filtroNumero = $state('');
let filtroNumeroSei = $state('');
const atasTotalQuery = useQuery(api.atas.list, {});
let atasTotal = $derived(atasTotalQuery.data || []);
const atasQuery = useQuery(api.atas.list, () => ({
periodoInicio: filtroPeriodoInicio || undefined,
periodoFim: filtroPeriodoFim || undefined,
numero: filtroNumero.trim() || undefined,
numeroSei: filtroNumeroSei.trim() || undefined
}));
let atas = $derived(atasQuery.data || []); let atas = $derived(atasQuery.data || []);
let loadingAtas = $derived(atasQuery.isLoading); let loadingAtas = $derived(atasQuery.isLoading);
let errorAtas = $derived(atasQuery.error?.message || null); let errorAtas = $derived(atasQuery.error?.message || null);
@@ -27,9 +42,15 @@
numeroSei: '', numeroSei: '',
empresaId: '' as Id<'empresas'> | '', empresaId: '' as Id<'empresas'> | '',
dataInicio: '', dataInicio: '',
dataFim: '' dataFim: '',
dataProrrogacao: ''
}); });
let selectedObjetos = $state<Id<'objetos'>[]>([]); let selectedObjetos = $state<Id<'objetos'>[]>([]);
type ObjetoAtaConfig = {
quantidadeTotal: number | undefined;
limitePercentual: number | undefined;
};
let objetosConfig = $state<Record<string, ObjetoAtaConfig>>({});
let searchObjeto = $state(''); let searchObjeto = $state('');
let saving = $state(false); let saving = $state(false);
@@ -53,12 +74,22 @@
numeroSei: ata.numeroSei, numeroSei: ata.numeroSei,
empresaId: ata.empresaId, empresaId: ata.empresaId,
dataInicio: ata.dataInicio || '', dataInicio: ata.dataInicio || '',
dataFim: ata.dataFim || '' dataFim: ata.dataFim || '',
dataProrrogacao: ata.dataProrrogacao || ''
}; };
// Fetch linked objects // Fetch linked objects
const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id }); const linkedObjetos = await client.query(api.atas.getObjetos, { id: ata._id });
selectedObjetos = linkedObjetos.map((o) => o._id); selectedObjetos = linkedObjetos.map((o) => o._id);
const linkedConfigs = await client.query(api.atas.getObjetosConfig, { id: ata._id });
objetosConfig = {};
for (const cfg of linkedConfigs) {
objetosConfig[String(cfg.objetoId)] = {
quantidadeTotal: cfg.quantidadeTotal ?? undefined,
limitePercentual: cfg.limitePercentual ?? 50
};
}
// Fetch attachments // Fetch attachments
attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id }); attachments = await client.query(api.atas.getDocumentos, { ataId: ata._id });
} else { } else {
@@ -68,9 +99,11 @@
numeroSei: '', numeroSei: '',
empresaId: '', empresaId: '',
dataInicio: '', dataInicio: '',
dataFim: '' dataFim: '',
dataProrrogacao: ''
}; };
selectedObjetos = []; selectedObjetos = [];
objetosConfig = {};
attachments = []; attachments = [];
} }
attachmentFiles = []; attachmentFiles = [];
@@ -83,11 +116,22 @@
editingId = null; editingId = null;
} }
function getObjetoConfig(id: Id<'objetos'>): ObjetoAtaConfig {
const key = String(id);
if (!objetosConfig[key]) {
objetosConfig[key] = { quantidadeTotal: 1, limitePercentual: 50 };
}
return objetosConfig[key];
}
function toggleObjeto(id: Id<'objetos'>) { function toggleObjeto(id: Id<'objetos'>) {
const key = String(id);
if (selectedObjetos.includes(id)) { if (selectedObjetos.includes(id)) {
selectedObjetos = selectedObjetos.filter((oid) => oid !== id); selectedObjetos = selectedObjetos.filter((oid) => oid !== id);
delete objetosConfig[key];
} else { } else {
selectedObjetos = [...selectedObjetos, id]; selectedObjetos = [...selectedObjetos, id];
objetosConfig[key] = { quantidadeTotal: 1, limitePercentual: 50 };
} }
} }
@@ -110,13 +154,38 @@
} }
saving = true; saving = true;
try { try {
const objetos = selectedObjetos.map((objetoId) => {
const cfg = objetosConfig[String(objetoId)];
if (
!cfg ||
cfg.quantidadeTotal === undefined ||
!Number.isFinite(cfg.quantidadeTotal) ||
cfg.quantidadeTotal <= 0
) {
throw new Error(
'Informe a quantidade (maior que zero) para todos os objetos vinculados.'
);
}
const limitePercentual =
cfg.limitePercentual === undefined || !Number.isFinite(cfg.limitePercentual)
? 50
: cfg.limitePercentual;
return {
objetoId,
quantidadeTotal: cfg.quantidadeTotal,
limitePercentual
};
});
const payload = { const payload = {
numero: formData.numero, numero: formData.numero,
numeroSei: formData.numeroSei, numeroSei: formData.numeroSei,
empresaId: formData.empresaId as Id<'empresas'>, empresaId: formData.empresaId as Id<'empresas'>,
dataInicio: formData.dataInicio || undefined, dataInicio: formData.dataInicio || undefined,
dataFim: formData.dataFim || undefined, dataFim: formData.dataFim || undefined,
objetosIds: selectedObjetos dataProrrogacao: formData.dataProrrogacao || undefined,
objetos
}; };
let ataId: Id<'atas'>; let ataId: Id<'atas'>;
@@ -188,6 +257,13 @@
attachmentFiles = Array.from(input.files); attachmentFiles = Array.from(input.files);
} }
} }
function limparFiltros() {
filtroPeriodoInicio = '';
filtroPeriodoFim = '';
filtroNumero = '';
filtroNumeroSei = '';
}
</script> </script>
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4"> <main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
@@ -220,6 +296,71 @@
</div> </div>
</div> </div>
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-4">
<div class="form-control w-full">
<label class="label" for="filtro_numero">
<span class="label-text font-semibold">Número</span>
</label>
<input
id="filtro_numero"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Ex.: 12/2025"
bind:value={filtroNumero}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_numeroSei">
<span class="label-text font-semibold">Número SEI</span>
</label>
<input
id="filtro_numeroSei"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Ex.: 12345.000000/2025-00"
bind:value={filtroNumeroSei}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_inicio">
<span class="label-text font-semibold">Período (início)</span>
</label>
<input
id="filtro_inicio"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroPeriodoInicio}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_fim">
<span class="label-text font-semibold">Período (fim)</span>
</label>
<input
id="filtro_fim"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroPeriodoFim}
/>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">
{atas.length} de {atasTotal.length}
</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</div>
</div>
{#if loadingAtas} {#if loadingAtas}
<div class="flex items-center justify-center py-10"> <div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
@@ -233,7 +374,7 @@
<div class="card-body p-0"> <div class="card-body p-0">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table-zebra table w-full"> <table class="table-zebra table w-full">
<thead class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md"> <thead>
<tr> <tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap" <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Número</th >Número</th
@@ -258,8 +399,13 @@
<tr> <tr>
<td colspan="5" class="py-12 text-center"> <td colspan="5" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2"> <div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhuma ata cadastrada</p> {#if atasTotal.length === 0}
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p> <p class="text-lg font-semibold">Nenhuma ata cadastrada</p>
<p class="text-sm">Clique em “Nova Ata” para cadastrar.</p>
{:else}
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
{/if}
</div> </div>
</td> </td>
</tr> </tr>
@@ -275,7 +421,13 @@
{getEmpresaNome(ata.empresaId)} {getEmpresaNome(ata.empresaId)}
</td> </td>
<td class="text-base-content/70 whitespace-nowrap"> <td class="text-base-content/70 whitespace-nowrap">
{ata.dataInicio || '-'} a {ata.dataFim || '-'} {ata.dataInicio ? formatarDataBR(ata.dataInicio) : '-'} a
{ata.dataFim ? formatarDataBR(ata.dataFim) : '-'}
{#if ata.dataProrrogacao}
<span class="text-base-content/50">
(prorrogação: {formatarDataBR(ata.dataProrrogacao)})</span
>
{/if}
</td> </td>
<td class="text-right whitespace-nowrap"> <td class="text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1"> <div class="flex items-center justify-end gap-1">
@@ -367,7 +519,7 @@
</select> </select>
</div> </div>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full"> <div class="form-control w-full">
<label class="label" for="dataInicio"> <label class="label" for="dataInicio">
<span class="label-text font-semibold">Data Início</span> <span class="label-text font-semibold">Data Início</span>
@@ -390,6 +542,17 @@
bind:value={formData.dataFim} bind:value={formData.dataFim}
/> />
</div> </div>
<div class="form-control w-full">
<label class="label" for="dataProrrogacao">
<span class="label-text font-semibold">Data Prorrogação</span>
</label>
<input
id="dataProrrogacao"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={formData.dataProrrogacao}
/>
</div>
</div> </div>
</div> </div>
@@ -446,6 +609,54 @@
{/if} {/if}
</div> </div>
{#if selectedObjetos.length > 0}
<div class="border-base-300 border-t pt-4">
<div class="font-semibold">Configuração por objeto</div>
<p class="text-base-content/60 mt-1 text-xs">
Defina a quantidade total do objeto na ata e o limite de uso em % (padrão 50%).
</p>
<div class="mt-3 max-h-52 space-y-3 overflow-y-auto">
{#each selectedObjetos as objetoId (objetoId)}
{@const obj = objetos.find((o) => o._id === objetoId)}
{@const cfg = getObjetoConfig(objetoId)}
<div class="border-base-300 rounded-lg border p-3">
<div class="text-sm font-semibold">{obj?.nome || 'Objeto'}</div>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for={`qtd_${objetoId}`}>
<span class="label-text text-xs font-semibold">Quantidade na ata</span
>
</label>
<input
id={`qtd_${objetoId}`}
class="input input-bordered input-sm focus:input-primary w-full"
type="number"
min="1"
required
bind:value={cfg.quantidadeTotal}
/>
</div>
<div class="form-control w-full">
<label class="label" for={`limite_${objetoId}`}>
<span class="label-text text-xs font-semibold">Limite (%)</span>
</label>
<input
id={`limite_${objetoId}`}
class="input input-bordered input-sm focus:input-primary w-full"
type="number"
min="0"
max="100"
placeholder="50"
bind:value={cfg.limitePercentual}
/>
</div>
</div>
</div>
{/each}
</div>
</div>
{/if}
<div class="border-base-300 border-t pt-4"> <div class="border-base-300 border-t pt-4">
<div class="font-semibold">Anexos</div> <div class="font-semibold">Anexos</div>
<div class="mt-2 space-y-2"> <div class="mt-2 space-y-2">

View File

@@ -9,7 +9,19 @@
const client = useConvexClient(); const client = useConvexClient();
// Reactive queries // Reactive queries
const objetosQuery = useQuery(api.objetos.list, {}); // Filtros (listagem)
let filtroNome = $state('');
let filtroTipo = $state<'todos' | 'material' | 'servico'>('todos');
let filtroCodigos = $state('');
const objetosTotalQuery = useQuery(api.objetos.list, {});
let objetosTotal = $derived(objetosTotalQuery.data || []);
const objetosQuery = useQuery(api.objetos.list, () => ({
nome: filtroNome.trim() || undefined,
tipo: filtroTipo === 'todos' ? undefined : filtroTipo,
codigos: filtroCodigos.trim() || undefined
}));
let objetos = $derived(objetosQuery.data || []); let objetos = $derived(objetosQuery.data || []);
let loading = $derived(objetosQuery.isLoading); let loading = $derived(objetosQuery.isLoading);
let error = $derived(objetosQuery.error?.message || null); let error = $derived(objetosQuery.error?.message || null);
@@ -115,6 +127,12 @@
formData.atas = [...formData.atas, ataId]; formData.atas = [...formData.atas, ataId];
} }
} }
function limparFiltros() {
filtroNome = '';
filtroTipo = 'todos';
filtroCodigos = '';
}
</script> </script>
<main class="container mx-auto flex max-w-7xl flex-col px-4 py-4"> <main class="container mx-auto flex max-w-7xl flex-col px-4 py-4">
@@ -147,6 +165,62 @@
</div> </div>
</div> </div>
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full">
<label class="label" for="filtro_nome">
<span class="label-text font-semibold">Nome</span>
</label>
<input
id="filtro_nome"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Digite para filtrar..."
bind:value={filtroNome}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_tipo">
<span class="label-text font-semibold">Tipo</span>
</label>
<select
id="filtro_tipo"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroTipo}
>
<option value="todos">Todos</option>
<option value="material">Material</option>
<option value="servico">Serviço</option>
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_codigos">
<span class="label-text font-semibold">Códigos</span>
</label>
<input
id="filtro_codigos"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Efisco / Catmat / Catserv"
bind:value={filtroCodigos}
/>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">
{objetos.length} de {objetosTotal.length}
</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</div>
</div>
{#if loading} {#if loading}
<div class="flex items-center justify-center py-10"> <div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span> <span class="loading loading-spinner loading-lg"></span>
@@ -160,7 +234,7 @@
<div class="card-body p-0"> <div class="card-body p-0">
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="table-zebra table w-full"> <table class="table-zebra table w-full">
<thead class="from-base-300 to-base-200 sticky top-0 z-10 bg-linear-to-r shadow-md"> <thead>
<tr> <tr>
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap" <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Nome</th >Nome</th
@@ -185,8 +259,13 @@
<tr> <tr>
<td colspan="5" class="py-12 text-center"> <td colspan="5" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2"> <div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhum objeto cadastrado</p> {#if objetosTotal.length === 0}
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p> <p class="text-lg font-semibold">Nenhum objeto cadastrado</p>
<p class="text-sm">Clique em “Novo Objeto” para cadastrar.</p>
{:else}
<p class="text-lg font-semibold">Nenhum resultado encontrado</p>
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
{/if}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -3,50 +3,52 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
const client = useConvexClient(); const client = useConvexClient();
// Estado do filtro // Estado dos filtros
let statusFilter = $state<'draft' | 'published' | 'archived' | undefined>(undefined); let statusFilter = $state<'active' | 'completed' | 'cancelled' | undefined>(undefined);
// Query de templates // Query de instâncias
const templatesQuery = useQuery(api.flows.listTemplates, () => const instancesQuery = useQuery(api.flows.listInstances, () =>
statusFilter ? { status: statusFilter } : {} statusFilter ? { status: statusFilter } : {}
); );
// Query de templates publicados (para o modal de criação)
const publishedTemplatesQuery = useQuery(api.flows.listTemplates, {
status: 'published'
});
// Modal de criação // Modal de criação
let showCreateModal = $state(false); let showCreateModal = $state(false);
let newTemplateName = $state(''); let selectedTemplateId = $state<Id<'flowTemplates'> | ''>('');
let newTemplateDescription = $state(''); let contratoId = $state<Id<'contratos'> | ''>('');
let managerId = $state<Id<'usuarios'> | ''>('');
let isCreating = $state(false); let isCreating = $state(false);
let createError = $state<string | null>(null); let createError = $state<string | null>(null);
// Modal de confirmação de exclusão // Query de usuários (para seleção de gerente)
let showDeleteModal = $state(false); const usuariosQuery = useQuery(api.usuarios.listar, {});
let templateToDelete = $state<{
_id: Id<'flowTemplates'>; // Query de contratos (para seleção)
name: string; const contratosQuery = useQuery(api.contratos.listar, {});
} | null>(null);
let isDeleting = $state(false);
let deleteError = $state<string | null>(null);
function openCreateModal() { function openCreateModal() {
newTemplateName = ''; selectedTemplateId = '';
newTemplateDescription = ''; contratoId = '';
managerId = '';
createError = null; createError = null;
showCreateModal = true; showCreateModal = true;
} }
function closeCreateModal() { function closeCreateModal() {
showCreateModal = false; showCreateModal = false;
newTemplateName = '';
newTemplateDescription = '';
createError = null;
} }
async function handleCreate() { async function handleCreate() {
if (!newTemplateName.trim()) { if (!selectedTemplateId || !managerId) {
createError = 'O nome é obrigatório'; createError = 'Template e gerente são obrigatórios';
return; return;
} }
@@ -54,72 +56,28 @@
createError = null; createError = null;
try { try {
const templateId = await client.mutation(api.flows.createTemplate, { const instanceId = await client.mutation(api.flows.instantiateFlow, {
name: newTemplateName.trim(), flowTemplateId: selectedTemplateId as Id<'flowTemplates'>,
description: newTemplateDescription.trim() || undefined contratoId: contratoId ? (contratoId as Id<'contratos'>) : undefined,
managerId: managerId as Id<'usuarios'>
}); });
closeCreateModal(); closeCreateModal();
// Navegar para o editor goto(resolve(`/fluxos/instancias/${instanceId}`));
goto(`/fluxos/${templateId}/editor`);
} catch (e) { } catch (e) {
createError = e instanceof Error ? e.message : 'Erro ao criar template'; createError = e instanceof Error ? e.message : 'Erro ao criar instância';
} finally { } finally {
isCreating = false; isCreating = false;
} }
} }
function openDeleteModal(template: { _id: Id<'flowTemplates'>; name: string }) { function getStatusBadge(status: 'active' | 'completed' | 'cancelled') {
templateToDelete = template;
deleteError = null;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
templateToDelete = null;
deleteError = null;
}
async function handleDelete() {
if (!templateToDelete) return;
isDeleting = true;
deleteError = null;
try {
await client.mutation(api.flows.deleteTemplate, {
id: templateToDelete._id
});
closeDeleteModal();
} catch (e) {
deleteError = e instanceof Error ? e.message : 'Erro ao excluir template';
} finally {
isDeleting = false;
}
}
async function handleStatusChange(
templateId: Id<'flowTemplates'>,
newStatus: 'draft' | 'published' | 'archived'
) {
try {
await client.mutation(api.flows.updateTemplate, {
id: templateId,
status: newStatus
});
} catch (e) {
console.error('Erro ao atualizar status:', e);
}
}
function getStatusBadge(status: 'draft' | 'published' | 'archived') {
switch (status) { switch (status) {
case 'draft': case 'active':
return { class: 'badge-warning', label: 'Rascunho' }; return { class: 'badge-info', label: 'Em Andamento' };
case 'published': case 'completed':
return { class: 'badge-success', label: 'Publicado' }; return { class: 'badge-success', label: 'Concluído' };
case 'archived': case 'cancelled':
return { class: 'badge-neutral', label: 'Arquivado' }; return { class: 'badge-error', label: 'Cancelado' };
} }
} }
@@ -127,43 +85,70 @@
return new Date(timestamp).toLocaleDateString('pt-BR', { return new Date(timestamp).toLocaleDateString('pt-BR', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric' year: 'numeric',
hour: '2-digit',
minute: '2-digit'
}); });
} }
function getProgressPercentage(completed: number, total: number): number {
if (total === 0) return 0;
return Math.round((completed / total) * 100);
}
</script> </script>
<main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10"> <main class="mx-auto w-full max-w-7xl space-y-8 px-4 py-10">
<!-- Header --> <!-- Header -->
<section <section
class="border-secondary/25 from-secondary/10 via-base-100 to-primary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl" class="border-info/25 from-info/10 via-base-100 to-secondary/20 relative overflow-hidden rounded-3xl border bg-linear-to-br p-8 shadow-2xl"
> >
<div class="bg-secondary/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div> <div class="bg-info/20 absolute top-10 -left-10 h-40 w-40 rounded-full blur-3xl"></div>
<div class="bg-primary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div> <div class="bg-secondary/20 absolute right-0 -bottom-16 h-56 w-56 rounded-full blur-3xl"></div>
<div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between"> <div class="relative z-10 flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
<div class="max-w-3xl space-y-4"> <div class="max-w-3xl space-y-4">
<span <div class="flex items-center gap-4">
class="border-secondary/40 bg-secondary/10 text-secondary inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase" <a href={resolve('/fluxos/templates')} class="btn btn-ghost btn-sm">
> <svg
Gestão de Fluxos xmlns="http://www.w3.org/2000/svg"
</span> class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"
/>
</svg>
Templates
</a>
<span
class="border-info/40 bg-info/10 text-info inline-flex w-fit items-center gap-2 rounded-full border px-4 py-1 text-xs font-semibold tracking-[0.28em] uppercase"
>
Execução
</span>
</div>
<h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl"> <h1 class="text-base-content text-4xl leading-tight font-black sm:text-5xl">
Templates de Fluxo Instâncias de Fluxo
</h1> </h1>
<p class="text-base-content/70 text-base leading-relaxed sm:text-lg"> <p class="text-base-content/70 text-base leading-relaxed sm:text-lg">
Crie e gerencie templates de fluxo de trabalho. Templates definem os passos e Acompanhe e gerencie as execuções de fluxos de trabalho. Visualize o progresso, documentos
responsabilidades que serão instanciados para projetos ou contratos. e responsáveis de cada etapa.
</p> </p>
</div> </div>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<!-- Filtro de status --> <!-- Filtro de status -->
<select class="select select-bordered" bind:value={statusFilter}> <select class="select select-bordered" bind:value={statusFilter}>
<option value={undefined}>Todos os status</option> <option value={undefined}>Todos os status</option>
<option value="draft">Rascunho</option> <option value="active">Em Andamento</option>
<option value="published">Publicado</option> <option value="completed">Concluído</option>
<option value="archived">Arquivado</option> <option value="cancelled">Cancelado</option>
</select> </select>
<button class="btn btn-secondary shadow-lg" onclick={openCreateModal}> <button class="btn btn-info shadow-lg" onclick={openCreateModal}>
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5" class="h-5 w-5"
@@ -179,19 +164,19 @@
d="M12 4v16m8-8H4" d="M12 4v16m8-8H4"
/> />
</svg> </svg>
Novo Template Nova Instância
</button> </button>
</div> </div>
</div> </div>
</section> </section>
<!-- Lista de Templates --> <!-- Lista de Instâncias -->
<section class="bg-base-100 rounded-2xl border p-6 shadow-lg"> <section class="bg-base-100 rounded-2xl border p-6 shadow-lg">
{#if templatesQuery.isLoading} {#if instancesQuery.isLoading}
<div class="flex items-center justify-center py-12"> <div class="flex items-center justify-center py-12">
<span class="loading loading-spinner loading-lg text-secondary"></span> <span class="loading loading-spinner loading-lg text-info"></span>
</div> </div>
{:else if !templatesQuery.data || templatesQuery.data.length === 0} {:else if !instancesQuery.data || instancesQuery.data.length === 0}
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex flex-col items-center justify-center py-12 text-center">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
@@ -205,77 +190,69 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="1.5" stroke-width="1.5"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/> />
</svg> </svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Nenhum template encontrado</h3> <h3 class="text-base-content/70 mt-4 text-lg font-semibold">
Nenhuma instância encontrada
</h3>
<p class="text-base-content/50 mt-2"> <p class="text-base-content/50 mt-2">
{statusFilter {statusFilter
? 'Não há templates com este status.' ? 'Não há instâncias com este status.'
: 'Clique em "Novo Template" para criar o primeiro.'} : 'Clique em "Nova Instância" para iniciar um fluxo.'}
</p> </p>
</div> </div>
{:else} {:else}
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div class="overflow-x-auto">
{#each templatesQuery.data as template (template._id)} <table class="table w-full">
{@const statusBadge = getStatusBadge(template.status)} <thead>
<article <tr>
class="card bg-base-200/50 hover:bg-base-200 border transition-all duration-200 hover:shadow-md" <th>Template</th>
> <th>Contrato</th>
<div class="card-body"> <th>Gerente</th>
<div class="flex items-start justify-between gap-2"> <th>Progresso</th>
<h2 class="card-title text-lg">{template.name}</h2> <th>Status</th>
<span class="badge {statusBadge.class}">{statusBadge.label}</span> <th>Iniciado em</th>
</div> <th class="text-right">Ações</th>
</tr>
{#if template.description} </thead>
<p class="text-base-content/60 line-clamp-2 text-sm"> <tbody>
{template.description} {#each instancesQuery.data as instance (instance._id)}
</p> {@const statusBadge = getStatusBadge(instance.status)}
{/if} {@const progressPercent = getProgressPercentage(
instance.progress.completed,
<div class="text-base-content/50 mt-2 flex flex-wrap gap-4 text-xs"> instance.progress.total
<span class="flex items-center gap-1"> )}
<svg <tr class="hover">
xmlns="http://www.w3.org/2000/svg" <td>
class="h-4 w-4" <div class="font-medium">{instance.templateName ?? 'Template desconhecido'}</div>
fill="none" </td>
viewBox="0 0 24 24" <td>
stroke="currentColor" {#if instance.contratoId}
aria-hidden="true" <span class="badge badge-outline badge-sm">{instance.contratoId}</span>
{:else}
<span class="text-base-content/40 text-sm">-</span>
{/if}
</td>
<td class="text-base-content/70">{instance.managerName ?? '-'}</td>
<td>
<div class="flex items-center gap-2">
<progress class="progress progress-info w-20" value={progressPercent} max="100"
></progress>
<span class="text-base-content/60 text-xs">
{instance.progress.completed}/{instance.progress.total}
</span>
</div>
</td>
<td>
<span class="badge {statusBadge.class}">{statusBadge.label}</span>
</td>
<td class="text-base-content/60 text-sm">{formatDate(instance.startedAt)}</td>
<td class="text-right">
<a
href={resolve(`/fluxos/instancias/${instance._id}`)}
class="btn btn-ghost btn-sm"
> >
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
{template.stepsCount} passos
</span>
<span class="flex items-center gap-1">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
{formatDate(template.createdAt)}
</span>
</div>
<div class="card-actions mt-4 justify-between">
<div class="dropdown">
<button class="btn btn-ghost btn-sm" aria-label="Alterar status">
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4" class="h-4 w-4"
@@ -288,97 +265,32 @@
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="2"
d="M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/> />
</svg> </svg>
</button> Ver
<ul </a>
class="dropdown-content menu bg-base-100 rounded-box z-10 w-52 p-2 shadow" </td>
role="menu" </tr>
> {/each}
{#if template.status !== 'draft'} </tbody>
<li> </table>
<button onclick={() => handleStatusChange(template._id, 'draft')}>
Voltar para Rascunho
</button>
</li>
{/if}
{#if template.status !== 'published'}
<li>
<button onclick={() => handleStatusChange(template._id, 'published')}>
Publicar
</button>
</li>
{/if}
{#if template.status !== 'archived'}
<li>
<button onclick={() => handleStatusChange(template._id, 'archived')}>
Arquivar
</button>
</li>
{/if}
<li class="mt-2 border-t pt-2">
<button class="text-error" onclick={() => openDeleteModal(template)}>
Excluir
</button>
</li>
</ul>
</div>
<a href="/fluxos/{template._id}/editor" class="btn btn-secondary btn-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
Editar
</a>
</div>
</div>
</article>
{/each}
</div> </div>
{/if} {/if}
</section> </section>
<!-- Link para Instâncias -->
<section class="flex justify-center">
<a href="/licitacoes/fluxos" class="btn btn-outline btn-lg">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"
/>
</svg>
Ver Fluxos de Trabalho
</a>
</section>
</main> </main>
<!-- Modal de Criação --> <!-- Modal de Criação -->
{#if showCreateModal} {#if showCreateModal}
<div class="modal modal-open"> <div class="modal modal-open">
<div class="modal-box"> <div class="modal-box max-w-2xl">
<h3 class="text-lg font-bold">Novo Template de Fluxo</h3> <h3 class="text-lg font-bold">Nova Instância de Fluxo</h3>
{#if createError} {#if createError}
<div class="alert alert-error mt-4"> <div class="alert alert-error mt-4">
@@ -408,41 +320,82 @@
class="mt-4 space-y-4" class="mt-4 space-y-4"
> >
<div class="form-control"> <div class="form-control">
<label class="label" for="template-name"> <label class="label" for="template-select">
<span class="label-text">Nome do Template</span> <span class="label-text">Template de Fluxo</span>
</label> </label>
<input <select
type="text" id="template-select"
id="template-name" bind:value={selectedTemplateId}
bind:value={newTemplateName} class="select select-bordered w-full"
class="input input-bordered w-full"
placeholder="Ex: Fluxo de Aprovação de Contrato"
required required
/> >
<option value="">Selecione um template</option>
{#if publishedTemplatesQuery.data}
{#each publishedTemplatesQuery.data as template (template._id)}
<option value={template._id}>{template.name}</option>
{/each}
{/if}
</select>
<p class="label">
<span class="label-text-alt text-base-content/60"
>Apenas templates publicados podem ser instanciados</span
>
</p>
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label" for="template-description"> <label class="label" for="contrato-select">
<span class="label-text">Descrição (opcional)</span> <span class="label-text">Contrato (Opcional)</span>
</label> </label>
<textarea <select
id="template-description" id="contrato-select"
bind:value={newTemplateDescription} bind:value={contratoId}
class="textarea textarea-bordered w-full" class="select select-bordered w-full"
placeholder="Descreva o propósito deste fluxo..." >
rows="3" <option value="">Nenhum contrato</option>
></textarea> {#if contratosQuery.data}
{#each contratosQuery.data as contrato (contrato._id)}
<option value={contrato._id}>
{contrato.numeroContrato}/{contrato.anoContrato}
</option>
{/each}
{/if}
</select>
<p class="label">
<span class="label-text-alt text-base-content/60"
>Opcional: vincule este fluxo a um contrato específico</span
>
</p>
</div>
<div class="form-control">
<label class="label" for="manager-select">
<span class="label-text">Gerente Responsável</span>
</label>
<select
id="manager-select"
bind:value={managerId}
class="select select-bordered w-full"
required
>
<option value="">Selecione um gerente</option>
{#if usuariosQuery.data}
{#each usuariosQuery.data as usuario (usuario._id)}
<option value={usuario._id}>{usuario.nome}</option>
{/each}
{/if}
</select>
</div> </div>
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}> <button type="button" class="btn" onclick={closeCreateModal} disabled={isCreating}>
Cancelar Cancelar
</button> </button>
<button type="submit" class="btn btn-secondary" disabled={isCreating}> <button type="submit" class="btn btn-info" disabled={isCreating}>
{#if isCreating} {#if isCreating}
<span class="loading loading-spinner loading-sm"></span> <span class="loading loading-spinner loading-sm"></span>
{/if} {/if}
Criar e Editar Iniciar Fluxo
</button> </button>
</div> </div>
</form> </form>
@@ -455,56 +408,3 @@
></button> ></button>
</div> </div>
{/if} {/if}
<!-- Modal de Confirmação de Exclusão -->
{#if showDeleteModal && templateToDelete}
<div class="modal modal-open">
<div class="modal-box">
<h3 class="text-error text-lg font-bold">Confirmar Exclusão</h3>
{#if deleteError}
<div class="alert alert-error mt-4">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 shrink-0 stroke-current"
fill="none"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{deleteError}</span>
</div>
{/if}
<p class="py-4">
Tem certeza que deseja excluir o template <strong>{templateToDelete.name}</strong>?
</p>
<p class="text-base-content/60 text-sm">
Esta ação não pode ser desfeita. Templates com instâncias vinculadas não podem ser
excluídos.
</p>
<div class="modal-action">
<button class="btn" onclick={closeDeleteModal} disabled={isDeleting}> Cancelar </button>
<button class="btn btn-error" onclick={handleDelete} disabled={isDeleting}>
{#if isDeleting}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Excluir
</button>
</div>
</div>
<button
type="button"
class="modal-backdrop"
onclick={closeDeleteModal}
aria-label="Fechar modal"
></button>
</div>
{/if}

View File

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

View File

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

View File

@@ -508,9 +508,7 @@
/> />
</svg> </svg>
<h3 class="text-base-content/70 mt-4 text-lg font-semibold">Fluxo não encontrado</h3> <h3 class="text-base-content/70 mt-4 text-lg font-semibold">Fluxo não encontrado</h3>
<a href={resolve('/(dashboard)/licitacoes/fluxos')} class="btn btn-ghost mt-4" <a href={resolve('/fluxos')} class="btn btn-ghost mt-4">Voltar para lista</a>
>Voltar para lista</a
>
</div> </div>
{:else} {:else}
{@const instance = instanceQuery.data.instance} {@const instance = instanceQuery.data.instance}
@@ -534,7 +532,7 @@
if (typeof window !== 'undefined' && window.history.length > 1) { if (typeof window !== 'undefined' && window.history.length > 1) {
window.history.back(); window.history.back();
} else { } else {
goto(resolve('/licitacoes/fluxos')); goto(resolve('/fluxos'));
} }
}} }}
aria-label="Voltar para página anterior" aria-label="Voltar para página anterior"

View File

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

View File

@@ -105,7 +105,7 @@
<!-- Grid de Opções --> <!-- Grid de Opções -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<a <a
href={resolve('/licitacoes/fluxos')} href={resolve('/fluxos')}
class="group border-base-300 from-secondary/10 to-secondary/20 hover:border-secondary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" class="group border-base-300 from-secondary/10 to-secondary/20 hover:border-secondary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
> >
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">
@@ -135,7 +135,7 @@
</a> </a>
<a <a
href={resolve('/fluxos')} href={resolve('/fluxos/templates')}
class="group border-base-300 from-secondary/10 to-secondary/20 hover:border-secondary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg" class="group border-base-300 from-secondary/10 to-secondary/20 hover:border-secondary relative transform overflow-hidden rounded-xl border-2 bg-linear-to-br p-6 transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
> >
<div class="flex h-full flex-col"> <div class="flex h-full flex-col">

View File

@@ -7,7 +7,16 @@
import { maskCEP, maskCNPJ, maskPhone, maskUF, onlyDigits } from '$lib/utils/masks'; import { maskCEP, maskCNPJ, maskPhone, maskUF, onlyDigits } from '$lib/utils/masks';
const client = useConvexClient(); const client = useConvexClient();
const empresasQuery = useQuery(api.empresas.list, {}); let filtroEmpresa = $state('');
const empresasTotalQuery = useQuery(api.empresas.list, {});
const empresasQuery = useQuery(api.empresas.list, () => ({
query: filtroEmpresa.trim() || undefined
}));
function limparFiltroEmpresa() {
filtroEmpresa = '';
}
let modalAberto = $state(false); let modalAberto = $state(false);
@@ -425,6 +434,32 @@
</button> </button>
</div> </div>
<div class="card bg-base-100/90 border-base-300 mb-6 border shadow-xl backdrop-blur-sm">
<div class="card-body">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="form-control w-full">
<label class="label" for="filtro_empresa">
<span class="label-text font-semibold">Buscar empresa</span>
</label>
<input
id="filtro_empresa"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Nome fantasia, razão social ou CNPJ"
bind:value={filtroEmpresa}
/>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">
{empresasQuery.data?.length ?? 0} de {empresasTotalQuery.data?.length ?? 0}
</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltroEmpresa}>Limpar</button>
</div>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl"> <div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
{#if empresasQuery.isLoading} {#if empresasQuery.isLoading}
@@ -437,11 +472,21 @@
</div> </div>
{:else if empresasQuery.data && empresasQuery.data.length === 0} {:else if empresasQuery.data && empresasQuery.data.length === 0}
<div class="py-10 text-center"> <div class="py-10 text-center">
<p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p> {#if (empresasTotalQuery.data?.length ?? 0) === 0}
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}> <p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p>
<Plus class="h-4 w-4" strokeWidth={2} /> <button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
Cadastrar primeira empresa <Plus class="h-4 w-4" strokeWidth={2} />
</button> Cadastrar primeira empresa
</button>
{:else}
<p class="text-base-content/70 mb-2">Nenhum resultado encontrado.</p>
<p class="text-base-content/60 mb-4 text-sm">
Ajuste ou limpe o filtro para ver empresas.
</p>
<button type="button" class="btn btn-ghost" onclick={limparFiltroEmpresa}>
Limpar filtro
</button>
{/if}
</div> </div>
{:else if empresasQuery.data} {:else if empresasQuery.data}
<div class="overflow-x-auto"> <div class="overflow-x-auto">

View File

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

View File

@@ -1,12 +1,124 @@
<script lang="ts"> <script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { useQuery } from 'convex-svelte'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { Eye, Plus } from 'lucide-svelte'; import { exportarRelatorioPedidosXLSX } from '$lib/utils/pedidos/relatorioPedidosExcel';
import { gerarRelatorioPedidosPDF } from '$lib/utils/pedidos/relatorioPedidosPDF';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import TableCard from '$lib/components/ui/TableCard.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { endOfDay, startOfDay } from 'date-fns';
import { Eye, FileText, Plus } from 'lucide-svelte';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
const client = useConvexClient();
const statusOptions = [
{ value: 'em_rascunho', label: 'Rascunho' },
{ value: 'aguardando_aceite', label: 'Aguardando Aceite' },
{ value: 'em_analise', label: 'Em Análise' },
{ value: 'precisa_ajustes', label: 'Precisa de Ajustes' },
{ value: 'concluido', label: 'Concluído' },
{ value: 'cancelado', label: 'Cancelado' }
] as const;
type PedidoStatus = (typeof statusOptions)[number]['value'];
// Filtros (cumulativos / backend)
let filtroNumeroSei = $state('');
let filtroCriadoPor = $state<Id<'usuarios'> | ''>('');
let filtroAceitoPor = $state<Id<'funcionarios'> | ''>('');
let filtroInicio = $state(''); // yyyy-MM-dd
let filtroFim = $state(''); // yyyy-MM-dd
let statusSelected = $state<Record<PedidoStatus, boolean>>({
em_rascunho: false,
aguardando_aceite: false,
em_analise: false,
precisa_ajustes: false,
concluido: false,
cancelado: false
});
function getSelectedStatuses(): PedidoStatus[] | undefined {
const selected = (Object.entries(statusSelected) as Array<[PedidoStatus, boolean]>)
.filter(([, v]) => v)
.map(([k]) => k);
return selected.length > 0 ? selected : undefined;
}
function getPeriodoInicio(): number | undefined {
if (!filtroInicio) return undefined;
return startOfDay(new Date(`${filtroInicio}T00:00:00`)).getTime();
}
function getPeriodoFim(): number | undefined {
if (!filtroFim) return undefined;
return endOfDay(new Date(`${filtroFim}T23:59:59`)).getTime();
}
function limparFiltros() {
filtroNumeroSei = '';
filtroCriadoPor = '';
filtroAceitoPor = '';
filtroInicio = '';
filtroFim = '';
(Object.keys(statusSelected) as PedidoStatus[]).forEach((k) => (statusSelected[k] = false));
}
const usuariosQuery = useQuery(api.usuarios.listar, { ativo: true });
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const filtroArgs = () => ({
statuses: getSelectedStatuses(),
numeroSei: filtroNumeroSei.trim() || undefined,
criadoPor: filtroCriadoPor ? filtroCriadoPor : undefined,
aceitoPor: filtroAceitoPor ? filtroAceitoPor : undefined,
periodoInicio: getPeriodoInicio(),
periodoFim: getPeriodoFim()
});
let generatingPDF = $state(false);
let generatingXLSX = $state(false);
async function gerarPDF() {
if (!filtroInicio && !filtroFim) {
alert('Informe um período (início e/ou fim) para gerar o relatório.');
return;
}
try {
generatingPDF = true;
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
gerarRelatorioPedidosPDF(relatorio);
} catch (e) {
console.error('Erro ao gerar relatório PDF:', e);
alert('Erro ao gerar relatório PDF. Tente novamente.');
} finally {
generatingPDF = false;
}
}
async function exportarXLSX() {
if (!filtroInicio && !filtroFim) {
alert('Informe um período (início e/ou fim) para exportar o relatório.');
return;
}
try {
generatingXLSX = true;
const relatorio = await client.query(api.pedidos.gerarRelatorio, filtroArgs());
await exportarRelatorioPedidosXLSX(relatorio);
} catch (e) {
console.error('Erro ao exportar relatório XLSX:', e);
alert('Erro ao exportar relatório Excel. Tente novamente.');
} finally {
generatingXLSX = false;
}
}
// Reactive queries // Reactive queries
const pedidosQuery = useQuery(api.pedidos.list, {}); const pedidosQuery = useQuery(api.pedidos.list, filtroArgs);
const myItemsQuery = useQuery(api.pedidos.listByItemCreator, {}); const myItemsQuery = useQuery(api.pedidos.listByItemCreator, filtroArgs);
const acoesQuery = useQuery(api.acoes.list, {}); const acoesQuery = useQuery(api.acoes.list, {});
let activeTab = $state<'all' | 'my_items'>('all'); let activeTab = $state<'all' | 'my_items'>('all');
@@ -42,22 +154,22 @@
} }
} }
function getStatusColor(status: string) { function getStatusBadgeClass(status: string) {
switch (status) { switch (status) {
case 'em_rascunho': case 'em_rascunho':
return 'bg-gray-100 text-gray-800'; return 'badge-ghost';
case 'aguardando_aceite': case 'aguardando_aceite':
return 'bg-yellow-100 text-yellow-800'; return 'badge-warning';
case 'em_analise': case 'em_analise':
return 'bg-blue-100 text-blue-800'; return 'badge-info';
case 'precisa_ajustes': case 'precisa_ajustes':
return 'bg-orange-100 text-orange-800'; return 'badge-secondary';
case 'concluido': case 'concluido':
return 'bg-green-100 text-green-800'; return 'badge-success';
case 'cancelado': case 'cancelado':
return 'bg-red-100 text-red-800'; return 'badge-error';
default: default:
return 'bg-gray-100 text-gray-800'; return 'badge-ghost';
} }
} }
@@ -66,17 +178,150 @@
} }
</script> </script>
<div class="container mx-auto p-6"> <PageShell>
<div class="mb-6 flex items-center justify-between"> <Breadcrumbs items={[{ label: 'Dashboard', href: resolve('/') }, { label: 'Pedidos' }]} />
<h1 class="text-2xl font-bold">Pedidos</h1>
<a <PageHeader title="Pedidos" subtitle="Cadastro, acompanhamento e relatórios de pedidos">
href={resolve('/pedidos/novo')} {#snippet icon()}
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700" <FileText strokeWidth={2} />
> {/snippet}
<Plus size={20} />
Novo Pedido {#snippet actions()}
</a> <button
</div> type="button"
class="btn btn-outline"
onclick={gerarPDF}
disabled={generatingPDF || generatingXLSX}
title="Gera relatório completo (PDF) no padrão do sistema"
>
{#if generatingPDF}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{generatingPDF ? 'Gerando PDF...' : 'Relatório (PDF)'}
</button>
<button
type="button"
class="btn btn-outline"
onclick={exportarXLSX}
disabled={generatingPDF || generatingXLSX}
title="Exporta relatório completo em Excel (XLSX)"
>
{#if generatingXLSX}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{generatingXLSX ? 'Exportando...' : 'Excel (XLSX)'}
</button>
<a
href={resolve('/pedidos/novo')}
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
>
<Plus class="h-5 w-5" strokeWidth={2} />
Novo Pedido
</a>
{/snippet}
</PageHeader>
<GlassCard class="mb-6">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
<div class="form-control w-full">
<label class="label" for="filtro_numeroSei">
<span class="label-text font-semibold">Número SEI</span>
</label>
<input
id="filtro_numeroSei"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Digite para filtrar..."
bind:value={filtroNumeroSei}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_criadoPor">
<span class="label-text font-semibold">Criado por</span>
</label>
<select
id="filtro_criadoPor"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroCriadoPor}
>
<option value="">Todos</option>
{#each usuariosQuery.data || [] as u (u._id)}
<option value={u._id}>{u.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_aceitoPor">
<span class="label-text font-semibold">Aceito por</span>
</label>
<select
id="filtro_aceitoPor"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroAceitoPor}
>
<option value="">Todos</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_inicio">
<span class="label-text font-semibold">Período (início)</span>
</label>
<input
id="filtro_inicio"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroInicio}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_fim">
<span class="label-text font-semibold">Período (fim)</span>
</label>
<input
id="filtro_fim"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroFim}
/>
</div>
<div class="form-control w-full md:col-span-3 lg:col-span-5">
<div class="label">
<span class="label-text font-semibold">Status</span>
</div>
<div class="flex flex-wrap gap-3">
{#each statusOptions as s (s.value)}
<label class="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={statusSelected[s.value]}
onchange={(e) =>
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="label-text">{s.label}</span>
</label>
{/each}
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">{pedidos.length} resultado(s)</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</GlassCard>
<div role="tablist" class="tabs tabs-bordered mb-6"> <div role="tablist" class="tabs tabs-bordered mb-6">
<button <button
@@ -96,87 +341,82 @@
</div> </div>
{#if loading} {#if loading}
<div class="flex flex-col gap-4"> <div class="flex items-center justify-center py-10">
{#each Array(3) as _, i (i)} <span class="loading loading-spinner loading-lg"></span>
<div class="skeleton h-16 w-full rounded-lg"></div>
{/each}
</div> </div>
{:else if error} {:else if error}
<div class="alert alert-error"> <div class="alert alert-error">
<span>{error}</span> <span>{error}</span>
</div> </div>
{:else} {:else}
<div class="overflow-hidden rounded-lg bg-white shadow-md"> <TableCard>
<table class="min-w-full divide-y divide-gray-200"> <table class="table-zebra table w-full">
<thead class="bg-gray-50"> <thead>
<tr> <tr>
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Número SEI</th >Número SEI</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
>Status</th >Status</th
> >
<th <th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" >Criado por</th
>Criado Por</th >
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
>Data de criação</th
> >
<th <th
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase" class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
>Data de Criação</th
>
<th
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
>Ações</th >Ações</th
> >
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 bg-white"> <tbody>
{#each pedidos as pedido (pedido._id)}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 font-medium whitespace-nowrap">
{#if pedido.numeroSei}
{pedido.numeroSei}
{:else}
<span class="text-amber-600">Sem número SEI</span>
{/if}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
pedido.status
)}"
>
{formatStatus(pedido.status)}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
{pedido.criadoPorNome || 'Desconhecido'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
{formatDate(pedido.criadoEm)}
</td>
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
<a
href={resolve(`/pedidos/${pedido._id}`)}
class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-900"
>
<Eye size={18} />
Visualizar
</a>
</td>
</tr>
{/each}
{#if pedidos.length === 0} {#if pedidos.length === 0}
<tr> <tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500" <td colspan="5" class="py-12 text-center">
>Nenhum pedido encontrado.</td <div class="text-base-content/60 flex flex-col items-center gap-2">
> <p class="text-lg font-semibold">Nenhum pedido encontrado</p>
<p class="text-sm">Ajuste ou limpe os filtros para ver resultados.</p>
</div>
</td>
</tr> </tr>
{:else}
{#each pedidos as pedido (pedido._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="font-medium whitespace-nowrap">
{#if pedido.numeroSei}
{pedido.numeroSei}
{:else}
<span class="text-warning">Sem número SEI</span>
{/if}
</td>
<td class="whitespace-nowrap">
<span class="badge badge-sm {getStatusBadgeClass(pedido.status)}">
{formatStatus(pedido.status)}
</span>
</td>
<td class="text-base-content/70 whitespace-nowrap">
{pedido.criadoPorNome || 'Desconhecido'}
</td>
<td class="text-base-content/70 whitespace-nowrap">
{formatDate(pedido.criadoEm)}
</td>
<td class="text-right whitespace-nowrap">
<a
href={resolve(`/pedidos/${pedido._id}`)}
class="btn btn-ghost btn-sm gap-2"
aria-label="Visualizar pedido"
>
<Eye class="h-4 w-4" />
Visualizar
</a>
</td>
</tr>
{/each}
{/if} {/if}
</tbody> </tbody>
</table> </table>
</div> </TableCard>
{/if} {/if}
</div> </PageShell>

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,12 @@
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { CheckCircle, Clock, FileText, User } from 'lucide-svelte'; import { CheckCircle, Clock, FileText, User } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { resolve } from '$app/paths';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
const client = useConvexClient(); const client = useConvexClient();
const ordersQuery = useQuery(api.pedidos.listForAcceptance, {}); const ordersQuery = useQuery(api.pedidos.listForAcceptance, {});
@@ -27,42 +33,44 @@
} }
</script> </script>
<div class="space-y-6"> <PageShell>
<div class="flex items-center justify-between"> <Breadcrumbs
<div> items={[
<h1 class="text-primary text-2xl font-bold tracking-tight">Pedidos para Aceite</h1> { label: 'Dashboard', href: resolve('/') },
<p class="text-base-content/70 mt-1"> { label: 'Pedidos', href: resolve('/pedidos') },
Lista de pedidos aguardando análise do setor de compras. { label: 'Aceite' }
</p> ]}
</div> />
</div>
{#if ordersQuery.isLoading} <PageHeader
<div class="flex flex-col gap-4"> title="Pedidos para Aceite"
{#each Array(3) as _, i (i)} subtitle="Lista de pedidos aguardando análise do setor de compras"
<div class="skeleton h-24 w-full rounded-lg"></div> >
{/each} {#snippet icon()}
</div> <Clock strokeWidth={2} />
{/snippet}
</PageHeader>
<div class="space-y-6">
{#if ordersQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if ordersQuery.error} {:else if ordersQuery.error}
<div class="alert alert-error"> <div class="alert alert-error">
<span>Erro ao carregar pedidos: {ordersQuery.error.message}</span> <span>Erro ao carregar pedidos: {ordersQuery.error.message}</span>
</div> </div>
{:else if !ordersQuery.data || ordersQuery.data.length === 0} {:else if !ordersQuery.data || ordersQuery.data.length === 0}
<div <EmptyState title="Tudo em dia!" description="Não há pedidos aguardando aceite no momento.">
class="bg-base-100 flex flex-col items-center justify-center rounded-lg border py-12 text-center shadow-sm" {#snippet icon()}
> <CheckCircle />
<div class="bg-base-200 mb-4 rounded-full p-4"> {/snippet}
<CheckCircle class="text-base-content/30 h-8 w-8" /> </EmptyState>
</div>
<h3 class="text-lg font-medium">Tudo em dia!</h3>
<p class="text-base-content/60 mt-1 max-w-sm">Não há pedidos aguardando aceite no momento.</p>
</div>
{:else} {:else}
<div class="grid gap-4"> <div class="grid gap-4">
{#each ordersQuery.data as pedido (pedido._id)} {#each ordersQuery.data as pedido (pedido._id)}
<div <GlassCard class="border-base-300 hover:border-primary/30 transition-all hover:shadow-md">
class="bg-base-100 border-base-200 hover:border-primary/30 group relative overflow-hidden rounded-xl border p-6 shadow-sm transition-all hover:shadow-md"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div class="space-y-1"> <div class="space-y-1">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -90,7 +98,7 @@
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/pedidos/{pedido._id}" class="btn btn-ghost btn-sm"> <a href={resolve(`/pedidos/${pedido._id}`)} class="btn btn-ghost btn-sm">
<FileText class="mr-2 h-4 w-4" /> <FileText class="mr-2 h-4 w-4" />
Ver Detalhes Ver Detalhes
</a> </a>
@@ -108,8 +116,9 @@
</button> </button>
</div> </div>
</div> </div>
</div> </GlassCard>
{/each} {/each}
</div> </div>
{/if} {/if}
</div> </div>
</PageShell>

View File

@@ -2,82 +2,91 @@
import { useQuery } from 'convex-svelte'; import { useQuery } from 'convex-svelte';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { ClipboardList, Clock, FileText, User, Search } from 'lucide-svelte'; import { ClipboardList, Clock, FileText, User, Search } from 'lucide-svelte';
import { resolve } from '$app/paths';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
const ordersQuery = useQuery(api.pedidos.listMyAnalysis, {}); const ordersQuery = useQuery(api.pedidos.listMyAnalysis, {});
</script> </script>
<div class="space-y-6"> <PageShell>
<div class="flex items-center justify-between"> <Breadcrumbs
<div> items={[
<h1 class="text-primary text-2xl font-bold tracking-tight">Minhas Análises</h1> { label: 'Dashboard', href: resolve('/') },
<p class="text-base-content/70 mt-1">Pedidos que você aceitou e está analisando.</p> { label: 'Pedidos', href: resolve('/pedidos') },
</div> { label: 'Minhas análises' }
</div> ]}
/>
{#if ordersQuery.isLoading} <PageHeader title="Minhas Análises" subtitle="Pedidos que você aceitou e está analisando">
<div class="flex flex-col gap-4"> {#snippet icon()}
{#each Array(3) as _, i (i)} <Search strokeWidth={2} />
<div class="skeleton h-24 w-full rounded-lg"></div> {/snippet}
{/each} </PageHeader>
</div>
{:else if ordersQuery.error} <div class="space-y-6">
<div class="alert alert-error"> {#if ordersQuery.isLoading}
<span>Erro ao carregar análises: {ordersQuery.error.message}</span> <div class="flex items-center justify-center py-10">
</div> <span class="loading loading-spinner loading-lg"></span>
{:else if !ordersQuery.data || ordersQuery.data.length === 0}
<div
class="bg-base-100 flex flex-col items-center justify-center rounded-lg border py-12 text-center shadow-sm"
>
<div class="bg-base-200 mb-4 rounded-full p-4">
<ClipboardList class="text-base-content/30 h-8 w-8" />
</div> </div>
<h3 class="text-lg font-medium">Nenhuma análise em andamento</h3> {:else if ordersQuery.error}
<p class="text-base-content/60 mt-1 max-w-sm"> <div class="alert alert-error">
Você não possui pedidos sob sua responsabilidade no momento. Vá para "Pedidos para Aceite" <span>Erro ao carregar análises: {ordersQuery.error.message}</span>
para pegar novos pedidos. </div>
</p> {:else if !ordersQuery.data || ordersQuery.data.length === 0}
</div> <EmptyState
{:else} title="Nenhuma análise em andamento"
<div class="grid gap-4"> description="Você não possui pedidos sob sua responsabilidade no momento. Vá para &quot;Pedidos para Aceite&quot; para pegar novos pedidos."
{#each ordersQuery.data as pedido (pedido._id)} >
<div {#snippet icon()}
class="bg-base-100 border-base-200 hover:border-primary/30 group relative overflow-hidden rounded-xl border p-6 shadow-sm transition-all hover:shadow-md" <ClipboardList />
> {/snippet}
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> </EmptyState>
<div class="space-y-1"> {:else}
<div class="flex items-center gap-2"> <div class="grid gap-4">
<span class="badge badge-info gap-1 font-medium"> {#each ordersQuery.data as pedido (pedido._id)}
<Search class="h-3 w-3" /> <GlassCard class="border-base-300 hover:border-primary/30 transition-all hover:shadow-md">
Em Análise <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
</span> <div class="space-y-1">
<span class="text-base-content/40 text-xs"> <div class="flex items-center gap-2">
#{pedido._id.slice(-6)} <span class="badge badge-info gap-1 font-medium">
</span> <Search class="h-3 w-3" />
</div> Em Análise
<h3 class="text-lg font-bold"> </span>
{pedido.numeroSei ? `SEI: ${pedido.numeroSei}` : 'Sem número SEI'} <span class="text-base-content/40 text-xs">
</h3> #{pedido._id.slice(-6)}
<div class="text-base-content/70 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"> </span>
<div class="flex items-center gap-1">
<User class="h-3.5 w-3.5" />
<span>Criado por: {pedido.criadoPorNome}</span>
</div> </div>
<div class="flex items-center gap-1"> <h3 class="text-lg font-bold">
<Clock class="h-3.5 w-3.5" /> {pedido.numeroSei ? `SEI: ${pedido.numeroSei}` : 'Sem número SEI'}
<span>Aceito em: {new Date(pedido.atualizadoEm).toLocaleDateString()}</span> </h3>
<div
class="text-base-content/70 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm"
>
<div class="flex items-center gap-1">
<User class="h-3.5 w-3.5" />
<span>Criado por: {pedido.criadoPorNome}</span>
</div>
<div class="flex items-center gap-1">
<Clock class="h-3.5 w-3.5" />
<span>Aceito em: {new Date(pedido.atualizadoEm).toLocaleDateString()}</span>
</div>
</div> </div>
</div> </div>
</div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<a href="/pedidos/{pedido._id}" class="btn btn-primary btn-sm"> <a href={resolve(`/pedidos/${pedido._id}`)} class="btn btn-primary btn-sm">
<FileText class="mr-2 h-4 w-4" /> <FileText class="mr-2 h-4 w-4" />
Continuar Análise Continuar Análise
</a> </a>
</div>
</div> </div>
</div> </GlassCard>
</div> {/each}
{/each} </div>
</div> {/if}
{/if} </div>
</div> </PageShell>

View File

@@ -2,7 +2,12 @@
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel'; import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { useConvexClient, useQuery } from 'convex-svelte'; import { useConvexClient, useQuery } from 'convex-svelte';
import { Plus, Trash2, X, Info } from 'lucide-svelte'; import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import { Info, Plus, Trash2, X } from 'lucide-svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
@@ -25,39 +30,28 @@
let warning = $state<string | null>(null); let warning = $state<string | null>(null);
// Item selection state // Item selection state
// Nota: modalidade é opcional aqui pois será definida pelo Setor de Compras posteriormente
type SelectedItem = { type SelectedItem = {
objeto: Doc<'objetos'>; objeto: Doc<'objetos'>;
quantidade: number; quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId?: Id<'acoes'>; acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
ataNumero?: string; // For display
ata?: Doc<'atas'>; // Full ata object for details
}; };
let selectedItems = $state<SelectedItem[]>([]); let selectedItems = $state<SelectedItem[]>([]);
let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id)); let selectedObjetoIds = $derived(selectedItems.map((i) => i.objeto._id));
let hasMixedModalidades = $derived(new Set(selectedItems.map((i) => i.modalidade)).size > 1);
// Item configuration modal // Item configuration modal
let showItemModal = $state(false); let showItemModal = $state(false);
let itemConfig = $state<{ let itemConfig = $state<{
objeto: Doc<'objetos'> | null; objeto: Doc<'objetos'> | null;
quantidade: number; quantidade: number;
modalidade: 'dispensa' | 'inexgibilidade' | 'adesao' | 'consumo';
acaoId: string; // using string to handle empty select acaoId: string; // using string to handle empty select
ataId: string; // using string to handle empty select
}>({ }>({
objeto: null, objeto: null,
quantidade: 1, quantidade: 1,
modalidade: 'consumo', acaoId: ''
acaoId: '',
ataId: ''
}); });
let availableAtas = $state<Doc<'atas'>[]>([]);
// Item Details Modal
let showDetailsModal = $state(false); let showDetailsModal = $state(false);
let detailsItem = $state<SelectedItem | null>(null); let detailsItem = $state<SelectedItem | null>(null);
@@ -72,16 +66,10 @@
} }
async function openItemModal(objeto: Doc<'objetos'>) { async function openItemModal(objeto: Doc<'objetos'>) {
// Fetch linked Atas for this object
const linkedAtas = await client.query(api.objetos.getAtas, { objetoId: objeto._id });
availableAtas = linkedAtas;
itemConfig = { itemConfig = {
objeto, objeto,
quantidade: 1, quantidade: 1,
modalidade: 'consumo', acaoId: ''
acaoId: '',
ataId: ''
}; };
showItemModal = true; showItemModal = true;
searchQuery = ''; // Clear search searchQuery = ''; // Clear search
@@ -90,24 +78,17 @@
function closeItemModal() { function closeItemModal() {
showItemModal = false; showItemModal = false;
itemConfig.objeto = null; itemConfig.objeto = null;
availableAtas = [];
} }
function confirmAddItem() { function confirmAddItem() {
if (!itemConfig.objeto) return; if (!itemConfig.objeto) return;
const selectedAta = availableAtas.find((a) => a._id === itemConfig.ataId);
selectedItems = [ selectedItems = [
...selectedItems, ...selectedItems,
{ {
objeto: itemConfig.objeto, objeto: itemConfig.objeto,
quantidade: itemConfig.quantidade, quantidade: itemConfig.quantidade,
modalidade: itemConfig.modalidade, acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined
acaoId: itemConfig.acaoId ? (itemConfig.acaoId as Id<'acoes'>) : undefined,
ataId: itemConfig.ataId ? (itemConfig.ataId as Id<'atas'>) : undefined,
ataNumero: selectedAta?.numero,
ata: selectedAta
} }
]; ];
checkExisting(); checkExisting();
@@ -128,7 +109,6 @@
criadoEm: number; criadoEm: number;
matchingItems?: { matchingItems?: {
objetoId: Id<'objetos'>; objetoId: Id<'objetos'>;
modalidade: SelectedItem['modalidade'];
quantidade: number; quantidade: number;
}[]; }[];
}[] }[]
@@ -154,36 +134,6 @@
} }
} }
function formatModalidade(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'Consumo';
case 'dispensa':
return 'Dispensa';
case 'inexgibilidade':
return 'Inexigibilidade';
case 'adesao':
return 'Adesão';
default:
return modalidade;
}
}
function getModalidadeBadgeClasses(modalidade: SelectedItem['modalidade']) {
switch (modalidade) {
case 'consumo':
return 'bg-blue-100 text-blue-800';
case 'dispensa':
return 'bg-yellow-100 text-yellow-800';
case 'inexgibilidade':
return 'bg-purple-100 text-purple-800';
case 'adesao':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
}
function getAcaoNome(acaoId: Id<'acoes'> | undefined) { function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
if (!acaoId) return '-'; if (!acaoId) return '-';
const acao = acoes.find((a) => a._id === acaoId); const acao = acoes.find((a) => a._id === acaoId);
@@ -203,8 +153,7 @@
.map((match) => { .map((match) => {
// Find name from selected items (might be multiple with same object, just pick one name) // Find name from selected items (might be multiple with same object, just pick one name)
const item = selectedItems.find((p) => p.objeto._id === match.objetoId); const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
const modalidadeLabel = formatModalidade(match.modalidade); return `${item?.objeto.nome}: ${match.quantidade} un.`;
return `${item?.objeto.nome} (${modalidadeLabel}): ${match.quantidade} un.`;
}) })
.join(', '); .join(', ');
@@ -215,9 +164,7 @@
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null; if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
for (const match of pedido.matchingItems) { for (const match of pedido.matchingItems) {
const item = selectedItems.find( const item = selectedItems.find((p) => p.objeto._id === match.objetoId);
(p) => p.objeto._id === match.objetoId && p.modalidade === match.modalidade
);
if (item) { if (item) {
return item; return item;
} }
@@ -226,27 +173,22 @@
return null; return null;
} }
function buildPedidoHref(pedido: (typeof existingPedidos)[0]) { function buildPedidoHref(pedido: (typeof existingPedidos)[0]): `/pedidos/${string}` {
const matchedItem = getFirstMatchingSelectedItem(pedido); const matchedItem = getFirstMatchingSelectedItem(pedido);
if (!matchedItem) { if (!matchedItem) {
return resolve(`/pedidos/${pedido._id}`); return `/pedidos/${pedido._id}` as `/pedidos/${string}`;
} }
const params = new URLSearchParams(); const params = new URLSearchParams();
params.set('obj', matchedItem.objeto._id); params.set('obj', matchedItem.objeto._id);
params.set('qtd', String(matchedItem.quantidade)); params.set('qtd', String(matchedItem.quantidade));
params.set('mod', matchedItem.modalidade);
if (matchedItem.acaoId) { if (matchedItem.acaoId) {
params.set('acao', matchedItem.acaoId); params.set('acao', matchedItem.acaoId);
} }
if (matchedItem.ataId) { return `/pedidos/${pedido._id}?${params.toString()}` as `/pedidos/${string}`;
params.set('ata', matchedItem.ataId);
}
return resolve(`/pedidos/${pedido._id}?${params.toString()}`);
} }
async function checkExisting() { async function checkExisting() {
@@ -258,13 +200,11 @@
checking = true; checking = true;
try { try {
// Importante: ação (acaoId) NÃO entra no filtro de similaridade. // Importante: O filtro considera apenas objetoId (modalidade não é mais usada na criação).
// O filtro considera apenas combinação de objeto + modalidade.
const itensFiltro = const itensFiltro =
selectedItems.length > 0 selectedItems.length > 0
? selectedItems.map((item) => ({ ? selectedItems.map((item) => ({
objetoId: item.objeto._id, objetoId: item.objeto._id
modalidade: item.modalidade
})) }))
: undefined; : undefined;
@@ -289,11 +229,6 @@
async function handleSubmit(e: Event) { async function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
if (hasMixedModalidades) {
error =
'Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens antes de continuar.';
return;
}
creating = true; creating = true;
error = null; error = null;
try { try {
@@ -309,9 +244,7 @@
objetoId: item.objeto._id, objetoId: item.objeto._id,
valorEstimado: item.objeto.valorEstimado, valorEstimado: item.objeto.valorEstimado,
quantidade: item.quantidade, quantidade: item.quantidade,
modalidade: item.modalidade, acaoId: item.acaoId
acaoId: item.acaoId,
ataId: item.ataId
}) })
) )
); );
@@ -326,71 +259,81 @@
} }
</script> </script>
<div class="container mx-auto max-w-4xl p-6"> <PageShell class="max-w-4xl">
<h1 class="mb-6 text-3xl font-bold">Novo Pedido</h1> <Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Novo' }
]}
/>
<PageHeader title="Novo Pedido" subtitle="Crie um pedido e adicione objetos.">
{#snippet icon()}
<Plus class="h-6 w-6" />
{/snippet}
</PageHeader>
<div class="space-y-6"> <div class="space-y-6">
{#if error} {#if error}
<div class="rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-red-700"> <div class="alert alert-error">
<p class="font-semibold">Erro</p> <span>{error}</span>
<p class="text-sm">{error}</p>
</div> </div>
{/if} {/if}
<form onsubmit={handleSubmit} class="space-y-6"> <form onsubmit={handleSubmit} class="space-y-6">
<!-- Section 1: Basic Information --> <GlassCard>
<div class="rounded-lg bg-white p-6 shadow-md"> <h2 class="text-lg font-semibold">Informações Básicas</h2>
<h2 class="mb-4 text-lg font-semibold text-gray-800">Informações Básicas</h2> <div class="mt-4">
<div> <label class="label py-0" for="numeroSei">
<label class="mb-2 block text-sm font-medium text-gray-700" for="numeroSei"> <span class="label-text font-semibold">Número SEI (Opcional)</span>
Número SEI (Opcional)
</label> </label>
<input <input
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
id="numeroSei" id="numeroSei"
type="text" type="text"
bind:value={formData.numeroSei} bind:value={formData.numeroSei}
placeholder="Ex: 12345.000000/2023-00" placeholder="Ex: 12345.000000/2023-00"
onblur={checkExisting} onblur={checkExisting}
class="input input-bordered focus:input-primary w-full"
/> />
<p class="mt-1.5 text-xs text-gray-500"> <p class="text-base-content/60 mt-2 text-xs">
Você pode adicionar o número SEI posteriormente. Você pode adicionar o número SEI posteriormente.
</p> </p>
</div> </div>
</div> </GlassCard>
<!-- Section 2: Add Objects --> <GlassCard>
<div class="rounded-lg bg-white p-6 shadow-md"> <h2 class="text-lg font-semibold">Adicionar Objetos ao Pedido</h2>
<h2 class="mb-4 text-lg font-semibold text-gray-800">Adicionar Objetos ao Pedido</h2>
<div class="relative mb-4"> <div class="relative mt-4">
<label class="mb-2 block text-sm font-medium text-gray-700" for="search-objetos"> <label class="label py-0" for="search-objetos">
Buscar Objetos <span class="label-text font-semibold">Buscar Objetos</span>
</label> </label>
<input <input
id="search-objetos" id="search-objetos"
type="text" type="text"
placeholder="Digite o nome do objeto..." placeholder="Digite o nome do objeto..."
class="focus:shadow-outline w-full appearance-none rounded-lg border border-gray-300 px-4 py-2.5 leading-tight text-gray-700 shadow-sm transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none" class="input input-bordered focus:input-primary w-full"
bind:value={searchQuery} bind:value={searchQuery}
/> />
{#if searchQuery.length > 0 && searchResults} {#if searchQuery.length > 0 && searchResults}
<div <div
class="absolute z-10 mt-2 w-full rounded-lg border border-gray-200 bg-white shadow-xl" class="border-base-300 bg-base-100 rounded-box absolute z-20 mt-2 w-full overflow-hidden border shadow"
> >
{#if searchResults.length === 0} {#if searchResults.length === 0}
<div class="p-4 text-sm text-gray-500">Nenhum objeto encontrado.</div> <div class="text-base-content/60 p-4 text-sm">Nenhum objeto encontrado.</div>
{:else} {:else}
<ul class="max-h-64 overflow-y-auto"> <ul class="menu max-h-64 overflow-y-auto p-2">
{#each searchResults as objeto (objeto._id)} {#each searchResults as objeto (objeto._id)}
<li> <li>
<button <button
type="button" type="button"
class="flex w-full items-center justify-between px-4 py-3 text-left transition hover:bg-blue-50" class="flex items-center justify-between"
onclick={() => openItemModal(objeto)} onclick={() => openItemModal(objeto)}
> >
<span class="font-medium text-gray-800">{objeto.nome}</span> <span class="font-medium">{objeto.nome}</span>
<Plus size={16} class="text-blue-600" /> <Plus class="h-4 w-4" />
</button> </button>
</li> </li>
{/each} {/each}
@@ -400,256 +343,161 @@
{/if} {/if}
</div> </div>
{#if selectedItems.length > 0} <div class="mt-6">
<div class="mt-6"> {#if selectedItems.length > 0}
<h3 class="mb-3 text-sm font-semibold text-gray-700"> <h3 class="text-base-content/70 mb-3 text-sm font-semibold">
Itens Selecionados ({selectedItems.length}) Itens Selecionados ({selectedItems.length})
</h3> </h3>
<div class="space-y-3"> <div class="grid gap-3">
{#each selectedItems as item, index (index)} {#each selectedItems as item, index (index)}
<div <GlassCard class="border-base-300" bodyClass="p-4">
class="rounded-xl border border-gray-200 bg-gray-50 p-4 transition hover:shadow-md"
>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1 space-y-2"> <div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<p class="font-semibold text-gray-900">{item.objeto.nome}</p> <p class="font-semibold">{item.objeto.nome}</p>
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
item.modalidade
)}`}
>
{formatModalidade(item.modalidade)}
</span>
{#if item.ataNumero}
<span
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800"
>
Ata {item.ataNumero}
</span>
{/if}
{#if item.acaoId} {#if item.acaoId}
<span <span class="badge badge-info badge-sm"
class="inline-flex items-center rounded-full bg-indigo-100 px-2.5 py-0.5 text-xs font-medium text-indigo-800" >Ação: {getAcaoNome(item.acaoId)}</span
> >
Ação: {getAcaoNome(item.acaoId)}
</span>
{/if} {/if}
</div> </div>
<div class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-gray-600"> <div class="text-base-content/70 mt-1 text-sm">
<span> <span class="font-semibold">Qtd:</span>
<strong>Qtd:</strong> {item.quantidade}
{item.quantidade} {item.objeto.unidade}
{item.objeto.unidade}
</span>
</div> </div>
</div> </div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-1">
<button <button
type="button" type="button"
class="rounded-lg p-2 text-blue-600 transition hover:bg-blue-50" class="btn btn-ghost btn-sm btn-square"
onclick={() => openDetails(item)} onclick={() => openDetails(item)}
aria-label="Ver detalhes" title="Ver detalhes"
> >
<Info size={18} /> <Info class="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
class="rounded-lg p-2 text-red-600 transition hover:bg-red-50" class="btn btn-ghost btn-sm btn-square text-error"
onclick={() => removeItem(index)} onclick={() => removeItem(index)}
aria-label="Remover item" title="Remover item"
> >
<Trash2 size={18} /> <Trash2 class="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
</div> </GlassCard>
{/each} {/each}
</div> </div>
</div> {:else}
{:else} <EmptyState
<div class="mt-4 rounded-lg border-2 border-dashed border-gray-300 p-8 text-center"> title="Nenhum item adicionado"
<p class="text-sm text-gray-500"> description="Use a busca acima para adicionar objetos ao pedido."
Nenhum item adicionado. Use a busca acima para adicionar objetos ao pedido. >
</p> {#snippet icon()}
</div> <Plus />
{/if} {/snippet}
</div> </EmptyState>
{/if}
<!-- Warnings Section -->
{#if hasMixedModalidades}
<div class="mb-3 rounded-lg border border-red-400 bg-red-50 px-4 py-3 text-sm text-red-800">
<p class="font-semibold">Modalidades diferentes detectadas</p>
<p>
Não é possível criar o pedido com itens de modalidades diferentes. Ajuste os itens para
usar uma única modalidade.
</p>
</div> </div>
{/if} </GlassCard>
{#if warning} {#if warning}
<div <div class={`alert ${existingPedidos.length > 0 ? 'alert-warning' : 'alert-info'}`}>
class="rounded-lg border border-yellow-400 bg-yellow-50 px-4 py-3 text-sm text-yellow-800" <span>{warning}</span>
>
<p class="font-semibold">Aviso</p>
<p>{warning}</p>
</div> </div>
{/if} {/if}
{#if checking} {#if checking}
<p class="text-sm text-gray-500">Verificando pedidos existentes...</p> <div class="text-base-content/60 flex items-center gap-2 text-sm">
{/if} <span class="loading loading-spinner loading-sm"></span>
Verificando pedidos existentes...
{#if existingPedidos.length > 0}
<div class="rounded-lg border border-yellow-300 bg-yellow-50 p-4">
<p class="mb-3 font-semibold text-yellow-900">Pedidos similares encontrados:</p>
<ul class="space-y-2">
{#each existingPedidos as pedido (pedido._id)}
<li class="flex flex-col rounded-lg bg-white px-4 py-3 shadow-sm">
<div class="flex items-center justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-gray-900">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p>
{#if getFirstMatchingSelectedItem(pedido)}
{#key pedido._id}
{#if getFirstMatchingSelectedItem(pedido)}
<span
class={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${getModalidadeBadgeClasses(
getFirstMatchingSelectedItem(pedido).modalidade
)}`}
>
Modalidade:{' '}
{formatModalidade(getFirstMatchingSelectedItem(pedido).modalidade)}
</span>
{/if}
{/key}
{/if}
{#if getMatchingInfo(pedido)}
<p class="mt-1 text-xs text-blue-700">
{getMatchingInfo(pedido)}
</p>
{/if}
</div>
<a
href={buildPedidoHref(pedido)}
class="text-sm font-medium text-blue-600 hover:text-blue-800"
>
Abrir
</a>
</div>
</li>
{/each}
</ul>
</div> </div>
{/if} {/if}
<!-- Action Buttons --> {#if existingPedidos.length > 0}
<div class="flex items-center justify-end gap-3 border-t pt-6"> <GlassCard>
<a <h2 class="text-lg font-semibold">Pedidos similares encontrados</h2>
href={resolve('/pedidos')} <div class="mt-4 space-y-2">
class="rounded-lg bg-gray-200 px-6 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300" {#each existingPedidos as pedido (pedido._id)}
> <div
Cancelar class="border-base-300 bg-base-100 flex items-start justify-between gap-3 rounded-lg border p-4"
</a> >
<div class="min-w-0">
<p class="font-medium">
Pedido {pedido.numeroSei || 'sem número SEI'}{formatStatus(pedido.status)}
</p>
{#if getMatchingInfo(pedido)}
<p class="text-info mt-1 text-xs">{getMatchingInfo(pedido)}</p>
{/if}
</div>
<a href={resolve(buildPedidoHref(pedido))} class="btn btn-ghost btn-sm">Abrir</a>
</div>
{/each}
</div>
</GlassCard>
{/if}
<div class="flex items-center justify-end gap-3">
<a href={resolve('/pedidos')} class="btn">Cancelar</a>
<button <button
type="submit" type="submit"
disabled={creating || selectedItems.length === 0 || hasMixedModalidades} disabled={creating || selectedItems.length === 0}
class="rounded-lg bg-blue-600 px-6 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" class="btn btn-primary"
> >
{creating ? 'Criando...' : 'Criar Pedido'} {#if creating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
Criar Pedido
</button> </button>
</div> </div>
</form> </form>
</div> </div>
<!-- Item Configuration Modal -->
{#if showItemModal && itemConfig.objeto} {#if showItemModal && itemConfig.objeto}
<div <div class="modal modal-open">
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4" <div class="modal-box max-w-lg">
>
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<button <button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeItemModal} onclick={closeItemModal}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600" aria-label="Fechar modal"
aria-label="Fechar"
> >
<X size={24} /> <X class="h-5 w-5" />
</button> </button>
<h3 class="mb-4 text-xl font-bold text-gray-900">Configurar Item</h3> <h3 class="text-lg font-bold">Configurar Item</h3>
<div class="mb-6 rounded-lg bg-blue-50 p-4"> <div class="border-base-300 bg-base-200/30 mt-4 rounded-lg border p-4">
<p class="font-semibold text-gray-900">{itemConfig.objeto.nome}</p> <p class="font-semibold">{itemConfig.objeto.nome}</p>
<p class="text-sm text-gray-600">Unidade: {itemConfig.objeto.unidade}</p> <p class="text-base-content/70 text-sm">Unidade: {itemConfig.objeto.unidade}</p>
<p class="mt-1 text-xs text-gray-500"> <p class="text-base-content/60 mt-1 text-xs">
Valor estimado: {itemConfig.objeto.valorEstimado} Valor estimado: {itemConfig.objeto.valorEstimado}
</p> </p>
</div> </div>
<div class="space-y-4"> <div class="mt-4 grid gap-4">
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="quantidade"> <label class="label py-0" for="quantidade">
Quantidade <span class="label-text font-semibold">Quantidade</span>
</label> </label>
<input <input
id="quantidade" id="quantidade"
type="number" type="number"
min="1" min="1"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none" class="input input-bordered focus:input-primary w-full"
bind:value={itemConfig.quantidade} bind:value={itemConfig.quantidade}
/> />
</div> </div>
<div> <div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="modalidade"> <label class="label py-0" for="itemAcao">
Modalidade <span class="label-text font-semibold">Ação (Opcional)</span>
</label>
<select
id="modalidade"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none"
bind:value={itemConfig.modalidade}
>
<option value="consumo">Consumo</option>
<option value="dispensa">Dispensa</option>
<option value="inexgibilidade">Inexigibilidade</option>
<option value="adesao">Adesão</option>
</select>
</div>
{#if availableAtas.length > 0}
<div class="rounded-lg border-2 border-green-200 bg-green-50 p-4">
<div class="mb-2 flex items-center gap-2">
<span class="rounded-full bg-green-600 px-2 py-0.5 text-xs font-bold text-white">
{availableAtas.length}
{availableAtas.length === 1 ? 'Ata' : 'Atas'}
</span>
<span class="text-sm font-semibold text-green-900">disponível para este objeto</span
>
</div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAta">
Selecionar Ata (Opcional)
</label>
<select
id="itemAta"
class="w-full rounded-lg border border-green-300 bg-white px-4 py-2.5 transition focus:border-green-500 focus:ring-2 focus:ring-green-200 focus:outline-none"
bind:value={itemConfig.ataId}
>
<option value="">Nenhuma</option>
{#each availableAtas as ata (ata._id)}
<option value={ata._id}>Ata {ata.numero} (SEI: {ata.numeroSei})</option>
{/each}
</select>
</div>
{/if}
<div>
<label class="mb-2 block text-sm font-medium text-gray-700" for="itemAcao">
Ação (Opcional)
</label> </label>
<select <select
id="itemAcao" id="itemAcao"
class="w-full rounded-lg border border-gray-300 px-4 py-2.5 transition focus:border-blue-500 focus:ring-2 focus:ring-blue-200 focus:outline-none" class="select select-bordered focus:select-primary w-full"
bind:value={itemConfig.acaoId} bind:value={itemConfig.acaoId}
> >
<option value="">Selecione uma ação...</option> <option value="">Selecione uma ação...</option>
@@ -660,97 +508,75 @@
</div> </div>
</div> </div>
<div class="mt-6 flex justify-end gap-3"> <div class="modal-action">
<button <button type="button" class="btn" onclick={closeItemModal}>Cancelar</button>
type="button" <button type="button" class="btn btn-primary" onclick={confirmAddItem}
onclick={closeItemModal} >Adicionar Item</button
class="rounded-lg bg-gray-200 px-5 py-2.5 font-semibold text-gray-800 transition hover:bg-gray-300"
> >
Cancelar
</button>
<button
type="button"
onclick={confirmAddItem}
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
>
Adicionar Item
</button>
</div> </div>
</div> </div>
<button
type="button"
class="modal-backdrop"
onclick={closeItemModal}
aria-label="Fechar modal"
></button>
</div> </div>
{/if} {/if}
<!-- Details Modal -->
{#if showDetailsModal && detailsItem} {#if showDetailsModal && detailsItem}
<div <div class="modal modal-open">
class="fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-black/40 p-4" <div class="modal-box max-w-lg">
>
<div class="relative w-full max-w-lg rounded-xl bg-white p-6 shadow-2xl">
<button <button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeDetails} onclick={closeDetails}
class="absolute top-4 right-4 rounded-lg p-1 text-gray-400 transition hover:bg-gray-100 hover:text-gray-600" aria-label="Fechar modal"
aria-label="Fechar"
> >
<X size={24} /> <X class="h-5 w-5" />
</button> </button>
<h3 class="mb-4 text-xl font-bold text-gray-900">Detalhes do Item</h3> <h3 class="text-lg font-bold">Detalhes do Item</h3>
<div class="space-y-4"> <div class="mt-4 space-y-4">
<div class="rounded-lg bg-gray-50 p-4"> <div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
<h4 class="mb-2 font-semibold text-gray-800">Objeto</h4> <h4 class="font-semibold">Objeto</h4>
<p class="text-gray-700"><strong>Nome:</strong> {detailsItem.objeto.nome}</p> <p class="text-base-content/70 mt-2 text-sm">
<p class="text-gray-700"><strong>Unidade:</strong> {detailsItem.objeto.unidade}</p> <strong>Nome:</strong>
<p class="text-gray-700"> {detailsItem.objeto.nome}
</p>
<p class="text-base-content/70 text-sm">
<strong>Unidade:</strong>
{detailsItem.objeto.unidade}
</p>
<p class="text-base-content/70 text-sm">
<strong>Valor Estimado:</strong> <strong>Valor Estimado:</strong>
{detailsItem.objeto.valorEstimado} {detailsItem.objeto.valorEstimado}
</p> </p>
</div> </div>
<div class="rounded-lg bg-gray-50 p-4"> <div class="border-base-300 bg-base-200/30 rounded-lg border p-4">
<h4 class="mb-2 font-semibold text-gray-800">Pedido</h4> <h4 class="font-semibold">Pedido</h4>
<p class="text-gray-700"><strong>Quantidade:</strong> {detailsItem.quantidade}</p> <p class="text-base-content/70 mt-2 text-sm">
<p class="text-gray-700"><strong>Modalidade:</strong> {detailsItem.modalidade}</p> <strong>Quantidade:</strong>
{detailsItem.quantidade}
</p>
{#if detailsItem.acaoId} {#if detailsItem.acaoId}
<p class="text-gray-700"> <p class="text-base-content/70 text-sm">
<strong>Ação:</strong> <strong>Ação:</strong>
{getAcaoNome(detailsItem.acaoId)} {getAcaoNome(detailsItem.acaoId)}
</p> </p>
{/if} {/if}
</div> </div>
{#if detailsItem.ata}
<div class="rounded-lg border border-green-100 bg-green-50 p-4">
<h4 class="mb-2 font-semibold text-green-900">Ata de Registro de Preços</h4>
<p class="text-green-800"><strong>Número:</strong> {detailsItem.ata.numero}</p>
<p class="text-green-800">
<strong>Processo SEI:</strong>
{detailsItem.ata.numeroSei}
</p>
{#if detailsItem.ata.dataInicio}
<p class="text-green-800">
<strong>Vigência:</strong>
{detailsItem.ata.dataInicio} até {detailsItem.ata.dataFim || 'Indefinido'}
</p>
{/if}
</div>
{:else}
<div class="rounded-lg bg-gray-50 p-4">
<p class="text-sm text-gray-500 italic">Nenhuma Ata vinculada a este item.</p>
</div>
{/if}
</div> </div>
<div class="mt-6 flex justify-end"> <div class="modal-action">
<button <button type="button" class="btn" onclick={closeDetails}>Fechar</button>
type="button"
onclick={closeDetails}
class="rounded-lg bg-blue-600 px-5 py-2.5 font-semibold text-white shadow-md transition hover:bg-blue-700"
>
Fechar
</button>
</div> </div>
</div> </div>
<button type="button" class="modal-backdrop" onclick={closeDetails} aria-label="Fechar modal"
></button>
</div> </div>
{/if} {/if}
</div> </PageShell>

View File

@@ -0,0 +1,573 @@
<script lang="ts">
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
import PageHeader from '$lib/components/layout/PageHeader.svelte';
import PageShell from '$lib/components/layout/PageShell.svelte';
import GlassCard from '$lib/components/ui/GlassCard.svelte';
import TableCard from '$lib/components/ui/TableCard.svelte';
import { useConvexClient, useQuery } from 'convex-svelte';
import { ClipboardList, Eye, Plus, X, Copy, FileText, FileSpreadsheet } from 'lucide-svelte';
import { resolve } from '$app/paths';
import { goto } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { exportarRelatorioPlanejamentosXLSX } from '$lib/utils/planejamentos/relatorioPlanejamentosExcel';
import { gerarRelatorioPlanejamentosPDF } from '$lib/utils/planejamentos/relatorioPlanejamentosPDF';
import { endOfDay, startOfDay } from 'date-fns';
const client = useConvexClient();
// Filtros
let filtroTexto = $state('');
let filtroResponsavel = $state<Id<'funcionarios'> | ''>('');
let filtroAcao = $state<Id<'acoes'> | ''>('');
let filtroInicio = $state('');
let filtroFim = $state('');
const statusOptions = [
{ value: 'rascunho', label: 'Rascunho' },
{ value: 'gerado', label: 'Gerado' },
{ value: 'cancelado', label: 'Cancelado' }
] as const;
type PlanejamentoStatus = (typeof statusOptions)[number]['value'];
let statusSelected = $state<Record<PlanejamentoStatus, boolean>>({
rascunho: false,
gerado: false,
cancelado: false
});
function getSelectedStatuses(): PlanejamentoStatus[] | undefined {
const selected = (Object.entries(statusSelected) as Array<[PlanejamentoStatus, boolean]>)
.filter(([, v]) => v)
.map(([k]) => k);
return selected.length > 0 ? selected : undefined;
}
function getPeriodoInicio(): number | undefined {
if (!filtroInicio) return undefined;
return startOfDay(new Date(`${filtroInicio}T00:00:00`)).getTime();
}
function getPeriodoFim(): number | undefined {
if (!filtroFim) return undefined;
return endOfDay(new Date(`${filtroFim}T23:59:59`)).getTime();
}
function limparFiltros() {
filtroTexto = '';
filtroResponsavel = '';
filtroAcao = '';
filtroInicio = '';
filtroFim = '';
(Object.keys(statusSelected) as PlanejamentoStatus[]).forEach(
(k) => (statusSelected[k] = false)
);
}
const filtroArgs = () => ({
statuses: getSelectedStatuses(),
texto: filtroTexto.trim() || undefined,
responsavelId: filtroResponsavel ? filtroResponsavel : undefined,
acaoId: filtroAcao ? filtroAcao : undefined,
periodoInicio: getPeriodoInicio(),
periodoFim: getPeriodoFim()
});
const planejamentosQuery = useQuery(api.planejamentos.list, filtroArgs);
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
const acoesQuery = useQuery(api.acoes.list, {});
let planejamentos = $derived(planejamentosQuery.data || []);
// Relatórios
let generatingPDF = $state(false);
let generatingXLSX = $state(false);
async function gerarPDF() {
try {
generatingPDF = true;
// Passa os mesmos filtros da lista (que é uma função)
const dados = await client.query(api.planejamentos.gerarRelatorio, filtroArgs());
gerarRelatorioPlanejamentosPDF(dados);
} catch (e) {
console.error(e);
toast.error('Erro ao gerar PDF.');
} finally {
generatingPDF = false;
}
}
async function exportarXLSX() {
try {
generatingXLSX = true;
const dados = await client.query(api.planejamentos.gerarRelatorio, filtroArgs());
await exportarRelatorioPlanejamentosXLSX(dados);
} catch (e) {
console.error(e);
toast.error('Erro ao gerar Excel.');
} finally {
generatingXLSX = false;
}
}
function formatStatus(status: string) {
switch (status) {
case 'rascunho':
return 'Rascunho';
case 'gerado':
return 'Gerado';
case 'cancelado':
return 'Cancelado';
default:
return status;
}
}
function getStatusBadgeClass(status: string) {
switch (status) {
case 'rascunho':
return 'badge-ghost';
case 'gerado':
return 'badge-success';
case 'cancelado':
return 'badge-error';
default:
return 'badge-ghost';
}
}
function formatDateYMD(ymd: string) {
// ymd: yyyy-MM-dd
const [y, m, d] = ymd.split('-');
if (!y || !m || !d) return ymd;
return `${d}/${m}/${y}`;
}
// Create modal
let showCreate = $state(false);
let creating = $state(false);
let form = $state({
titulo: '',
descricao: '',
data: '',
responsavelId: '' as string,
acaoId: '' as string,
sourcePlanningId: '' as string
});
function openCreate() {
form = {
titulo: '',
descricao: '',
data: '',
responsavelId: '',
acaoId: '',
sourcePlanningId: ''
};
showCreate = true;
}
function openClone(planning: (typeof planejamentos)[0]) {
form = {
titulo: `${planning.titulo} (Cópia)`,
descricao: planning.descricao,
data: planning.data,
responsavelId: planning.responsavelId,
acaoId: planning.acaoId || '',
sourcePlanningId: planning._id
};
showCreate = true;
}
function closeCreate() {
showCreate = false;
}
async function handleCreate() {
if (!form.titulo.trim()) return toast.error('Informe um título.');
if (!form.descricao.trim()) return toast.error('Informe uma descrição.');
if (!form.data.trim()) return toast.error('Informe uma data.');
if (!form.responsavelId) return toast.error('Selecione um responsável.');
creating = true;
try {
const id = await client.mutation(api.planejamentos.create, {
titulo: form.titulo,
descricao: form.descricao,
data: form.data,
responsavelId: form.responsavelId as Id<'funcionarios'>,
acaoId: form.acaoId ? (form.acaoId as Id<'acoes'>) : undefined,
sourcePlanningId: form.sourcePlanningId
? (form.sourcePlanningId as Id<'planejamentosPedidos'>)
: undefined
});
toast.success('Planejamento criado.');
showCreate = false;
await goto(resolve(`/pedidos/planejamento/${id}`));
} catch (e) {
toast.error((e as Error).message);
} finally {
creating = false;
}
}
</script>
<PageShell>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: resolve('/') },
{ label: 'Pedidos', href: resolve('/pedidos') },
{ label: 'Planejamento' }
]}
/>
<PageHeader
title="Planejamento de Pedidos"
subtitle="Crie e acompanhe planejamentos antes de gerar pedidos"
>
{#snippet icon()}
<ClipboardList strokeWidth={2} />
{/snippet}
{#snippet actions()}
<button
type="button"
class="btn btn-ghost gap-2"
onclick={gerarPDF}
disabled={generatingPDF || generatingXLSX}
title="Gerar PDF"
>
{#if generatingPDF}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<FileText class="h-5 w-5" />
{/if}
{generatingPDF ? 'Gerando...' : 'PDF'}
</button>
<button
type="button"
class="btn btn-ghost gap-2"
onclick={exportarXLSX}
disabled={generatingPDF || generatingXLSX}
title="Exportar Excel"
>
{#if generatingXLSX}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<FileSpreadsheet class="h-5 w-5" />
{/if}
{generatingXLSX ? 'Exportando...' : 'Excel'}
</button>
<button
type="button"
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
onclick={openCreate}
>
<Plus class="h-5 w-5" strokeWidth={2} />
Novo planejamento
</button>
{/snippet}
</PageHeader>
<GlassCard class="mb-6">
<div class="grid gap-4 sm:grid-cols-[1fr_auto] sm:items-end">
<div class="grid grid-cols-1 gap-4 md:grid-cols-3 lg:grid-cols-5">
<div class="form-control w-full">
<label class="label" for="filtro_texto">
<span class="label-text font-semibold">Busca (título/descrição)</span>
</label>
<input
id="filtro_texto"
class="input input-bordered focus:input-primary w-full"
type="text"
placeholder="Digite para pesquisar..."
bind:value={filtroTexto}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_responsavel">
<span class="label-text font-semibold">Responsável</span>
</label>
<select
id="filtro_responsavel"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroResponsavel}
>
<option value="">Todos</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_acao">
<span class="label-text font-semibold">Ação</span>
</label>
<select
id="filtro_acao"
class="select select-bordered focus:select-primary w-full"
bind:value={filtroAcao}
>
<option value="">Todas</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_inicio">
<span class="label-text font-semibold">Período (início)</span>
</label>
<input
id="filtro_inicio"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroInicio}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtro_fim">
<span class="label-text font-semibold">Período (fim)</span>
</label>
<input
id="filtro_fim"
class="input input-bordered focus:input-primary w-full"
type="date"
bind:value={filtroFim}
/>
</div>
<div class="form-control w-full md:col-span-3 lg:col-span-5">
<div class="label">
<span class="label-text font-semibold">Status</span>
</div>
<div class="flex flex-wrap gap-3">
{#each statusOptions as s (s.value)}
<label class="label cursor-pointer justify-start gap-2 py-1">
<input
type="checkbox"
class="checkbox checkbox-primary checkbox-sm"
checked={statusSelected[s.value]}
onchange={(e) =>
(statusSelected[s.value] = (e.currentTarget as HTMLInputElement).checked)}
/>
<span class="label-text">{s.label}</span>
</label>
{/each}
</div>
</div>
</div>
<div class="flex items-center justify-between gap-3 sm:min-w-max sm:justify-end">
<div class="text-base-content/70 text-sm">{planejamentos.length} resultado(s)</div>
<button type="button" class="btn btn-ghost" onclick={limparFiltros}>Limpar</button>
</div>
</div>
</GlassCard>
{#if planejamentosQuery.isLoading}
<div class="flex items-center justify-center py-10">
<span class="loading loading-spinner loading-lg"></span>
</div>
{:else if planejamentosQuery.error}
<div class="alert alert-error">
<span>{planejamentosQuery.error.message}</span>
</div>
{:else}
<TableCard>
<table class="table-zebra table w-full">
<thead>
<tr>
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Título</th
>
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Data</th
>
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Responsável</th
>
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Ação</th
>
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
>Status</th
>
<th
class="text-base-content border-base-300 border-b text-right font-bold whitespace-nowrap"
>Ações</th
>
</tr>
</thead>
<tbody>
{#if planejamentos.length === 0}
<tr>
<td colspan="6" class="py-12 text-center">
<div class="text-base-content/60 flex flex-col items-center gap-2">
<p class="text-lg font-semibold">Nenhum planejamento encontrado</p>
<p class="text-sm">Tente ajustar os filtros ou crie um novo planejamento.</p>
</div>
</td>
</tr>
{:else}
{#each planejamentos as p (p._id)}
<tr class="hover:bg-base-200/50 transition-colors">
<td class="font-medium whitespace-nowrap">{p.titulo}</td>
<td class="text-base-content/70 whitespace-nowrap">{formatDateYMD(p.data)}</td>
<td class="text-base-content/70 whitespace-nowrap">{p.responsavelNome}</td>
<td class="text-base-content/70 whitespace-nowrap">{p.acaoNome || '-'}</td>
<td class="whitespace-nowrap">
<span class="badge badge-sm {getStatusBadgeClass(p.status)}">
{formatStatus(p.status)}
</span>
</td>
<td class="text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-2">
<a
href={resolve(`/pedidos/planejamento/${p._id}`)}
class="btn btn-ghost btn-sm gap-2"
aria-label="Abrir planejamento"
>
<Eye class="h-4 w-4" />
Abrir
</a>
<button
class="btn btn-ghost btn-sm gap-2"
onclick={() => openClone(p)}
aria-label="Clonar planejamento"
>
<Copy class="h-4 w-4" />
Clonar
</button>
</div>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</TableCard>
{/if}
{#if showCreate}
<div class="modal modal-open">
<div class="modal-box max-w-2xl">
<button
type="button"
class="btn btn-sm btn-circle absolute top-2 right-2"
onclick={closeCreate}
aria-label="Fechar modal"
disabled={creating}
>
<X class="h-5 w-5" />
</button>
<h3 class="text-lg font-bold">
{form.sourcePlanningId ? 'Clonar planejamento' : 'Novo planejamento'}
</h3>
<div class="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full md:col-span-2">
<label class="label" for="titulo">
<span class="label-text font-semibold">Título</span>
</label>
<input
id="titulo"
type="text"
class="input input-bordered focus:input-primary w-full"
bind:value={form.titulo}
disabled={creating}
/>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label" for="descricao">
<span class="label-text font-semibold">Descrição</span>
</label>
<textarea
id="descricao"
class="textarea textarea-bordered focus:textarea-primary w-full"
rows="4"
bind:value={form.descricao}
disabled={creating}
></textarea>
</div>
<div class="form-control w-full">
<label class="label" for="data">
<span class="label-text font-semibold">Data</span>
</label>
<input
id="data"
type="date"
class="input input-bordered focus:input-primary w-full"
bind:value={form.data}
disabled={creating}
/>
</div>
<div class="form-control w-full">
<label class="label" for="responsavel">
<span class="label-text font-semibold">Responsável</span>
</label>
<select
id="responsavel"
class="select select-bordered focus:select-primary w-full"
bind:value={form.responsavelId}
disabled={creating || funcionariosQuery.isLoading}
>
<option value="">Selecione...</option>
{#each funcionariosQuery.data || [] as f (f._id)}
<option value={f._id}>{f.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label" for="acao">
<span class="label-text font-semibold">Ação (opcional)</span>
</label>
<select
id="acao"
class="select select-bordered focus:select-primary w-full"
bind:value={form.acaoId}
disabled={creating || acoesQuery.isLoading}
>
<option value="">Nenhuma</option>
{#each acoesQuery.data || [] as a (a._id)}
<option value={a._id}>{a.nome}</option>
{/each}
</select>
</div>
</div>
<div class="modal-action">
<button type="button" class="btn" onclick={closeCreate} disabled={creating}
>Cancelar</button
>
<button type="button" class="btn btn-primary" onclick={handleCreate} disabled={creating}>
{#if creating}
<span class="loading loading-spinner loading-sm"></span>
{/if}
{creating
? form.sourcePlanningId
? 'Clonando...'
: 'Criando...'
: form.sourcePlanningId
? 'Clonar'
: 'Criar'}
</button>
</div>
</div>
<button type="button" class="modal-backdrop" onclick={closeCreate} aria-label="Fechar modal"
></button>
</div>
{/if}
</PageShell>

View File

@@ -0,0 +1,3 @@
export const load = async ({ parent }) => {
await parent();
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,24 +1,22 @@
import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit'; import { createConvexHttpClient } from '@mmailaender/convex-better-auth-svelte/sveltekit';
import { api } from '@sgse-app/backend/convex/_generated/api'; import { api } from '@sgse-app/backend/convex/_generated/api';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import type { FunctionReference } from 'convex/server';
export const load = async ({ locals, url }) => { export const load = async ({ locals, url }) => {
try { if (!locals.token) {
const client = createConvexHttpClient({ token: locals.token }); return { currentUser: null };
const currentUser = await client.query(api.auth.getCurrentUser as FunctionReference<'query'>);
if (currentUser) {
const redirectTo = url.searchParams.get('redirect');
if (redirectTo && redirectTo.startsWith('/')) {
throw redirect(302, redirectTo);
}
throw redirect(302, '/');
}
} catch (error) {
// Se houver falha transitória na API/auth, ainda assim permitir renderizar a página de login.
console.error('Erro ao validar sessão na página de login:', error);
} }
return {}; const client = createConvexHttpClient({ token: locals.token });
const currentUser = await client.query(api.auth.getCurrentUser);
if (currentUser) {
const redirectTo = url.searchParams.get('redirect');
if (redirectTo && redirectTo.startsWith('/')) {
throw redirect(302, redirectTo);
}
throw redirect(302, '/');
}
return { currentUser };
}; };

View File

@@ -52,6 +52,7 @@ import type * as monitoramento from "../monitoramento.js";
import type * as objetos from "../objetos.js"; import type * as objetos from "../objetos.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 pontos from "../pontos.js"; import type * as pontos from "../pontos.js";
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js"; import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
import type * as pushNotifications from "../pushNotifications.js"; import type * as pushNotifications from "../pushNotifications.js";
@@ -77,6 +78,7 @@ import type * as tables_lgpdTables from "../tables/lgpdTables.js";
import type * as tables_licencas from "../tables/licencas.js"; import type * as tables_licencas from "../tables/licencas.js";
import type * as tables_objetos from "../tables/objetos.js"; import type * as tables_objetos from "../tables/objetos.js";
import type * as tables_pedidos from "../tables/pedidos.js"; import type * as tables_pedidos from "../tables/pedidos.js";
import type * as tables_planejamentos from "../tables/planejamentos.js";
import type * as tables_ponto from "../tables/ponto.js"; import type * as tables_ponto from "../tables/ponto.js";
import type * as tables_security from "../tables/security.js"; import type * as tables_security from "../tables/security.js";
import type * as tables_setores from "../tables/setores.js"; import type * as tables_setores from "../tables/setores.js";
@@ -144,6 +146,7 @@ declare const fullApi: ApiFromModules<{
objetos: typeof objetos; objetos: typeof objetos;
pedidos: typeof pedidos; pedidos: typeof pedidos;
permissoesAcoes: typeof permissoesAcoes; permissoesAcoes: typeof permissoesAcoes;
planejamentos: typeof planejamentos;
pontos: typeof pontos; pontos: typeof pontos;
preferenciasNotificacao: typeof preferenciasNotificacao; preferenciasNotificacao: typeof preferenciasNotificacao;
pushNotifications: typeof pushNotifications; pushNotifications: typeof pushNotifications;
@@ -169,6 +172,7 @@ declare const fullApi: ApiFromModules<{
"tables/licencas": typeof tables_licencas; "tables/licencas": typeof tables_licencas;
"tables/objetos": typeof tables_objetos; "tables/objetos": typeof tables_objetos;
"tables/pedidos": typeof tables_pedidos; "tables/pedidos": typeof tables_pedidos;
"tables/planejamentos": typeof tables_planejamentos;
"tables/ponto": typeof tables_ponto; "tables/ponto": typeof tables_ponto;
"tables/security": typeof tables_security; "tables/security": typeof tables_security;
"tables/setores": typeof tables_setores; "tables/setores": typeof tables_setores;

View File

@@ -4,14 +4,67 @@ import type { Id } from './_generated/dataModel';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
import { internal } from './_generated/api'; import { internal } from './_generated/api';
function normalizeLimitePercentual(value: number | undefined): number {
const fallback = 50;
if (value === undefined) return fallback;
if (!Number.isFinite(value)) return fallback;
if (value < 0) return 0;
if (value > 100) return 100;
return value;
}
function assertQuantidadeTotalValida(value: number) {
if (!Number.isFinite(value) || value <= 0) {
throw new Error('Quantidade do produto na ata deve ser maior que zero.');
}
}
export const list = query({ export const list = query({
args: {}, args: {
handler: async (ctx) => { periodoInicio: v.optional(v.string()),
periodoFim: v.optional(v.string()),
numero: v.optional(v.string()),
numeroSei: v.optional(v.string())
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'atas', recurso: 'atas',
acao: 'listar' acao: 'listar'
}); });
return await ctx.db.query('atas').collect();
const numero = args.numero?.trim().toLowerCase();
const numeroSei = args.numeroSei?.trim().toLowerCase();
const periodoInicio = args.periodoInicio || undefined;
const periodoFim = args.periodoFim || undefined;
const atas = await ctx.db.query('atas').collect();
return atas.filter((ata) => {
const numeroOk = !numero || (ata.numero || '').toLowerCase().includes(numero);
const seiOk = !numeroSei || (ata.numeroSei || '').toLowerCase().includes(numeroSei);
// Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado.
// Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis).
const ataInicio = ata.dataInicio ?? '0000-01-01';
const ataFimEfetivo = (() => {
const a = ata.dataFim;
const b = (ata as { dataProrrogacao?: string }).dataProrrogacao;
if (!a && !b) return '9999-12-31';
if (!a) return b!;
if (!b) return a;
return a >= b ? a : b;
})();
const periodoOk =
(!periodoInicio && !periodoFim) ||
(periodoInicio &&
periodoFim &&
ataInicio <= periodoFim &&
ataFimEfetivo >= periodoInicio) ||
(periodoInicio && !periodoFim && ataFimEfetivo >= periodoInicio) ||
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
return numeroOk && seiOk && periodoOk;
});
} }
}); });
@@ -43,6 +96,34 @@ export const getObjetos = query({
} }
}); });
export const getObjetosConfig = query({
args: { id: v.id('atas') },
returns: v.array(
v.object({
objetoId: v.id('objetos'),
quantidadeTotal: v.union(v.number(), v.null()),
limitePercentual: v.union(v.number(), v.null())
})
),
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'atas',
acao: 'ver'
});
const links = await ctx.db
.query('atasObjetos')
.withIndex('by_ataId', (q) => q.eq('ataId', args.id))
.collect();
return links.map((l) => ({
objetoId: l.objetoId,
quantidadeTotal: l.quantidadeTotal ?? null,
limitePercentual: l.limitePercentual ?? null
}));
}
});
export const listByObjetoIds = query({ export const listByObjetoIds = query({
args: { args: {
objetoIds: v.array(v.id('objetos')) objetoIds: v.array(v.id('objetos'))
@@ -78,9 +159,16 @@ export const create = mutation({
numero: v.string(), numero: v.string(),
dataInicio: v.optional(v.string()), dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()), dataFim: v.optional(v.string()),
dataProrrogacao: v.optional(v.string()),
empresaId: v.id('empresas'), empresaId: v.id('empresas'),
numeroSei: v.string(), numeroSei: v.string(),
objetosIds: v.array(v.id('objetos')) objetos: v.array(
v.object({
objetoId: v.id('objetos'),
quantidadeTotal: v.number(),
limitePercentual: v.optional(v.number())
})
)
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
@@ -97,16 +185,23 @@ export const create = mutation({
empresaId: args.empresaId, empresaId: args.empresaId,
dataInicio: args.dataInicio, dataInicio: args.dataInicio,
dataFim: args.dataFim, dataFim: args.dataFim,
dataProrrogacao: args.dataProrrogacao,
criadoPor: user._id, criadoPor: user._id,
criadoEm: Date.now(), criadoEm: Date.now(),
atualizadoEm: Date.now() atualizadoEm: Date.now()
}); });
// Vincular objetos // Vincular objetos
for (const objetoId of args.objetosIds) { for (const cfg of args.objetos) {
assertQuantidadeTotalValida(cfg.quantidadeTotal);
const limitePercentual = normalizeLimitePercentual(cfg.limitePercentual);
await ctx.db.insert('atasObjetos', { await ctx.db.insert('atasObjetos', {
ataId, ataId,
objetoId objetoId: cfg.objetoId,
quantidadeTotal: cfg.quantidadeTotal,
limitePercentual,
quantidadeUsada: 0
}); });
} }
@@ -122,7 +217,14 @@ export const update = mutation({
empresaId: v.id('empresas'), empresaId: v.id('empresas'),
dataInicio: v.optional(v.string()), dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()), dataFim: v.optional(v.string()),
objetosIds: v.array(v.id('objetos')) dataProrrogacao: v.optional(v.string()),
objetos: v.array(
v.object({
objetoId: v.id('objetos'),
quantidadeTotal: v.number(),
limitePercentual: v.optional(v.number())
})
)
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
@@ -139,26 +241,76 @@ export const update = mutation({
empresaId: args.empresaId, empresaId: args.empresaId,
dataInicio: args.dataInicio, dataInicio: args.dataInicio,
dataFim: args.dataFim, dataFim: args.dataFim,
dataProrrogacao: args.dataProrrogacao,
atualizadoEm: Date.now() atualizadoEm: Date.now()
}); });
// Atualizar objetos vinculados // Atualizar objetos vinculados
// Primeiro remove todos os vínculos existentes
const existingLinks = await ctx.db const existingLinks = await ctx.db
.query('atasObjetos') .query('atasObjetos')
.withIndex('by_ataId', (q) => q.eq('ataId', args.id)) .withIndex('by_ataId', (q) => q.eq('ataId', args.id))
.collect(); .collect();
const existingByObjeto = new Map<Id<'objetos'>, (typeof existingLinks)[number]>();
for (const link of existingLinks) { for (const link of existingLinks) {
await ctx.db.delete(link._id); existingByObjeto.set(link.objetoId, link);
} }
// Adiciona os novos vínculos const desiredObjetoIds = new Set<Id<'objetos'>>(args.objetos.map((o) => o.objetoId));
for (const objetoId of args.objetosIds) {
await ctx.db.insert('atasObjetos', { // Upsert dos vínculos desejados (preserva quantidadeUsada quando já existe)
ataId: args.id, for (const cfg of args.objetos) {
objetoId assertQuantidadeTotalValida(cfg.quantidadeTotal);
}); const limitePercentual = normalizeLimitePercentual(cfg.limitePercentual);
const existing = existingByObjeto.get(cfg.objetoId);
if (existing) {
await ctx.db.patch(existing._id, {
quantidadeTotal: cfg.quantidadeTotal,
limitePercentual
});
} else {
await ctx.db.insert('atasObjetos', {
ataId: args.id,
objetoId: cfg.objetoId,
quantidadeTotal: cfg.quantidadeTotal,
limitePercentual,
quantidadeUsada: 0
});
}
}
// Remoção de vínculos não selecionados (somente se não houver uso em pedidos não-cancelados)
for (const link of existingLinks) {
if (desiredObjetoIds.has(link.objetoId)) continue;
const items = await ctx.db
.query('objetoItems')
.withIndex('by_ataId_and_objetoId', (q) =>
q.eq('ataId', args.id).eq('objetoId', link.objetoId)
)
.collect();
// Se existe qualquer item em pedido não cancelado, bloquear remoção do vínculo
let inUse = false;
const seenPedidos = new Set<Id<'pedidos'>>();
for (const item of items) {
if (seenPedidos.has(item.pedidoId)) continue;
seenPedidos.add(item.pedidoId);
const pedido = await ctx.db.get(item.pedidoId);
if (pedido && pedido.status !== 'cancelado') {
inUse = true;
break;
}
}
if (inUse) {
throw new Error(
'Não é possível remover este objeto da ata porque já existe uso em pedidos não cancelados.'
);
}
await ctx.db.delete(link._id);
} }
} }
}); });

View File

@@ -8,8 +8,6 @@ import { type MutationCtx, type QueryCtx, query } from './_generated/server';
// Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão // Usar SITE_URL se disponível, caso contrário usar CONVEX_SITE_URL ou um valor padrão
const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173'; const siteUrl = process.env.SITE_URL || process.env.CONVEX_SITE_URL || 'http://localhost:5173';
console.log('siteUrl:', siteUrl);
// The component client has methods needed for integrating Convex with Better Auth, // The component client has methods needed for integrating Convex with Better Auth,
// as well as helper methods for general use. // as well as helper methods for general use.
export const authComponent = createClient<DataModel>(components.betterAuth); export const authComponent = createClient<DataModel>(components.betterAuth);

View File

@@ -5,15 +5,37 @@ import { getCurrentUserFunction } from './auth';
import type { Id } from './_generated/dataModel'; import type { Id } from './_generated/dataModel';
export const list = query({ export const list = query({
args: {}, args: {
handler: async (ctx) => { query: v.optional(v.string())
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'empresas', recurso: 'empresas',
acao: 'listar' acao: 'listar'
}); });
const empresas = await ctx.db.query('empresas').collect(); const empresas = await ctx.db.query('empresas').collect();
return empresas; const term = args.query?.trim();
if (!term) return empresas;
const termLower = term.toLowerCase();
const termDigits = term.replace(/\D/g, '');
return empresas.filter((empresa) => {
const razao = (empresa.razao_social || '').toLowerCase();
const fantasia = (empresa.nome_fantasia || '').toLowerCase();
const cnpjRaw = empresa.cnpj || '';
const cnpjLower = cnpjRaw.toLowerCase();
const cnpjDigits = cnpjRaw.replace(/\D/g, '');
const matchNome = razao.includes(termLower) || fantasia.includes(termLower);
const matchCnpj = termDigits
? cnpjDigits.includes(termDigits)
: cnpjLower.includes(termLower);
return matchNome || matchCnpj;
});
} }
}); });

View File

@@ -1,11 +1,184 @@
import { v } from 'convex/values'; import { v } from 'convex/values';
import { mutation, query } from './_generated/server'; import { mutation, query } from './_generated/server';
import type { Id } from './_generated/dataModel';
import type { QueryCtx } from './_generated/server';
import { getCurrentUserFunction } from './auth'; import { getCurrentUserFunction } from './auth';
import { addMonthsClampedYMD, getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
const ataComLimiteValidator = v.object({
_id: v.id('atas'),
numero: v.string(),
numeroSei: v.string(),
dataInicio: v.union(v.string(), v.null()),
dataFim: v.union(v.string(), v.null()),
dataProrrogacao: v.union(v.string(), v.null()),
dataFimEfetiva: v.union(v.string(), v.null()),
quantidadeTotal: v.union(v.number(), v.null()),
limitePercentual: v.number(),
quantidadeUsada: v.number(),
limitePermitido: v.number(),
restante: v.number(),
isLocked: v.boolean(),
isVencidaRecentemente: v.boolean(),
lockReason: v.union(
v.literal('vigencia_expirada'),
v.literal('limite_atingido'),
v.literal('nao_configurada'),
v.null()
)
});
async function computeAtasComLimiteForObjeto(
ctx: QueryCtx,
objetoId: Id<'objetos'>,
includeAtaIds?: Id<'atas'>[]
) {
const today = getTodayYMD();
const threeMonthsAgo = addMonthsClampedYMD(today, -3);
const includeSet = new Set<Id<'atas'>>(includeAtaIds ?? []);
const links = await ctx.db
.query('atasObjetos')
.withIndex('by_objetoId', (q) => q.eq('objetoId', objetoId))
.collect();
const results: Array<{
_id: Id<'atas'>;
numero: string;
numeroSei: string;
dataInicio: string | null;
dataFim: string | null;
dataProrrogacao: string | null;
dataFimEfetiva: string | null;
quantidadeTotal: number | null;
limitePercentual: number;
quantidadeUsada: number;
limitePermitido: number;
restante: number;
isLocked: boolean;
isVencidaRecentemente: boolean;
lockReason: 'vigencia_expirada' | 'limite_atingido' | 'nao_configurada' | null;
}> = [];
for (const link of links) {
const ata = await ctx.db.get(link.ataId);
if (!ata) continue;
const dataProrrogacao = (ata as { dataProrrogacao?: string }).dataProrrogacao ?? null;
const dataFimEfetiva = maxYMD(ata.dataFim ?? null, dataProrrogacao);
const vigenteHoje = isWithinRangeYMD(today, ata.dataInicio ?? null, dataFimEfetiva);
const isVencidaRecentemente =
!!dataFimEfetiva && dataFimEfetiva < today && dataFimEfetiva >= threeMonthsAgo;
// Para seleção em pedidos: manter somente vigentes e vencidas recentemente,
// mas permitir incluir atas específicas (ex.: já selecionadas em pedido antigo).
const shouldForceInclude = includeSet.has(ata._id);
if (!shouldForceInclude && !vigenteHoje && !isVencidaRecentemente) {
continue;
}
const isForaDaVigencia = !vigenteHoje;
let quantidadeUsada = link.quantidadeUsada ?? 0;
if (link.quantidadeUsada === undefined) {
const items = await ctx.db
.query('objetoItems')
.withIndex('by_ataId_and_objetoId', (q) =>
q.eq('ataId', link.ataId).eq('objetoId', link.objetoId)
)
.collect();
const sumByPedidoId = new Map<Id<'pedidos'>, number>();
for (const item of items) {
const prev = sumByPedidoId.get(item.pedidoId) ?? 0;
sumByPedidoId.set(item.pedidoId, prev + item.quantidade);
}
let total = 0;
for (const [pedidoId, sum] of sumByPedidoId.entries()) {
const pedido = await ctx.db.get(pedidoId);
if (pedido && pedido.status !== 'cancelado') {
total += sum;
}
}
quantidadeUsada = total;
}
const quantidadeTotal = link.quantidadeTotal ?? null;
const limitePercentualRaw = link.limitePercentual ?? 50;
const limitePercentual = Number.isFinite(limitePercentualRaw)
? Math.min(100, Math.max(0, limitePercentualRaw))
: 50;
const limitePermitido =
quantidadeTotal && quantidadeTotal > 0
? Math.floor(quantidadeTotal * (limitePercentual / 100))
: 0;
const restante = Math.max(0, limitePermitido - quantidadeUsada);
const lockReason: 'nao_configurada' | 'limite_atingido' | 'vigencia_expirada' | null =
!quantidadeTotal || quantidadeTotal <= 0
? 'nao_configurada'
: quantidadeUsada >= limitePermitido
? 'limite_atingido'
: isForaDaVigencia
? 'vigencia_expirada'
: null;
const isLocked = lockReason !== null;
results.push({
_id: ata._id,
numero: ata.numero,
numeroSei: ata.numeroSei,
dataInicio: ata.dataInicio ?? null,
dataFim: ata.dataFim ?? null,
dataProrrogacao,
dataFimEfetiva,
quantidadeTotal,
limitePercentual,
quantidadeUsada,
limitePermitido,
restante,
isLocked,
isVencidaRecentemente,
lockReason
});
}
return results;
}
export const list = query({ export const list = query({
args: {}, args: {
handler: async (ctx) => { nome: v.optional(v.string()),
return await ctx.db.query('objetos').collect(); tipo: v.optional(v.union(v.literal('material'), v.literal('servico'))),
codigos: v.optional(v.string())
},
handler: async (ctx, args) => {
const nome = args.nome?.trim();
const codigos = args.codigos?.trim().toLowerCase();
const base =
nome && nome.length > 0
? await ctx.db
.query('objetos')
.withSearchIndex('search_nome', (q) => q.search('nome', nome))
.collect()
: await ctx.db.query('objetos').collect();
return base.filter((objeto) => {
const tipoOk = !args.tipo || objeto.tipo === args.tipo;
const codigosOk =
!codigos ||
(objeto.codigoEfisco || '').toLowerCase().includes(codigos) ||
(objeto.codigoCatmat || '').toLowerCase().includes(codigos) ||
(objeto.codigoCatserv || '').toLowerCase().includes(codigos);
return tipoOk && codigosOk;
});
} }
}); });
@@ -120,6 +293,43 @@ export const getAtas = query({
} }
}); });
export const getAtasComLimite = query({
args: {
objetoId: v.id('objetos'),
// Permite incluir atas específicas (ex.: já selecionadas em um pedido antigo),
// mesmo que não estejam vigentes ou não tenham vencido nos últimos 3 meses.
includeAtaIds: v.optional(v.array(v.id('atas')))
},
returns: v.array(ataComLimiteValidator),
handler: async (ctx, args) => {
return await computeAtasComLimiteForObjeto(ctx, args.objetoId, args.includeAtaIds);
}
});
export const getAtasComLimiteBatch = query({
args: {
objetoIds: v.array(v.id('objetos')),
includeAtaIds: v.optional(v.array(v.id('atas')))
},
returns: v.array(
v.object({
objetoId: v.id('objetos'),
atas: v.array(ataComLimiteValidator)
})
),
handler: async (ctx, args) => {
if (args.objetoIds.length === 0) return [];
const include = args.includeAtaIds ?? [];
const out = [];
for (const objetoId of args.objetoIds) {
const atas = await computeAtasComLimiteForObjeto(ctx, objetoId, include);
out.push({ objetoId, atas });
}
return out;
}
});
export const remove = mutation({ export const remove = mutation({
args: { args: {
id: v.id('objetos') id: v.id('objetos')

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,662 @@
import { v } from 'convex/values';
import type { Doc, Id } from './_generated/dataModel';
import { mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
async function getUsuarioAutenticado(ctx: Parameters<typeof getCurrentUserFunction>[0]) {
const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized');
return user;
}
function normalizeOptionalString(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
// ========== QUERIES ==========
export const list = query({
args: {
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
statuses: v.optional(
v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')))
),
responsavelId: v.optional(v.id('funcionarios')),
acaoId: v.optional(v.id('acoes')),
texto: v.optional(v.string()),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
handler: async (ctx, args) => {
const { periodoInicio, periodoFim, texto } = args;
let base = await ctx.db.query('planejamentosPedidos').collect();
// Filtros em memória (devido à complexidade de múltiplos índices)
if (args.responsavelId) {
base = base.filter((p) => p.responsavelId === args.responsavelId);
}
if (args.acaoId) {
base = base.filter((p) => p.acaoId === args.acaoId);
}
// Status simples ou múltiplo
if (args.statuses && args.statuses.length > 0) {
base = base.filter((p) => args.statuses!.includes(p.status));
} else if (args.status) {
base = base.filter((p) => p.status === args.status);
}
if (periodoInicio) {
base = base.filter((p) => p.data >= new Date(periodoInicio).toISOString().split('T')[0]);
}
if (periodoFim) {
base = base.filter((p) => p.data <= new Date(periodoFim).toISOString().split('T')[0]);
}
if (texto) {
const t = texto.toLowerCase();
base = base.filter(
(p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t)
);
}
base.sort((a, b) => b.criadoEm - a.criadoEm);
return await Promise.all(
base.map(async (p) => {
const [responsavel, acao] = await Promise.all([
ctx.db.get(p.responsavelId),
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null)
]);
return {
...p,
responsavelNome: responsavel?.nome ?? 'Desconhecido',
acaoNome: acao?.nome ?? undefined
};
})
);
}
});
export const gerarRelatorio = query({
args: {
statuses: v.optional(
v.array(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')))
),
responsavelId: v.optional(v.id('funcionarios')),
acaoId: v.optional(v.id('acoes')),
texto: v.optional(v.string()),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
handler: async (ctx, args) => {
// Reutilizar lógica de filtro
let base = await ctx.db.query('planejamentosPedidos').collect();
if (args.responsavelId) {
base = base.filter((p) => p.responsavelId === args.responsavelId);
}
if (args.acaoId) {
base = base.filter((p) => p.acaoId === args.acaoId);
}
if (args.statuses && args.statuses.length > 0) {
base = base.filter((p) => args.statuses!.includes(p.status));
}
if (args.periodoInicio) {
base = base.filter(
(p) => p.data >= new Date(args.periodoInicio!).toISOString().split('T')[0]
);
}
if (args.periodoFim) {
base = base.filter((p) => p.data <= new Date(args.periodoFim!).toISOString().split('T')[0]);
}
if (args.texto) {
const t = args.texto.toLowerCase();
base = base.filter(
(p) => p.titulo.toLowerCase().includes(t) || p.descricao.toLowerCase().includes(t)
);
}
base.sort((a, b) => b.criadoEm - a.criadoEm);
// Enriquecer dados
const planejamentosEnriquecidos = await Promise.all(
base.map(async (p) => {
const [responsavel, acao, itens] = await Promise.all([
ctx.db.get(p.responsavelId),
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null),
ctx.db
.query('planejamentoItens')
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', p._id))
.collect()
]);
let valorEstimadoTotal = 0;
for (const item of itens) {
// Corrigir string '1.000,00' -> number
const val = parseFloat(
item.valorEstimado.replace(/\./g, '').replace(',', '.').replace('R$', '').trim()
);
if (!isNaN(val)) valorEstimadoTotal += val * item.quantidade;
}
return {
...p,
responsavelNome: responsavel?.nome ?? 'Desconhecido',
acaoNome: acao?.nome ?? undefined,
itensCount: itens.length,
valorEstimadoTotal
};
})
);
// Calcular resumo
const totalPlanejamentos = base.length;
const totalValorEstimado = planejamentosEnriquecidos.reduce(
(acc, curr) => acc + curr.valorEstimadoTotal,
0
);
const totalPorStatus = [
{ status: 'rascunho', count: 0 },
{ status: 'gerado', count: 0 },
{ status: 'cancelado', count: 0 }
];
base.forEach((p) => {
const st = totalPorStatus.find((s) => s.status === p.status);
if (st) st.count++;
});
return {
filtros: args,
resumo: {
totalPlanejamentos,
totalValorEstimado,
totalPorStatus
},
planejamentos: planejamentosEnriquecidos
};
}
});
export const get = query({
args: { id: v.id('planejamentosPedidos') },
handler: async (ctx, args) => {
const p = await ctx.db.get(args.id);
if (!p) return null;
const [responsavel, acao] = await Promise.all([
ctx.db.get(p.responsavelId),
p.acaoId ? ctx.db.get(p.acaoId) : Promise.resolve(null)
]);
return {
...p,
responsavelNome: responsavel?.nome ?? 'Desconhecido',
acaoNome: acao?.nome ?? undefined
};
}
});
export const listItems = query({
args: { planejamentoId: v.id('planejamentosPedidos') },
handler: async (ctx, args) => {
const items = await ctx.db
.query('planejamentoItens')
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
.collect();
// Ordenação útil: primeiro sem pedido, depois por numeroDfd, depois por criadoEm
items.sort((a, b) => {
const ap = a.pedidoId ? 1 : 0;
const bp = b.pedidoId ? 1 : 0;
if (ap !== bp) return ap - bp;
const ad = (a.numeroDfd ?? '').localeCompare(b.numeroDfd ?? '');
if (ad !== 0) return ad;
return a.criadoEm - b.criadoEm;
});
return await Promise.all(
items.map(async (it) => {
const [objeto, pedido] = await Promise.all([
ctx.db.get(it.objetoId),
it.pedidoId ? ctx.db.get(it.pedidoId) : Promise.resolve(null)
]);
return {
...it,
objetoNome: objeto?.nome ?? 'Objeto desconhecido',
objetoUnidade: objeto?.unidade ?? '',
pedidoNumeroSei: pedido?.numeroSei ?? undefined,
pedidoStatus: pedido?.status ?? undefined
};
})
);
}
});
export const listPedidos = query({
args: { planejamentoId: v.id('planejamentosPedidos') },
handler: async (ctx, args) => {
const links = await ctx.db
.query('planejamentoPedidosLinks')
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
.collect();
links.sort((a, b) => a.numeroDfd.localeCompare(b.numeroDfd));
return await Promise.all(
links.map(async (link) => {
const pedido = await ctx.db.get(link.pedidoId);
if (!pedido) {
return {
...link,
pedido: null,
lastHistory: []
};
}
const history = await ctx.db
.query('historicoPedidos')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', link.pedidoId))
.order('desc')
.take(3);
const historyWithNames = await Promise.all(
history.map(async (h) => {
const usuario = await ctx.db.get(h.usuarioId);
return {
...h,
usuarioNome: usuario?.nome ?? 'Desconhecido'
};
})
);
return {
...link,
pedido: {
_id: pedido._id,
numeroSei: pedido.numeroSei,
numeroDfd: pedido.numeroDfd,
status: pedido.status,
criadoEm: pedido.criadoEm,
atualizadoEm: pedido.atualizadoEm
},
lastHistory: historyWithNames
};
})
);
}
});
// ========== MUTATIONS ==========
export const create = mutation({
args: {
titulo: v.string(),
descricao: v.string(),
data: v.string(),
responsavelId: v.id('funcionarios'),
acaoId: v.optional(v.id('acoes')),
sourcePlanningId: v.optional(v.id('planejamentosPedidos'))
},
returns: v.id('planejamentosPedidos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const now = Date.now();
const titulo = args.titulo.trim();
const descricao = args.descricao.trim();
const data = args.data.trim();
if (!titulo) throw new Error('Informe um título.');
if (!descricao) throw new Error('Informe uma descrição.');
if (!data) throw new Error('Informe uma data.');
const newItemId = await ctx.db.insert('planejamentosPedidos', {
titulo,
descricao,
data,
responsavelId: args.responsavelId,
acaoId: args.acaoId,
status: 'rascunho',
criadoPor: user._id,
criadoEm: now,
atualizadoEm: now
});
const sourcePlanningId = args.sourcePlanningId;
if (sourcePlanningId) {
const sourceItems = await ctx.db
.query('planejamentoItens')
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', sourcePlanningId))
.collect();
for (const item of sourceItems) {
await ctx.db.insert('planejamentoItens', {
planejamentoId: newItemId,
objetoId: item.objetoId,
quantidade: item.quantidade,
valorEstimado: item.valorEstimado,
numeroDfd: item.numeroDfd,
// Não copiamos o pedidoId pois é um novo planejamento
criadoEm: now,
atualizadoEm: now
});
}
}
return newItemId;
}
});
export const update = mutation({
args: {
id: v.id('planejamentosPedidos'),
titulo: v.optional(v.string()),
descricao: v.optional(v.string()),
data: v.optional(v.string()),
responsavelId: v.optional(v.id('funcionarios')),
acaoId: v.optional(v.union(v.id('acoes'), v.null()))
},
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const p = await ctx.db.get(args.id);
if (!p) throw new Error('Planejamento não encontrado.');
if (p.status !== 'rascunho')
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
const patch: Partial<Doc<'planejamentosPedidos'>> & { acaoId?: Id<'acoes'> | undefined } = {};
if (args.titulo !== undefined) {
const t = args.titulo.trim();
if (!t) throw new Error('Título não pode ficar vazio.');
patch.titulo = t;
}
if (args.descricao !== undefined) {
const d = args.descricao.trim();
if (!d) throw new Error('Descrição não pode ficar vazia.');
patch.descricao = d;
}
if (args.data !== undefined) {
const dt = args.data.trim();
if (!dt) throw new Error('Data não pode ficar vazia.');
patch.data = dt;
}
if (args.responsavelId !== undefined) {
patch.responsavelId = args.responsavelId;
}
if (args.acaoId !== undefined) {
patch.acaoId = args.acaoId === null ? undefined : args.acaoId;
}
patch.atualizadoEm = Date.now();
await ctx.db.patch(args.id, patch);
return null;
}
});
export const addItem = mutation({
args: {
planejamentoId: v.id('planejamentosPedidos'),
objetoId: v.id('objetos'),
quantidade: v.number(),
valorEstimado: v.string(),
numeroDfd: v.optional(v.string())
},
returns: v.id('planejamentoItens'),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const p = await ctx.db.get(args.planejamentoId);
if (!p) throw new Error('Planejamento não encontrado.');
if (p.status !== 'rascunho')
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) {
throw new Error('Quantidade inválida.');
}
const now = Date.now();
const numeroDfd = normalizeOptionalString(args.numeroDfd);
const valorEstimado = args.valorEstimado.trim();
if (!valorEstimado) throw new Error('Valor estimado inválido.');
const itemId = await ctx.db.insert('planejamentoItens', {
planejamentoId: args.planejamentoId,
numeroDfd,
objetoId: args.objetoId,
quantidade: args.quantidade,
valorEstimado,
criadoEm: now,
atualizadoEm: now
});
await ctx.db.patch(args.planejamentoId, { atualizadoEm: Date.now() });
return itemId;
}
});
export const updateItem = mutation({
args: {
itemId: v.id('planejamentoItens'),
numeroDfd: v.optional(v.union(v.string(), v.null())),
quantidade: v.optional(v.number()),
valorEstimado: v.optional(v.string())
},
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const it = await ctx.db.get(args.itemId);
if (!it) throw new Error('Item não encontrado.');
const p = await ctx.db.get(it.planejamentoId);
if (!p) throw new Error('Planejamento não encontrado.');
if (p.status !== 'rascunho')
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
const patch: Partial<Doc<'planejamentoItens'>> = { atualizadoEm: Date.now() };
if (args.numeroDfd !== undefined) {
patch.numeroDfd =
args.numeroDfd === null
? undefined
: (normalizeOptionalString(args.numeroDfd) ?? undefined);
}
if (args.quantidade !== undefined) {
if (!Number.isFinite(args.quantidade) || args.quantidade <= 0) {
throw new Error('Quantidade inválida.');
}
patch.quantidade = args.quantidade;
}
if (args.valorEstimado !== undefined) {
const vEst = args.valorEstimado.trim();
if (!vEst) throw new Error('Valor estimado inválido.');
patch.valorEstimado = vEst;
}
await ctx.db.patch(args.itemId, patch);
await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() });
return null;
}
});
export const removeItem = mutation({
args: { itemId: v.id('planejamentoItens') },
returns: v.null(),
handler: async (ctx, args) => {
await getUsuarioAutenticado(ctx);
const it = await ctx.db.get(args.itemId);
if (!it) return null;
const p = await ctx.db.get(it.planejamentoId);
if (!p) throw new Error('Planejamento não encontrado.');
if (p.status !== 'rascunho')
throw new Error('Apenas planejamentos em rascunho podem ser editados.');
await ctx.db.delete(args.itemId);
await ctx.db.patch(it.planejamentoId, { atualizadoEm: Date.now() });
return null;
}
});
export const gerarPedidosPorDfd = mutation({
args: {
planejamentoId: v.id('planejamentosPedidos'),
dfds: v.array(
v.object({
numeroDfd: v.string(),
numeroSei: v.string()
})
)
},
returns: v.array(v.id('pedidos')),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
const planejamento = await ctx.db.get(args.planejamentoId);
if (!planejamento) throw new Error('Planejamento não encontrado.');
if (planejamento.status !== 'rascunho') {
throw new Error('Este planejamento não está em rascunho.');
}
const items = await ctx.db
.query('planejamentoItens')
.withIndex('by_planejamentoId', (q) => q.eq('planejamentoId', args.planejamentoId))
.collect();
if (items.length === 0) {
throw new Error('Adicione ao menos um item antes de gerar pedidos.');
}
const itensSemDfd = items.filter((i) => !i.numeroDfd || !i.numeroDfd.trim());
if (itensSemDfd.length > 0) {
throw new Error(
`Existem ${itensSemDfd.length} item(ns) sem DFD. Atribua um DFD a todos os itens antes de gerar pedidos.`
);
}
const dfdsPayload = args.dfds.map((d) => ({
numeroDfd: d.numeroDfd.trim(),
numeroSei: d.numeroSei.trim()
}));
if (dfdsPayload.length === 0) {
throw new Error('Informe ao menos um DFD para gerar.');
}
for (const d of dfdsPayload) {
if (!d.numeroDfd) throw new Error('DFD inválido.');
if (!d.numeroSei) throw new Error(`Informe o número SEI para o DFD ${d.numeroDfd}.`);
}
// Validar que todos os DFDs existem nos itens
const dfdsFromItems = new Set(items.map((i) => (i.numeroDfd as string).trim()));
for (const d of dfdsPayload) {
if (!dfdsFromItems.has(d.numeroDfd)) {
throw new Error(`DFD ${d.numeroDfd} não existe nos itens do planejamento.`);
}
}
// Evitar duplicidade de DFD no payload
const payloadSet = new Set<string>();
for (const d of dfdsPayload) {
if (payloadSet.has(d.numeroDfd)) throw new Error(`DFD duplicado no envio: ${d.numeroDfd}.`);
payloadSet.add(d.numeroDfd);
}
// Garantir que será gerado 1 pedido para CADA DFD existente nos itens
if (payloadSet.size !== dfdsFromItems.size) {
const missing = [...dfdsFromItems].filter((d) => !payloadSet.has(d));
throw new Error(`Informe o número SEI para todos os DFDs. Faltando: ${missing.join(', ')}.`);
}
// Não permitir gerar se algum item já tiver sido movido para pedido
const jaMovidos = items.filter((i) => i.pedidoId);
if (jaMovidos.length > 0) {
throw new Error('Este planejamento já possui itens vinculados a pedidos.');
}
const now = Date.now();
const pedidoIds: Id<'pedidos'>[] = [];
for (const dfd of dfdsPayload) {
// Criar pedido em rascunho (similar a pedidos.create)
const pedidoId = await ctx.db.insert('pedidos', {
numeroSei: dfd.numeroSei,
numeroDfd: dfd.numeroDfd,
status: 'em_rascunho',
criadoPor: user._id,
criadoEm: now,
atualizadoEm: now
});
pedidoIds.push(pedidoId);
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId: user._id,
acao: 'criacao',
detalhes: JSON.stringify({ numeroSei: dfd.numeroSei, numeroDfd: dfd.numeroDfd }),
data: now
});
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId: user._id,
acao: 'gerado_de_planejamento',
detalhes: JSON.stringify({ planejamentoId: args.planejamentoId, numeroDfd: dfd.numeroDfd }),
data: now
});
await ctx.db.insert('planejamentoPedidosLinks', {
planejamentoId: args.planejamentoId,
numeroDfd: dfd.numeroDfd,
pedidoId,
criadoEm: now
});
// Mover itens deste DFD para o pedido
const itensDfd = items.filter((i) => (i.numeroDfd as string).trim() === dfd.numeroDfd);
for (const it of itensDfd) {
// Criar item real diretamente no pedido (sem etapa de conversão)
await ctx.db.insert('objetoItems', {
pedidoId,
objetoId: it.objetoId,
ataId: undefined,
acaoId: undefined,
valorEstimado: it.valorEstimado,
quantidade: it.quantidade,
adicionadoPor: user.funcionarioId,
criadoEm: now
});
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId: user._id,
acao: 'adicao_item',
detalhes: JSON.stringify({
objetoId: it.objetoId,
valor: it.valorEstimado,
quantidade: it.quantidade,
acaoId: null,
ataId: null,
modalidade: null,
origem: { planejamentoId: args.planejamentoId }
}),
data: now
});
await ctx.db.patch(it._id, { pedidoId, atualizadoEm: Date.now() });
}
}
await ctx.db.patch(args.planejamentoId, { status: 'gerado', atualizadoEm: Date.now() });
return pedidoIds;
}
});

View File

@@ -14,6 +14,7 @@ import { funcionariosTables } from './tables/funcionarios';
import { licencasTables } from './tables/licencas'; import { licencasTables } from './tables/licencas';
import { objetosTables } from './tables/objetos'; import { objetosTables } from './tables/objetos';
import { pedidosTables } from './tables/pedidos'; import { pedidosTables } from './tables/pedidos';
import { planejamentosTables } from './tables/planejamentos';
import { pontoTables } from './tables/ponto'; import { pontoTables } from './tables/ponto';
import { securityTables } from './tables/security'; import { securityTables } from './tables/security';
import { setoresTables } from './tables/setores'; import { setoresTables } from './tables/setores';
@@ -42,6 +43,7 @@ export default defineSchema({
...securityTables, ...securityTables,
...pontoTables, ...pontoTables,
...pedidosTables, ...pedidosTables,
...planejamentosTables,
...objetosTables, ...objetosTables,
...atasTables, ...atasTables,
...lgpdTables ...lgpdTables

View File

@@ -6,6 +6,7 @@ export const atasTables = {
numero: v.string(), numero: v.string(),
dataInicio: v.optional(v.string()), dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()), dataFim: v.optional(v.string()),
dataProrrogacao: v.optional(v.string()),
empresaId: v.id('empresas'), empresaId: v.id('empresas'),
numeroSei: v.string(), numeroSei: v.string(),
criadoPor: v.id('usuarios'), criadoPor: v.id('usuarios'),
@@ -18,10 +19,16 @@ export const atasTables = {
atasObjetos: defineTable({ atasObjetos: defineTable({
ataId: v.id('atas'), ataId: v.id('atas'),
objetoId: v.id('objetos') objetoId: v.id('objetos'),
// Configuração de limite de uso por (ataId, objetoId)
quantidadeTotal: v.optional(v.number()),
limitePercentual: v.optional(v.number()), // padrão lógico: 50
// Controle transacional para evitar corrida; se ausente, pode ser inicializado via rebuild.
quantidadeUsada: v.optional(v.number())
}) })
.index('by_ataId', ['ataId']) .index('by_ataId', ['ataId'])
.index('by_objetoId', ['objetoId']), .index('by_objetoId', ['objetoId'])
.index('by_ataId_and_objetoId', ['ataId', 'objetoId']),
atasDocumentos: defineTable({ atasDocumentos: defineTable({
ataId: v.id('atas'), ataId: v.id('atas'),

View File

@@ -4,6 +4,7 @@ import { v } from 'convex/values';
export const pedidosTables = { export const pedidosTables = {
pedidos: defineTable({ pedidos: defineTable({
numeroSei: v.optional(v.string()), numeroSei: v.optional(v.string()),
numeroDfd: v.optional(v.string()),
status: v.union( status: v.union(
v.literal('em_rascunho'), v.literal('em_rascunho'),
v.literal('aguardando_aceite'), v.literal('aguardando_aceite'),
@@ -16,23 +17,31 @@ export const pedidosTables = {
criadoPor: v.id('usuarios'), criadoPor: v.id('usuarios'),
aceitoPor: v.optional(v.id('funcionarios')), aceitoPor: v.optional(v.id('funcionarios')),
descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes' descricaoAjuste: v.optional(v.string()), // Required when status is 'precisa_ajustes'
concluidoEm: v.optional(v.number()),
criadoEm: v.number(), criadoEm: v.number(),
atualizadoEm: v.number() atualizadoEm: v.number()
}) })
.index('by_numeroSei', ['numeroSei']) .index('by_numeroSei', ['numeroSei'])
.index('by_numeroDfd', ['numeroDfd'])
.index('by_status', ['status']) .index('by_status', ['status'])
.index('by_criadoPor', ['criadoPor']), .index('by_criadoPor', ['criadoPor'])
.index('by_aceitoPor', ['aceitoPor'])
.index('by_criadoEm', ['criadoEm'])
.index('by_concluidoEm', ['concluidoEm']),
objetoItems: defineTable({ objetoItems: defineTable({
pedidoId: v.id('pedidos'), pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'), // was produtoId objetoId: v.id('objetos'), // was produtoId
ataId: v.optional(v.id('atas')), ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')), // Moved from pedidos acaoId: v.optional(v.id('acoes')), // Moved from pedidos
modalidade: v.union( // Opcional: permite criar itens sem definir modalidade upfront (ex: geração via planejamento)
v.literal('dispensa'), modalidade: v.optional(
v.literal('inexgibilidade'), v.union(
v.literal('adesao'), v.literal('dispensa'),
v.literal('consumo') v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
)
), ),
valorEstimado: v.string(), valorEstimado: v.string(),
valorReal: v.optional(v.string()), valorReal: v.optional(v.string()),
@@ -42,6 +51,7 @@ export const pedidosTables = {
}) })
.index('by_pedidoId', ['pedidoId']) .index('by_pedidoId', ['pedidoId'])
.index('by_objetoId', ['objetoId']) .index('by_objetoId', ['objetoId'])
.index('by_ataId_and_objetoId', ['ataId', 'objetoId'])
.index('by_adicionadoPor', ['adicionadoPor']) .index('by_adicionadoPor', ['adicionadoPor'])
.index('by_acaoId', ['acaoId']), .index('by_acaoId', ['acaoId']),
@@ -70,5 +80,37 @@ export const pedidosTables = {
}) })
.index('by_pedidoId', ['pedidoId']) .index('by_pedidoId', ['pedidoId'])
.index('by_usuarioId', ['usuarioId']) .index('by_usuarioId', ['usuarioId'])
.index('by_data', ['data']) .index('by_data', ['data']),
// Documentos anexados diretamente ao pedido (ilimitado)
pedidoDocumentos: defineTable({
pedidoId: v.id('pedidos'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(), // MIME type
tamanho: v.number(), // bytes
criadoPor: v.id('funcionarios'),
criadoEm: v.number(),
origemSolicitacaoId: v.optional(v.id('solicitacoesItens'))
})
.index('by_pedidoId', ['pedidoId'])
.index('by_criadoPor', ['criadoPor'])
.index('by_origemSolicitacaoId', ['origemSolicitacaoId']),
// Documentos anexados a uma solicitação (somente solicitante; pode ter mais de um)
solicitacoesItensDocumentos: defineTable({
requestId: v.id('solicitacoesItens'),
pedidoId: v.id('pedidos'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(), // MIME type
tamanho: v.number(), // bytes
criadoPor: v.id('funcionarios'),
criadoEm: v.number()
})
.index('by_requestId', ['requestId'])
.index('by_pedidoId', ['pedidoId'])
.index('by_criadoPor', ['criadoPor'])
}; };

View File

@@ -0,0 +1,45 @@
import { defineTable } from 'convex/server';
import { v } from 'convex/values';
export const planejamentosTables = {
planejamentosPedidos: defineTable({
titulo: v.string(),
descricao: v.string(),
// Armazenar como yyyy-MM-dd para facilitar input type="date" no frontend.
data: v.string(),
responsavelId: v.id('funcionarios'),
acaoId: v.optional(v.id('acoes')),
status: v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado')),
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoEm: v.number()
})
.index('by_responsavelId', ['responsavelId'])
.index('by_status', ['status'])
.index('by_criadoEm', ['criadoEm']),
planejamentoItens: defineTable({
planejamentoId: v.id('planejamentosPedidos'),
// Opcional no cadastro; obrigatório para gerar pedidos.
numeroDfd: v.optional(v.string()),
objetoId: v.id('objetos'),
quantidade: v.number(),
valorEstimado: v.string(),
// Preenchido após a geração (itens foram materializados no pedido).
pedidoId: v.optional(v.id('pedidos')),
criadoEm: v.number(),
atualizadoEm: v.number()
})
.index('by_planejamentoId', ['planejamentoId'])
.index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd']),
planejamentoPedidosLinks: defineTable({
planejamentoId: v.id('planejamentosPedidos'),
numeroDfd: v.string(),
pedidoId: v.id('pedidos'),
criadoEm: v.number()
})
.index('by_planejamentoId', ['planejamentoId'])
.index('by_pedidoId', ['pedidoId'])
.index('by_planejamentoId_and_numeroDfd', ['planejamentoId', 'numeroDfd'])
};

View File

@@ -63,3 +63,69 @@ export function formatarDataBR(date: Date | string): string {
return `${day}/${month}/${year}`; return `${day}/${month}/${year}`;
} }
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/**
* Retorna a data atual (UTC) no formato YYYY-MM-DD.
* Útil para comparações lexicográficas com strings YYYY-MM-DD persistidas no banco.
*/
export function getTodayYMD(): string {
const now = new Date();
const y = now.getUTCFullYear();
const m = now.getUTCMonth() + 1;
const d = now.getUTCDate();
return `${y}-${pad2(m)}-${pad2(d)}`;
}
/**
* Soma meses (calendário) a uma data YYYY-MM-DD, mantendo o dia quando possível,
* e fazendo clamp para o último dia do mês quando necessário.
*
* Ex.: 2025-03-31 + (-1) mês => 2025-02-28 (ou 29 em ano bissexto)
*/
export function addMonthsClampedYMD(dateString: string, deltaMonths: number): string {
const base = parseLocalDate(dateString); // UTC midnight
const year = base.getUTCFullYear();
const monthIndex = base.getUTCMonth(); // 0..11
const day = base.getUTCDate();
const totalMonths = monthIndex + deltaMonths;
const newYear = year + Math.floor(totalMonths / 12);
let newMonthIndex = totalMonths % 12;
if (newMonthIndex < 0) {
newMonthIndex += 12;
}
// Último dia do mês alvo
const lastDay = new Date(Date.UTC(newYear, newMonthIndex + 1, 0)).getUTCDate();
const newDay = Math.min(day, lastDay);
return `${newYear}-${pad2(newMonthIndex + 1)}-${pad2(newDay)}`;
}
/**
* Retorna o maior (mais recente) entre duas datas YYYY-MM-DD (lexicograficamente).
* Se uma delas for null/undefined, retorna a outra.
*/
export function maxYMD(a?: string | null, b?: string | null): string | null {
if (!a && !b) return null;
if (!a) return b ?? null;
if (!b) return a;
return a >= b ? a : b;
}
/**
* Checa se `date` está dentro do intervalo [inicio..fim], onde
* `inicio` e `fim` são YYYY-MM-DD (ou null para aberto).
*/
export function isWithinRangeYMD(
date: string,
inicio?: string | null,
fim?: string | null
): boolean {
const start = inicio ?? '0000-01-01';
const end = fim ?? '9999-12-31';
return start <= date && date <= end;
}