Feat pedidos #70
@@ -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:
|
||||||
@@ -174,7 +174,8 @@
|
|||||||
{
|
{
|
||||||
label: 'Meus Processos',
|
label: 'Meus Processos',
|
||||||
link: '/fluxos',
|
link: '/fluxos',
|
||||||
permission: { recurso: 'fluxos_instancias', acao: 'listar' }
|
permission: { recurso: 'fluxos_instancias', acao: 'listar' },
|
||||||
|
exact: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Modelos de Fluxo',
|
label: 'Modelos de Fluxo',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -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}
|
|
||||||
@@ -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"
|
||||||
514
apps/web/src/routes/(dashboard)/fluxos/templates/+page.svelte
Normal file
514
apps/web/src/routes/(dashboard)/fluxos/templates/+page.svelte
Normal 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}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export const load = async ({ parent }) => {
|
|
||||||
await parent();
|
|
||||||
};
|
|
||||||
@@ -4,21 +4,112 @@
|
|||||||
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
import Breadcrumbs from '$lib/components/layout/Breadcrumbs.svelte';
|
||||||
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
import PageHeader from '$lib/components/layout/PageHeader.svelte';
|
||||||
import PageShell from '$lib/components/layout/PageShell.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 TableCard from '$lib/components/ui/TableCard.svelte';
|
||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { ClipboardList, Eye, Plus, X } from 'lucide-svelte';
|
import { ClipboardList, Eye, Plus, X, Copy, FileText, FileSpreadsheet } from 'lucide-svelte';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { toast } from 'svelte-sonner';
|
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();
|
const client = useConvexClient();
|
||||||
|
|
||||||
const planejamentosQuery = useQuery(api.planejamentos.list, {});
|
// 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 funcionariosQuery = useQuery(api.funcionarios.getAll, {});
|
||||||
const acoesQuery = useQuery(api.acoes.list, {});
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
let planejamentos = $derived(planejamentosQuery.data || []);
|
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) {
|
function formatStatus(status: string) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'rascunho':
|
case 'rascunho':
|
||||||
@@ -60,11 +151,31 @@
|
|||||||
descricao: '',
|
descricao: '',
|
||||||
data: '',
|
data: '',
|
||||||
responsavelId: '' as string,
|
responsavelId: '' as string,
|
||||||
acaoId: '' as string
|
acaoId: '' as string,
|
||||||
|
sourcePlanningId: '' as string
|
||||||
});
|
});
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
form = { titulo: '', descricao: '', data: '', responsavelId: '', acaoId: '' };
|
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;
|
showCreate = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +196,11 @@
|
|||||||
descricao: form.descricao,
|
descricao: form.descricao,
|
||||||
data: form.data,
|
data: form.data,
|
||||||
responsavelId: form.responsavelId as Id<'funcionarios'>,
|
responsavelId: form.responsavelId as Id<'funcionarios'>,
|
||||||
acaoId: form.acaoId ? (form.acaoId as Id<'acoes'>) : undefined
|
|
||||||
|
acaoId: form.acaoId ? (form.acaoId as Id<'acoes'>) : undefined,
|
||||||
|
sourcePlanningId: form.sourcePlanningId
|
||||||
|
? (form.sourcePlanningId as Id<'planejamentosPedidos'>)
|
||||||
|
: undefined
|
||||||
});
|
});
|
||||||
toast.success('Planejamento criado.');
|
toast.success('Planejamento criado.');
|
||||||
showCreate = false;
|
showCreate = false;
|
||||||
@@ -116,6 +231,36 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet actions()}
|
{#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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
class="btn btn-primary gap-2 shadow-md transition-all hover:shadow-lg"
|
||||||
@@ -127,6 +272,106 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</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}
|
{#if planejamentosQuery.isLoading}
|
||||||
<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>
|
||||||
@@ -140,23 +385,23 @@
|
|||||||
<table class="table-zebra table w-full">
|
<table class="table-zebra table w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Título</th
|
>Título</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Data</th
|
>Data</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Responsável</th
|
>Responsável</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Ação</th
|
>Ação</th
|
||||||
>
|
>
|
||||||
<th class="text-base-content border-base-400 border-b font-bold whitespace-nowrap"
|
<th class="text-base-content border-base-300 border-b font-bold whitespace-nowrap"
|
||||||
>Status</th
|
>Status</th
|
||||||
>
|
>
|
||||||
<th
|
<th
|
||||||
class="text-base-content border-base-400 border-b text-right font-bold whitespace-nowrap"
|
class="text-base-content border-base-300 border-b text-right font-bold whitespace-nowrap"
|
||||||
>Ações</th
|
>Ações</th
|
||||||
>
|
>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -167,7 +412,7 @@
|
|||||||
<td colspan="6" class="py-12 text-center">
|
<td colspan="6" 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 planejamento encontrado</p>
|
<p class="text-lg font-semibold">Nenhum planejamento encontrado</p>
|
||||||
<p class="text-sm">Clique em “Novo planejamento” para criar o primeiro.</p>
|
<p class="text-sm">Tente ajustar os filtros ou crie um novo planejamento.</p>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -184,14 +429,24 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right whitespace-nowrap">
|
<td class="text-right whitespace-nowrap">
|
||||||
<a
|
<div class="flex items-center justify-end gap-2">
|
||||||
href={resolve(`/pedidos/planejamento/${p._id}`)}
|
<a
|
||||||
class="btn btn-ghost btn-sm gap-2"
|
href={resolve(`/pedidos/planejamento/${p._id}`)}
|
||||||
aria-label="Abrir planejamento"
|
class="btn btn-ghost btn-sm gap-2"
|
||||||
>
|
aria-label="Abrir planejamento"
|
||||||
<Eye class="h-4 w-4" />
|
>
|
||||||
Abrir
|
<Eye class="h-4 w-4" />
|
||||||
</a>
|
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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -214,7 +469,9 @@
|
|||||||
<X class="h-5 w-5" />
|
<X class="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<h3 class="text-lg font-bold">Novo planejamento</h3>
|
<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="mt-6 grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<div class="form-control w-full md:col-span-2">
|
<div class="form-control w-full md:col-span-2">
|
||||||
@@ -299,7 +556,13 @@
|
|||||||
{#if creating}
|
{#if creating}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{creating ? 'Criando...' : 'Criar'}
|
{creating
|
||||||
|
? form.sourcePlanningId
|
||||||
|
? 'Clonando...'
|
||||||
|
: 'Criando...'
|
||||||
|
: form.sourcePlanningId
|
||||||
|
? 'Clonar'
|
||||||
|
: 'Criar'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,9 @@
|
|||||||
import { useConvexClient, useQuery } from 'convex-svelte';
|
import { useConvexClient, useQuery } from 'convex-svelte';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { Plus, Trash2, X, Save, Edit } from 'lucide-svelte';
|
import { Plus, Trash2, X, Save, Edit, Copy } from 'lucide-svelte';
|
||||||
|
|
||||||
const client = useConvexClient();
|
const client = useConvexClient();
|
||||||
const planejamentoId = $derived(page.params.id as Id<'planejamentosPedidos'>);
|
const planejamentoId = $derived(page.params.id as Id<'planejamentosPedidos'>);
|
||||||
@@ -323,6 +324,63 @@
|
|||||||
gerando = false;
|
gerando = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Clone/Create Modal logic (duplicated from list page for now) ---
|
||||||
|
let showCreate = $state(false);
|
||||||
|
let creating = $state(false);
|
||||||
|
let createForm = $state({
|
||||||
|
titulo: '',
|
||||||
|
descricao: '',
|
||||||
|
data: '',
|
||||||
|
responsavelId: '' as string,
|
||||||
|
acaoId: '' as string,
|
||||||
|
sourcePlanningId: '' as string
|
||||||
|
});
|
||||||
|
|
||||||
|
function openClone() {
|
||||||
|
if (!planejamento) return;
|
||||||
|
createForm = {
|
||||||
|
titulo: `${planejamento.titulo} (Cópia)`,
|
||||||
|
descricao: planejamento.descricao,
|
||||||
|
data: planejamento.data,
|
||||||
|
responsavelId: planejamento.responsavelId,
|
||||||
|
acaoId: planejamento.acaoId || '',
|
||||||
|
sourcePlanningId: planejamento._id
|
||||||
|
};
|
||||||
|
showCreate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreate() {
|
||||||
|
showCreate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!createForm.titulo.trim()) return toast.error('Informe um título.');
|
||||||
|
if (!createForm.descricao.trim()) return toast.error('Informe uma descrição.');
|
||||||
|
if (!createForm.data.trim()) return toast.error('Informe uma data.');
|
||||||
|
if (!createForm.responsavelId) return toast.error('Selecione um responsável.');
|
||||||
|
|
||||||
|
creating = true;
|
||||||
|
try {
|
||||||
|
const id = await client.mutation(api.planejamentos.create, {
|
||||||
|
titulo: createForm.titulo,
|
||||||
|
descricao: createForm.descricao,
|
||||||
|
data: createForm.data,
|
||||||
|
responsavelId: createForm.responsavelId as Id<'funcionarios'>,
|
||||||
|
acaoId: createForm.acaoId ? (createForm.acaoId as Id<'acoes'>) : undefined,
|
||||||
|
sourcePlanningId: createForm.sourcePlanningId
|
||||||
|
? (createForm.sourcePlanningId as Id<'planejamentosPedidos'>)
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
|
toast.success('Planejamento clonado com sucesso.');
|
||||||
|
showCreate = false;
|
||||||
|
await goto(resolve(`/pedidos/planejamento/${id}`));
|
||||||
|
} catch (e) {
|
||||||
|
toast.error((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<PageShell>
|
<PageShell>
|
||||||
@@ -375,6 +433,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex shrink-0 flex-col items-end gap-2">
|
<div class="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
<button class="btn btn-ghost btn-sm gap-2" onclick={openClone}>
|
||||||
|
<Copy class="h-4 w-4" />
|
||||||
|
Clonar
|
||||||
|
</button>
|
||||||
{#if isRascunho}
|
{#if isRascunho}
|
||||||
{#if editingHeader}
|
{#if editingHeader}
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
@@ -803,9 +865,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-action">
|
<div class="modal-action">
|
||||||
<button type="button" class="btn" onclick={closeAddItemModal} disabled={addingItem}>
|
<button type="button" class="btn" onclick={closeAddItemModal} disabled={addingItem}
|
||||||
Cancelar
|
>Cancelar</button
|
||||||
</button>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@@ -815,15 +877,123 @@
|
|||||||
{#if addingItem}
|
{#if addingItem}
|
||||||
<span class="loading loading-spinner loading-sm"></span>
|
<span class="loading loading-spinner loading-sm"></span>
|
||||||
{/if}
|
{/if}
|
||||||
{addingItem ? 'Adicionando...' : 'Adicionar'}
|
Adicionar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button type="button" class="modal-backdrop" onclick={closeAddItemModal} aria-label="Fechar"
|
||||||
type="button"
|
></button>
|
||||||
class="modal-backdrop"
|
</div>
|
||||||
onclick={closeAddItemModal}
|
{/if}
|
||||||
aria-label="Fechar modal"
|
|
||||||
|
{#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">Clonar 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="c_titulo">
|
||||||
|
<span class="label-text font-semibold">Título</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="c_titulo"
|
||||||
|
type="text"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={createForm.titulo}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full md:col-span-2">
|
||||||
|
<label class="label" for="c_descricao">
|
||||||
|
<span class="label-text font-semibold">Descrição</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="c_descricao"
|
||||||
|
class="textarea textarea-bordered focus:textarea-primary w-full"
|
||||||
|
rows="4"
|
||||||
|
bind:value={createForm.descricao}
|
||||||
|
disabled={creating}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="c_data">
|
||||||
|
<span class="label-text font-semibold">Data</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="c_data"
|
||||||
|
type="date"
|
||||||
|
class="input input-bordered focus:input-primary w-full"
|
||||||
|
bind:value={createForm.data}
|
||||||
|
disabled={creating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-control w-full">
|
||||||
|
<label class="label" for="c_responsavel">
|
||||||
|
<span class="label-text font-semibold">Responsável</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="c_responsavel"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={createForm.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="c_acao">
|
||||||
|
<span class="label-text font-semibold">Ação (opcional)</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="c_acao"
|
||||||
|
class="select select-bordered focus:select-primary w-full"
|
||||||
|
bind:value={createForm.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}
|
||||||
|
Clonar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="modal-backdrop" onclick={closeCreate} aria-label="Fechar"
|
||||||
></button>
|
></button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -19,26 +19,47 @@ function normalizeOptionalString(value: string | undefined): string | undefined
|
|||||||
export const list = query({
|
export const list = query({
|
||||||
args: {
|
args: {
|
||||||
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
|
status: v.optional(v.union(v.literal('rascunho'), v.literal('gerado'), v.literal('cancelado'))),
|
||||||
responsavelId: v.optional(v.id('funcionarios'))
|
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) => {
|
handler: async (ctx, args) => {
|
||||||
const status = args.status;
|
const { periodoInicio, periodoFim, texto } = args;
|
||||||
const responsavelId = args.responsavelId;
|
|
||||||
|
|
||||||
let base: Doc<'planejamentosPedidos'>[] = [];
|
let base = await ctx.db.query('planejamentosPedidos').collect();
|
||||||
|
|
||||||
if (responsavelId) {
|
// Filtros em memória (devido à complexidade de múltiplos índices)
|
||||||
base = await ctx.db
|
if (args.responsavelId) {
|
||||||
.query('planejamentosPedidos')
|
base = base.filter((p) => p.responsavelId === args.responsavelId);
|
||||||
.withIndex('by_responsavelId', (q) => q.eq('responsavelId', responsavelId))
|
}
|
||||||
.collect();
|
if (args.acaoId) {
|
||||||
} else if (status) {
|
base = base.filter((p) => p.acaoId === args.acaoId);
|
||||||
base = await ctx.db
|
}
|
||||||
.query('planejamentosPedidos')
|
|
||||||
.withIndex('by_status', (q) => q.eq('status', status))
|
// Status simples ou múltiplo
|
||||||
.collect();
|
if (args.statuses && args.statuses.length > 0) {
|
||||||
} else {
|
base = base.filter((p) => args.statuses!.includes(p.status));
|
||||||
base = await ctx.db.query('planejamentosPedidos').collect();
|
} 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);
|
base.sort((a, b) => b.criadoEm - a.criadoEm);
|
||||||
@@ -59,6 +80,108 @@ export const list = query({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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({
|
export const get = query({
|
||||||
args: { id: v.id('planejamentosPedidos') },
|
args: { id: v.id('planejamentosPedidos') },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -177,7 +300,8 @@ export const create = mutation({
|
|||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
data: v.string(),
|
data: v.string(),
|
||||||
responsavelId: v.id('funcionarios'),
|
responsavelId: v.id('funcionarios'),
|
||||||
acaoId: v.optional(v.id('acoes'))
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
sourcePlanningId: v.optional(v.id('planejamentosPedidos'))
|
||||||
},
|
},
|
||||||
returns: v.id('planejamentosPedidos'),
|
returns: v.id('planejamentosPedidos'),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -192,7 +316,7 @@ export const create = mutation({
|
|||||||
if (!descricao) throw new Error('Informe uma descrição.');
|
if (!descricao) throw new Error('Informe uma descrição.');
|
||||||
if (!data) throw new Error('Informe uma data.');
|
if (!data) throw new Error('Informe uma data.');
|
||||||
|
|
||||||
return await ctx.db.insert('planejamentosPedidos', {
|
const newItemId = await ctx.db.insert('planejamentosPedidos', {
|
||||||
titulo,
|
titulo,
|
||||||
descricao,
|
descricao,
|
||||||
data,
|
data,
|
||||||
@@ -203,6 +327,30 @@ export const create = mutation({
|
|||||||
criadoEm: now,
|
criadoEm: now,
|
||||||
atualizadoEm: 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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user