Merge remote-tracking branch 'origin/master' into ajustes_gerais
This commit is contained in:
@@ -124,4 +124,4 @@ async function updateStatus(newStatus: string) {
|
|||||||
// ...
|
// ...
|
||||||
status: newStatus as any; // Avoid this
|
status: newStatus as any; // Avoid this
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2,185 +2,191 @@
|
|||||||
|
|
||||||
/** Remove all non-digit characters from string */
|
/** Remove all non-digit characters from string */
|
||||||
export const onlyDigits = (value: string): string => {
|
export const onlyDigits = (value: string): string => {
|
||||||
return (value || "").replace(/\D/g, "");
|
return (value || '').replace(/\D/g, '');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format CPF: 000.000.000-00 */
|
/** Format CPF: 000.000.000-00 */
|
||||||
export const maskCPF = (value: string): string => {
|
export const maskCPF = (value: string): string => {
|
||||||
const digits = onlyDigits(value).slice(0, 11);
|
const digits = onlyDigits(value).slice(0, 11);
|
||||||
return digits
|
return digits
|
||||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||||
.replace(/(\d{3})(\d{1,2})$/, "$1-$2");
|
.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Validate CPF format and checksum */
|
/** Validate CPF format and checksum */
|
||||||
export const validateCPF = (value: string): boolean => {
|
export const validateCPF = (value: string): boolean => {
|
||||||
const digits = onlyDigits(value);
|
const digits = onlyDigits(value);
|
||||||
|
|
||||||
if (digits.length !== 11 || /^([0-9])\1+$/.test(digits)) {
|
if (digits.length !== 11 || /^([0-9])\1+$/.test(digits)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const calculateDigit = (base: string, factor: number): number => {
|
const calculateDigit = (base: string, factor: number): number => {
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
for (let i = 0; i < base.length; i++) {
|
for (let i = 0; i < base.length; i++) {
|
||||||
sum += parseInt(base[i]) * (factor - i);
|
sum += parseInt(base[i]) * (factor - i);
|
||||||
}
|
}
|
||||||
const rest = (sum * 10) % 11;
|
const rest = (sum * 10) % 11;
|
||||||
return rest === 10 ? 0 : rest;
|
return rest === 10 ? 0 : rest;
|
||||||
};
|
};
|
||||||
|
|
||||||
const digit1 = calculateDigit(digits.slice(0, 9), 10);
|
const digit1 = calculateDigit(digits.slice(0, 9), 10);
|
||||||
const digit2 = calculateDigit(digits.slice(0, 10), 11);
|
const digit2 = calculateDigit(digits.slice(0, 10), 11);
|
||||||
|
|
||||||
return digits[9] === String(digit1) && digits[10] === String(digit2);
|
return digits[9] === String(digit1) && digits[10] === String(digit2);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format CEP: 00000-000 */
|
/** Format CEP: 00000-000 */
|
||||||
export const maskCEP = (value: string): string => {
|
export const maskCEP = (value: string): string => {
|
||||||
const digits = onlyDigits(value).slice(0, 8);
|
const digits = onlyDigits(value).slice(0, 8);
|
||||||
return digits.replace(/(\d{5})(\d{1,3})$/, "$1-$2");
|
return digits.replace(/(\d{5})(\d{1,3})$/, '$1-$2');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format CNPJ: 00.000.000/0000-00 */
|
/** Format CNPJ: 00.000.000/0000-00 */
|
||||||
export const maskCNPJ = (value: string): string => {
|
export const maskCNPJ = (value: string): string => {
|
||||||
const digits = onlyDigits(value).slice(0, 14);
|
const digits = onlyDigits(value).slice(0, 14);
|
||||||
return digits
|
return digits
|
||||||
.replace(/(\d{2})(\d)/, "$1.$2")
|
.replace(/(\d{2})(\d)/, '$1.$2')
|
||||||
.replace(/(\d{3})(\d)/, "$1.$2")
|
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||||
.replace(/(\d{3})(\d)/, "$1/$2")
|
.replace(/(\d{3})(\d)/, '$1/$2')
|
||||||
.replace(/(\d{4})(\d{1,2})$/, "$1-$2");
|
.replace(/(\d{4})(\d{1,2})$/, '$1-$2');
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format phone: (00) 0000-0000 or (00) 00000-0000 */
|
/** Format phone: (00) 0000-0000 or (00) 00000-0000 */
|
||||||
export const maskPhone = (value: string): string => {
|
export const maskPhone = (value: string): string => {
|
||||||
const digits = onlyDigits(value).slice(0, 11);
|
const digits = onlyDigits(value).slice(0, 11);
|
||||||
|
|
||||||
if (digits.length <= 10) {
|
if (digits.length <= 10) {
|
||||||
return digits
|
return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{4})(\d{1,4})$/, '$1-$2');
|
||||||
.replace(/(\d{2})(\d)/, "($1) $2")
|
}
|
||||||
.replace(/(\d{4})(\d{1,4})$/, "$1-$2");
|
|
||||||
}
|
return digits.replace(/(\d{2})(\d)/, '($1) $2').replace(/(\d{5})(\d{1,4})$/, '$1-$2');
|
||||||
|
|
||||||
return digits
|
|
||||||
.replace(/(\d{2})(\d)/, "($1) $2")
|
|
||||||
.replace(/(\d{5})(\d{1,4})$/, "$1-$2");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format date: dd/mm/aaaa */
|
/** Format date: dd/mm/aaaa */
|
||||||
export const maskDate = (value: string): string => {
|
export const maskDate = (value: string): string => {
|
||||||
const digits = onlyDigits(value).slice(0, 8);
|
const digits = onlyDigits(value).slice(0, 8);
|
||||||
return digits
|
return digits.replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d{1,4})$/, '$1/$2');
|
||||||
.replace(/(\d{2})(\d)/, "$1/$2")
|
|
||||||
.replace(/(\d{2})(\d{1,4})$/, "$1/$2");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Validate date in format dd/mm/aaaa */
|
/** Validate date in format dd/mm/aaaa */
|
||||||
export const validateDate = (value: string): boolean => {
|
export const validateDate = (value: string): boolean => {
|
||||||
const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
const match = value.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
||||||
if (!match) return false;
|
if (!match) return false;
|
||||||
|
|
||||||
const day = Number(match[1]);
|
const day = Number(match[1]);
|
||||||
const month = Number(match[2]) - 1;
|
const month = Number(match[2]) - 1;
|
||||||
const year = Number(match[3]);
|
const year = Number(match[3]);
|
||||||
|
|
||||||
const date = new Date(year, month, day);
|
const date = new Date(year, month, day);
|
||||||
|
|
||||||
return (
|
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day;
|
||||||
date.getFullYear() === year &&
|
|
||||||
date.getMonth() === month &&
|
|
||||||
date.getDate() === day
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format UF: uppercase, max 2 chars */
|
/** Format UF: uppercase, max 2 chars */
|
||||||
export const maskUF = (value: string): string => {
|
export const maskUF = (value: string): string => {
|
||||||
return (value || "").toUpperCase().replace(/[^A-Z]/g, "").slice(0, 2);
|
return (value || '')
|
||||||
|
.toUpperCase()
|
||||||
|
.replace(/[^A-Z]/g, '')
|
||||||
|
.slice(0, 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format RG by UF */
|
/** Format RG by UF */
|
||||||
const rgFormatByUF: Record<string, [number, number, number, number]> = {
|
const rgFormatByUF: Record<string, [number, number, number, number]> = {
|
||||||
RJ: [2, 3, 2, 1],
|
RJ: [2, 3, 2, 1],
|
||||||
SP: [2, 3, 3, 1],
|
SP: [2, 3, 3, 1],
|
||||||
MG: [2, 3, 3, 1],
|
MG: [2, 3, 3, 1],
|
||||||
ES: [2, 3, 3, 1],
|
ES: [2, 3, 3, 1],
|
||||||
PR: [2, 3, 3, 1],
|
PR: [2, 3, 3, 1],
|
||||||
SC: [2, 3, 3, 1],
|
SC: [2, 3, 3, 1],
|
||||||
RS: [2, 3, 3, 1],
|
RS: [2, 3, 3, 1],
|
||||||
BA: [2, 3, 3, 1],
|
BA: [2, 3, 3, 1],
|
||||||
PE: [2, 3, 3, 1],
|
PE: [2, 3, 3, 1],
|
||||||
CE: [2, 3, 3, 1],
|
CE: [2, 3, 3, 1],
|
||||||
PA: [2, 3, 3, 1],
|
PA: [2, 3, 3, 1],
|
||||||
AM: [2, 3, 3, 1],
|
AM: [2, 3, 3, 1],
|
||||||
AC: [2, 3, 3, 1],
|
AC: [2, 3, 3, 1],
|
||||||
AP: [2, 3, 3, 1],
|
AP: [2, 3, 3, 1],
|
||||||
AL: [2, 3, 3, 1],
|
AL: [2, 3, 3, 1],
|
||||||
RN: [2, 3, 3, 1],
|
RN: [2, 3, 3, 1],
|
||||||
PB: [2, 3, 3, 1],
|
PB: [2, 3, 3, 1],
|
||||||
MA: [2, 3, 3, 1],
|
MA: [2, 3, 3, 1],
|
||||||
PI: [2, 3, 3, 1],
|
PI: [2, 3, 3, 1],
|
||||||
DF: [2, 3, 3, 1],
|
DF: [2, 3, 3, 1],
|
||||||
GO: [2, 3, 3, 1],
|
GO: [2, 3, 3, 1],
|
||||||
MT: [2, 3, 3, 1],
|
MT: [2, 3, 3, 1],
|
||||||
MS: [2, 3, 3, 1],
|
MS: [2, 3, 3, 1],
|
||||||
RO: [2, 3, 3, 1],
|
RO: [2, 3, 3, 1],
|
||||||
RR: [2, 3, 3, 1],
|
RR: [2, 3, 3, 1],
|
||||||
TO: [2, 3, 3, 1],
|
TO: [2, 3, 3, 1]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const maskRGByUF = (uf: string, value: string): string => {
|
export const maskRGByUF = (uf: string, value: string): string => {
|
||||||
const raw = (value || "").toUpperCase().replace(/[^0-9X]/g, "");
|
const raw = (value || '').toUpperCase().replace(/[^0-9X]/g, '');
|
||||||
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
|
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
|
||||||
const baseMax = a + b + c;
|
const baseMax = a + b + c;
|
||||||
const baseDigits = raw.replace(/X/g, "").slice(0, baseMax);
|
const baseDigits = raw.replace(/X/g, '').slice(0, baseMax);
|
||||||
const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1);
|
const verifier = raw.slice(baseDigits.length, baseDigits.length + dv).slice(0, 1);
|
||||||
|
|
||||||
const g1 = baseDigits.slice(0, a);
|
const g1 = baseDigits.slice(0, a);
|
||||||
const g2 = baseDigits.slice(a, a + b);
|
const g2 = baseDigits.slice(a, a + b);
|
||||||
const g3 = baseDigits.slice(a + b, a + b + c);
|
const g3 = baseDigits.slice(a + b, a + b + c);
|
||||||
|
|
||||||
let formatted = g1;
|
let formatted = g1;
|
||||||
if (g2) formatted += `.${g2}`;
|
if (g2) formatted += `.${g2}`;
|
||||||
if (g3) formatted += `.${g3}`;
|
if (g3) formatted += `.${g3}`;
|
||||||
if (verifier) formatted += `-${verifier}`;
|
if (verifier) formatted += `-${verifier}`;
|
||||||
|
|
||||||
return formatted;
|
return formatted;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const padRGLeftByUF = (uf: string, value: string): string => {
|
export const padRGLeftByUF = (uf: string, value: string): string => {
|
||||||
const raw = (value || "").toUpperCase().replace(/[^0-9X]/g, "");
|
const raw = (value || '').toUpperCase().replace(/[^0-9X]/g, '');
|
||||||
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
|
const [a, b, c, dv] = rgFormatByUF[uf?.toUpperCase()] ?? [2, 3, 3, 1];
|
||||||
const baseMax = a + b + c;
|
const baseMax = a + b + c;
|
||||||
let base = raw.replace(/X/g, "");
|
let base = raw.replace(/X/g, '');
|
||||||
const verifier = raw.slice(base.length, base.length + dv).slice(0, 1);
|
const verifier = raw.slice(base.length, base.length + dv).slice(0, 1);
|
||||||
|
|
||||||
if (base.length < baseMax) {
|
if (base.length < baseMax) {
|
||||||
base = base.padStart(baseMax, "0");
|
base = base.padStart(baseMax, '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
return maskRGByUF(uf, base + (verifier || ""));
|
return maskRGByUF(uf, base + (verifier || ''));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format account number */
|
/** Format account number */
|
||||||
export const maskContaBancaria = (value: string): string => {
|
export const maskContaBancaria = (value: string): string => {
|
||||||
const digits = onlyDigits(value);
|
const digits = onlyDigits(value);
|
||||||
return digits;
|
return digits;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format zone and section for voter title */
|
/** Format zone and section for voter title */
|
||||||
export const maskZonaSecao = (value: string): string => {
|
export const maskZonaSecao = (value: string): string => {
|
||||||
const digits = onlyDigits(value).slice(0, 4);
|
const digits = onlyDigits(value).slice(0, 4);
|
||||||
return digits;
|
return digits;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Format general numeric field */
|
/** Format general numeric field */
|
||||||
export const maskNumeric = (value: string): string => {
|
export const maskNumeric = (value: string): string => {
|
||||||
return onlyDigits(value);
|
return onlyDigits(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format Brazilian currency (e.g. R$ 1.234,56) */
|
||||||
|
export const maskCurrencyBRL = (value: string): string => {
|
||||||
|
const digits = onlyDigits(value);
|
||||||
|
if (!digits) return '';
|
||||||
|
|
||||||
|
const int = parseInt(digits, 10);
|
||||||
|
const amount = int / 100;
|
||||||
|
|
||||||
|
return `R$ ${amount.toLocaleString('pt-BR', {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2
|
||||||
|
})}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Remove extra spaces and trim */
|
/** Remove extra spaces and trim */
|
||||||
export const normalizeText = (value: string): string => {
|
export const normalizeText = (value: string): string => {
|
||||||
return (value || "").replace(/\s+/g, " ").trim();
|
return (value || '').replace(/\s+/g, ' ').trim();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte";
|
import { ShoppingCart, Package, FileText } from "lucide-svelte";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||||
</script>
|
</script>
|
||||||
@@ -25,22 +25,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-xl">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
<div class="card-body">
|
<a
|
||||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
href={resolve('/compras/produtos')}
|
||||||
<div class="mb-6">
|
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-primary"
|
||||||
<ShoppingBag class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="p-2 bg-primary/10 rounded-lg">
|
||||||
|
<Package class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Produtos</h4>
|
||||||
</div>
|
</div>
|
||||||
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
<p class="text-sm text-base-content/70">
|
||||||
<p class="text-base-content/70 max-w-md mb-6">
|
Cadastro, listagem e edição de produtos e serviços disponíveis para compra.
|
||||||
O módulo de Compras está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de compras e aquisições.
|
|
||||||
</p>
|
</p>
|
||||||
<div class="badge badge-warning badge-lg gap-2">
|
|
||||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
|
||||||
Em Desenvolvimento
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={resolve('/pedidos')}
|
||||||
|
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-secondary"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="p-2 bg-secondary/10 rounded-lg">
|
||||||
|
<FileText class="h-6 w-6 text-secondary" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Pedidos</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Gerencie pedidos de compra, acompanhe status e histórico de aquisições.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
|
|||||||
BIN
apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte
Normal file
BIN
apps/web/src/routes/(dashboard)/compras/produtos/+page.svelte
Normal file
Binary file not shown.
157
apps/web/src/routes/(dashboard)/pedidos/+page.svelte
Normal file
157
apps/web/src/routes/(dashboard)/pedidos/+page.svelte
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { Plus, Eye } from 'lucide-svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
|
||||||
|
// Reactive queries
|
||||||
|
const pedidosQuery = useQuery(api.pedidos.list, {});
|
||||||
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
|
const pedidos = $derived(pedidosQuery.data || []);
|
||||||
|
const acoes = $derived(acoesQuery.data || []);
|
||||||
|
const loading = $derived(pedidosQuery.isLoading || acoesQuery.isLoading);
|
||||||
|
const error = $derived(pedidosQuery.error?.message || acoesQuery.error?.message || null);
|
||||||
|
|
||||||
|
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
|
||||||
|
if (!acaoId) return '-';
|
||||||
|
const acao = acoes.find((a) => a._id === acaoId);
|
||||||
|
return acao ? acao.nome : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'Aguardando Aceite';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'Em Análise';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'Precisa de Ajustes';
|
||||||
|
case 'concluido':
|
||||||
|
return 'Concluído';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'bg-orange-100 text-orange-800';
|
||||||
|
case 'concluido':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(timestamp: number) {
|
||||||
|
return new Date(timestamp).toLocaleString('pt-BR');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Pedidos</h1>
|
||||||
|
<a
|
||||||
|
href={resolve('/pedidos/novo')}
|
||||||
|
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
Novo Pedido
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Carregando...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-600">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow-md">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Número SEI</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Status</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Ação</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Data de Criação</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Ações</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
{#each pedidos as pedido (pedido._id)}
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-6 py-4 font-medium whitespace-nowrap">
|
||||||
|
{#if pedido.numeroSei}
|
||||||
|
{pedido.numeroSei}
|
||||||
|
{:else}
|
||||||
|
<span class="text-amber-600">Sem número SEI</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full px-2 py-1 text-xs font-semibold {getStatusColor(
|
||||||
|
pedido.status
|
||||||
|
)}"
|
||||||
|
>
|
||||||
|
{formatStatus(pedido.status)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||||
|
{getAcaoNome(pedido.acaoId)}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-gray-500">
|
||||||
|
{formatDate(pedido.criadoEm)}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||||
|
<a
|
||||||
|
href={resolve(`/pedidos/${pedido._id}`)}
|
||||||
|
class="inline-flex items-center gap-1 text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
<Eye size={18} />
|
||||||
|
Visualizar
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if pedidos.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-4 text-center text-gray-500"
|
||||||
|
>Nenhum pedido cadastrado.</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
609
apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte
Normal file
609
apps/web/src/routes/(dashboard)/pedidos/[id]/+page.svelte
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { maskCurrencyBRL } from '$lib/utils/masks';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Send,
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Edit,
|
||||||
|
Save,
|
||||||
|
X
|
||||||
|
} from 'lucide-svelte';
|
||||||
|
|
||||||
|
const pedidoId = $page.params.id as Id<'pedidos'>;
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Reactive queries
|
||||||
|
const pedidoQuery = useQuery(api.pedidos.get, { id: pedidoId });
|
||||||
|
const itemsQuery = useQuery(api.pedidos.getItems, { pedidoId });
|
||||||
|
const historyQuery = useQuery(api.pedidos.getHistory, { pedidoId });
|
||||||
|
const produtosQuery = useQuery(api.produtos.list, {});
|
||||||
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
|
||||||
|
// Derived state
|
||||||
|
const pedido = $derived(pedidoQuery.data);
|
||||||
|
const items = $derived(itemsQuery.data || []);
|
||||||
|
const history = $derived(historyQuery.data || []);
|
||||||
|
const produtos = $derived(produtosQuery.data || []);
|
||||||
|
|
||||||
|
const acao = $derived.by(() => {
|
||||||
|
if (pedido && pedido.acaoId && acoesQuery.data) {
|
||||||
|
return acoesQuery.data.find((a) => a._id === pedido.acaoId);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loading = $derived(
|
||||||
|
pedidoQuery.isLoading ||
|
||||||
|
itemsQuery.isLoading ||
|
||||||
|
historyQuery.isLoading ||
|
||||||
|
produtosQuery.isLoading ||
|
||||||
|
acoesQuery.isLoading
|
||||||
|
);
|
||||||
|
|
||||||
|
const error = $derived(
|
||||||
|
pedidoQuery.error?.message ||
|
||||||
|
itemsQuery.error?.message ||
|
||||||
|
historyQuery.error?.message ||
|
||||||
|
produtosQuery.error?.message ||
|
||||||
|
acoesQuery.error?.message ||
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add Item State
|
||||||
|
let showAddItem = $state(false);
|
||||||
|
let newItem = $state({
|
||||||
|
produtoId: '' as string,
|
||||||
|
valorEstimado: '',
|
||||||
|
quantidade: 1
|
||||||
|
});
|
||||||
|
let addingItem = $state(false);
|
||||||
|
|
||||||
|
// Edit SEI State
|
||||||
|
let editingSei = $state(false);
|
||||||
|
let seiValue = $state('');
|
||||||
|
let updatingSei = $state(false);
|
||||||
|
|
||||||
|
async function handleAddItem() {
|
||||||
|
if (!newItem.produtoId || !newItem.valorEstimado) return;
|
||||||
|
addingItem = true;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pedidos.addItem, {
|
||||||
|
pedidoId,
|
||||||
|
produtoId: newItem.produtoId as Id<'produtos'>,
|
||||||
|
valorEstimado: newItem.valorEstimado,
|
||||||
|
quantidade: newItem.quantidade
|
||||||
|
});
|
||||||
|
newItem = { produtoId: '', valorEstimado: '', quantidade: 1 };
|
||||||
|
showAddItem = false;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao adicionar item: ' + (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
addingItem = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateQuantity(itemId: Id<'pedidoItems'>, novaQuantidade: number) {
|
||||||
|
if (novaQuantidade < 1) {
|
||||||
|
alert('Quantidade deve ser pelo menos 1.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pedidos.updateItemQuantity, {
|
||||||
|
itemId,
|
||||||
|
novaQuantidade
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao atualizar quantidade: ' + (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveItem(itemId: Id<'pedidoItems'>) {
|
||||||
|
if (!confirm('Remover este item?')) return;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pedidos.removeItem, { itemId });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao remover item: ' + (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus(
|
||||||
|
novoStatus:
|
||||||
|
| 'cancelado'
|
||||||
|
| 'concluido'
|
||||||
|
| 'em_rascunho'
|
||||||
|
| 'aguardando_aceite'
|
||||||
|
| 'em_analise'
|
||||||
|
| 'precisa_ajustes'
|
||||||
|
) {
|
||||||
|
if (!confirm(`Confirmar alteração de status para: ${novoStatus}?`)) return;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pedidos.updateStatus, {
|
||||||
|
pedidoId,
|
||||||
|
novoStatus
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao atualizar status: ' + (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProductName(id: string) {
|
||||||
|
return produtos.find((p) => p._id === id)?.nome || 'Produto desconhecido';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProductChange(id: string) {
|
||||||
|
newItem.produtoId = id;
|
||||||
|
const produto = produtos.find((p) => p._id === id);
|
||||||
|
if (produto) {
|
||||||
|
newItem.valorEstimado = maskCurrencyBRL(produto.valorEstimado || '');
|
||||||
|
} else {
|
||||||
|
newItem.valorEstimado = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMoneyToNumber(value: string): number {
|
||||||
|
const cleanValue = value.replace(/[^0-9,]/g, '').replace(',', '.');
|
||||||
|
return parseFloat(cleanValue) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateItemTotal(valorEstimado: string, quantidade: number): number {
|
||||||
|
const unitValue = parseMoneyToNumber(valorEstimado);
|
||||||
|
return unitValue * quantidade;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalGeral = $derived(
|
||||||
|
items.reduce((sum, item) => sum + calculateItemTotal(item.valorEstimado, item.quantidade), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
function formatStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'Aguardando Aceite';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'Em Análise';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'Precisa de Ajustes';
|
||||||
|
case 'concluido':
|
||||||
|
return 'Concluído';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUpdateSei() {
|
||||||
|
if (!seiValue.trim()) {
|
||||||
|
alert('O número SEI não pode estar vazio.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatingSei = true;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.pedidos.updateSeiNumber, {
|
||||||
|
pedidoId,
|
||||||
|
numeroSei: seiValue.trim()
|
||||||
|
});
|
||||||
|
editingSei = false;
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao atualizar número SEI: ' + (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
updatingSei = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEditingSei() {
|
||||||
|
seiValue = pedido?.numeroSei || '';
|
||||||
|
editingSei = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEditingSei() {
|
||||||
|
editingSei = false;
|
||||||
|
seiValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'bg-yellow-100 text-yellow-800';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'bg-orange-100 text-orange-800';
|
||||||
|
case 'concluido':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'bg-red-100 text-red-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getHistoryIcon(acao: string) {
|
||||||
|
switch (acao) {
|
||||||
|
case 'criacao_pedido':
|
||||||
|
return '📝';
|
||||||
|
case 'adicao_item':
|
||||||
|
return '➕';
|
||||||
|
case 'remocao_item':
|
||||||
|
return '🗑️';
|
||||||
|
case 'alteracao_quantidade':
|
||||||
|
return '🔢';
|
||||||
|
case 'alteracao_status':
|
||||||
|
return '🔄';
|
||||||
|
case 'atualizacao_sei':
|
||||||
|
return '📋';
|
||||||
|
default:
|
||||||
|
return '•';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHistoryEntry(entry: {
|
||||||
|
acao: string;
|
||||||
|
detalhes: string | undefined;
|
||||||
|
usuarioNome: string;
|
||||||
|
}): string {
|
||||||
|
try {
|
||||||
|
const detalhes = entry.detalhes ? JSON.parse(entry.detalhes) : {};
|
||||||
|
|
||||||
|
switch (entry.acao) {
|
||||||
|
case 'criacao_pedido':
|
||||||
|
return `${entry.usuarioNome} criou o pedido ${detalhes.numeroSei || ''}`;
|
||||||
|
|
||||||
|
case 'adicao_item': {
|
||||||
|
const produto = produtos.find((p) => p._id === detalhes.produtoId);
|
||||||
|
const nomeProduto = produto?.nome || 'Produto desconhecido';
|
||||||
|
const quantidade = detalhes.quantidade || 1;
|
||||||
|
return `${entry.usuarioNome} adicionou ${quantidade}x ${nomeProduto} (${detalhes.valor})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'remocao_item': {
|
||||||
|
const produto = produtos.find((p) => p._id === detalhes.produtoId);
|
||||||
|
const nomeProduto = produto?.nome || 'Produto desconhecido';
|
||||||
|
return `${entry.usuarioNome} removeu ${nomeProduto}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'alteracao_quantidade': {
|
||||||
|
const produto = produtos.find((p) => p._id === detalhes.produtoId);
|
||||||
|
const nomeProduto = produto?.nome || 'Produto desconhecido';
|
||||||
|
return `${entry.usuarioNome} alterou a quantidade de ${nomeProduto} de ${detalhes.quantidadeAnterior} para ${detalhes.novaQuantidade}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'alteracao_status':
|
||||||
|
return `${entry.usuarioNome} alterou o status para "${formatStatus(detalhes.novoStatus)}"`;
|
||||||
|
return `${entry.usuarioNome} atualizou o número SEI para "${detalhes.numeroSei}"`;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return `${entry.usuarioNome} realizou: ${entry.acao}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return `${entry.usuarioNome} - ${entry.acao}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
{#if loading}
|
||||||
|
<p>Carregando...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-600">{error}</p>
|
||||||
|
{:else if pedido}
|
||||||
|
<div class="mb-6 flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-3 text-2xl font-bold">
|
||||||
|
{#if editingSei}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={seiValue}
|
||||||
|
class="rounded border px-2 py-1 text-sm"
|
||||||
|
placeholder="Número SEI"
|
||||||
|
disabled={updatingSei}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onclick={handleUpdateSei}
|
||||||
|
disabled={updatingSei}
|
||||||
|
class="rounded bg-green-600 p-1 text-white hover:bg-green-700 disabled:opacity-50"
|
||||||
|
title="Salvar"
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={cancelEditingSei}
|
||||||
|
disabled={updatingSei}
|
||||||
|
class="rounded bg-gray-400 p-1 text-white hover:bg-gray-500 disabled:opacity-50"
|
||||||
|
title="Cancelar"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span>Pedido {pedido.numeroSei || 'sem número SEI'}</span>
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||||
|
<button
|
||||||
|
onclick={startEditingSei}
|
||||||
|
class="rounded bg-blue-100 p-1 text-blue-600 hover:bg-blue-200"
|
||||||
|
title="Editar número SEI"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<span class="rounded-full px-3 py-1 text-sm font-medium {getStatusColor(pedido.status)}">
|
||||||
|
{formatStatus(pedido.status)}
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
{#if acao}
|
||||||
|
<p class="mt-1 text-gray-600">Ação: {acao.nome} ({acao.tipo})</p>
|
||||||
|
{/if}
|
||||||
|
{#if !pedido.numeroSei}
|
||||||
|
<p class="mt-1 text-sm text-amber-600">
|
||||||
|
⚠️ Este pedido não possui número SEI. Adicione um número SEI quando disponível.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||||
|
<button
|
||||||
|
onclick={() => updateStatus('aguardando_aceite')}
|
||||||
|
class="flex items-center gap-2 rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Send size={18} /> Enviar para Aceite
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pedido.status === 'aguardando_aceite'}
|
||||||
|
<!-- Actions for Purchasing Sector (Assuming current user has permission, logic handled in backend/UI visibility) -->
|
||||||
|
<button
|
||||||
|
onclick={() => updateStatus('em_analise')}
|
||||||
|
class="flex items-center gap-2 rounded bg-indigo-600 px-4 py-2 text-white hover:bg-indigo-700"
|
||||||
|
>
|
||||||
|
<Clock size={18} /> Iniciar Análise
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pedido.status === 'em_analise'}
|
||||||
|
<button
|
||||||
|
onclick={() => updateStatus('concluido')}
|
||||||
|
class="flex items-center gap-2 rounded bg-green-600 px-4 py-2 text-white hover:bg-green-700"
|
||||||
|
>
|
||||||
|
<CheckCircle size={18} /> Concluir
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => updateStatus('precisa_ajustes')}
|
||||||
|
class="flex items-center gap-2 rounded bg-orange-500 px-4 py-2 text-white hover:bg-orange-600"
|
||||||
|
>
|
||||||
|
<AlertTriangle size={18} /> Solicitar Ajustes
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if pedido.status !== 'cancelado' && pedido.status !== 'concluido'}
|
||||||
|
<button
|
||||||
|
onclick={() => updateStatus('cancelado')}
|
||||||
|
class="flex items-center gap-2 rounded bg-red-100 px-4 py-2 text-red-700 hover:bg-red-200"
|
||||||
|
>
|
||||||
|
<XCircle size={18} /> Cancelar
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items Section -->
|
||||||
|
<div class="mb-6 overflow-hidden rounded-lg bg-white shadow-md">
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
<h2 class="text-lg font-semibold">Itens do Pedido</h2>
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddItem = true)}
|
||||||
|
class="flex items-center gap-1 text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
<Plus size={16} /> Adicionar Item
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAddItem}
|
||||||
|
<div class="border-b border-gray-200 bg-gray-50 px-6 py-4">
|
||||||
|
<div class="flex items-end gap-4">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label for="produto-select" class="mb-1 block text-xs font-medium text-gray-500"
|
||||||
|
>Produto</label
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
id="produto-select"
|
||||||
|
bind:value={newItem.produtoId}
|
||||||
|
onchange={(e) => handleProductChange(e.currentTarget.value)}
|
||||||
|
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="">Selecione...</option>
|
||||||
|
{#each produtos as p (p._id)}
|
||||||
|
<option value={p._id}>{p.nome} ({p.tipo})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="w-32">
|
||||||
|
<label for="quantidade-input" class="mb-1 block text-xs font-medium text-gray-500"
|
||||||
|
>Quantidade</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="quantidade-input"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
bind:value={newItem.quantidade}
|
||||||
|
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||||
|
placeholder="1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-40">
|
||||||
|
<label for="valor-input" class="mb-1 block text-xs font-medium text-gray-500"
|
||||||
|
>Valor Estimado</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="valor-input"
|
||||||
|
type="text"
|
||||||
|
bind:value={newItem.valorEstimado}
|
||||||
|
oninput={(e) => (newItem.valorEstimado = maskCurrencyBRL(e.currentTarget.value))}
|
||||||
|
class="w-full rounded-md border-gray-300 text-sm shadow-sm"
|
||||||
|
placeholder="R$ 0,00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick={handleAddItem}
|
||||||
|
disabled={addingItem}
|
||||||
|
class="rounded bg-blue-600 px-3 py-2 text-sm text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
Adicionar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (showAddItem = false)}
|
||||||
|
class="rounded bg-gray-200 px-3 py-2 text-sm text-gray-700 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Produto</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Quantidade</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Valor Estimado</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Adicionado Por</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Total</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Ações</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
{#each items as item (item._id)}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">{getProductName(item.produtoId)}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
value={item.quantidade}
|
||||||
|
onchange={(e) =>
|
||||||
|
handleUpdateQuantity(item._id, parseInt(e.currentTarget.value) || 1)}
|
||||||
|
class="w-20 rounded border px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
{item.quantidade}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{maskCurrencyBRL(item.valorEstimado) || 'R$ 0,00'}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-600">
|
||||||
|
{item.adicionadoPorNome}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right font-medium whitespace-nowrap">
|
||||||
|
R$ {calculateItemTotal(item.valorEstimado, item.quantidade)
|
||||||
|
.toFixed(2)
|
||||||
|
.replace('.', ',')}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||||
|
{#if pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes'}
|
||||||
|
<button
|
||||||
|
onclick={() => handleRemoveItem(item._id)}
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if items.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-6 py-4 text-center text-gray-500"
|
||||||
|
>Nenhum item adicionado.</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr class="bg-gray-50 font-semibold">
|
||||||
|
<td
|
||||||
|
colspan="5"
|
||||||
|
class="px-6 py-4 text-right text-sm tracking-wider text-gray-700 uppercase"
|
||||||
|
>
|
||||||
|
Total Geral:
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-base font-bold text-gray-900">
|
||||||
|
R$ {totalGeral.toFixed(2).replace('.', ',')}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Histórico -->
|
||||||
|
<div class="rounded-lg bg-white p-6 shadow">
|
||||||
|
<h2 class="mb-4 text-lg font-semibold text-gray-900">Histórico</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#if history.length === 0}
|
||||||
|
<p class="text-sm text-gray-500">Nenhum histórico disponível.</p>
|
||||||
|
{:else}
|
||||||
|
{#each history as entry (entry._id)}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div class="flex-shrink-0 text-2xl">
|
||||||
|
{getHistoryIcon(entry.acao)}
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm text-gray-900">
|
||||||
|
{formatHistoryEntry(entry)}
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
{new Date(entry.data).toLocaleString('pt-BR', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
358
apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte
Normal file
358
apps/web/src/routes/(dashboard)/pedidos/novo/+page.svelte
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
const acoes = $derived(acoesQuery.data || []);
|
||||||
|
const loading = $derived(acoesQuery.isLoading);
|
||||||
|
let searchQuery = $state('');
|
||||||
|
const searchResultsQuery = useQuery(api.produtos.search, () => ({ query: searchQuery }));
|
||||||
|
const searchResults = $derived(searchResultsQuery.data);
|
||||||
|
|
||||||
|
let formData = $state({
|
||||||
|
numeroSei: '',
|
||||||
|
acaoId: '' as Id<'acoes'> | ''
|
||||||
|
});
|
||||||
|
let creating = $state(false);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let warning = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Updated to store quantity
|
||||||
|
let selectedProdutos = $state<{ produto: Doc<'produtos'>; quantidade: number }[]>([]);
|
||||||
|
let selectedProdutoIds = $derived(selectedProdutos.map((p) => p.produto._id));
|
||||||
|
|
||||||
|
function addProduto(produto: Doc<'produtos'>) {
|
||||||
|
if (!selectedProdutos.find((p) => p.produto._id === produto._id)) {
|
||||||
|
// Default quantity 1
|
||||||
|
selectedProdutos = [...selectedProdutos, { produto, quantidade: 1 }];
|
||||||
|
checkExisting();
|
||||||
|
}
|
||||||
|
searchQuery = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeProduto(produtoId: Id<'produtos'>) {
|
||||||
|
selectedProdutos = selectedProdutos.filter((p) => p.produto._id !== produtoId);
|
||||||
|
checkExisting();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Updated type for existingPedidos to include matchingItems
|
||||||
|
let existingPedidos = $state<
|
||||||
|
{
|
||||||
|
_id: Id<'pedidos'>;
|
||||||
|
numeroSei?: string;
|
||||||
|
status:
|
||||||
|
| 'em_rascunho'
|
||||||
|
| 'aguardando_aceite'
|
||||||
|
| 'em_analise'
|
||||||
|
| 'precisa_ajustes'
|
||||||
|
| 'cancelado'
|
||||||
|
| 'concluido';
|
||||||
|
acaoId?: Id<'acoes'>;
|
||||||
|
criadoEm: number;
|
||||||
|
matchingItems?: { produtoId: Id<'produtos'>; quantidade: number }[];
|
||||||
|
}[]
|
||||||
|
>([]);
|
||||||
|
let checking = $state(false);
|
||||||
|
|
||||||
|
function formatStatus(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'em_rascunho':
|
||||||
|
return 'Rascunho';
|
||||||
|
case 'aguardando_aceite':
|
||||||
|
return 'Aguardando Aceite';
|
||||||
|
case 'em_analise':
|
||||||
|
return 'Em Análise';
|
||||||
|
case 'precisa_ajustes':
|
||||||
|
return 'Precisa de Ajustes';
|
||||||
|
case 'concluido':
|
||||||
|
return 'Concluído';
|
||||||
|
case 'cancelado':
|
||||||
|
return 'Cancelado';
|
||||||
|
default:
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAcaoNome(acaoId: Id<'acoes'> | undefined) {
|
||||||
|
if (!acaoId) return '-';
|
||||||
|
const acao = acoes.find((a) => a._id === acaoId);
|
||||||
|
return acao ? acao.nome : '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get matching product info for display
|
||||||
|
function getMatchingInfo(pedido: (typeof existingPedidos)[0]) {
|
||||||
|
if (!pedido.matchingItems || pedido.matchingItems.length === 0) return null;
|
||||||
|
|
||||||
|
// Find which of the selected products match this order
|
||||||
|
const matches = pedido.matchingItems.filter((item) =>
|
||||||
|
selectedProdutoIds.includes(item.produtoId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matches.length === 0) return null;
|
||||||
|
|
||||||
|
// Create a summary string
|
||||||
|
const details = matches
|
||||||
|
.map((match) => {
|
||||||
|
const prod = selectedProdutos.find((p) => p.produto._id === match.produtoId);
|
||||||
|
return `${prod?.produto.nome}: ${match.quantidade} un.`;
|
||||||
|
})
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return `Contém: ${details}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkExisting() {
|
||||||
|
warning = null;
|
||||||
|
existingPedidos = [];
|
||||||
|
|
||||||
|
const hasFilters = formData.acaoId || formData.numeroSei || selectedProdutoIds.length > 0;
|
||||||
|
if (!hasFilters) return;
|
||||||
|
|
||||||
|
checking = true;
|
||||||
|
try {
|
||||||
|
const result = await client.query(api.pedidos.checkExisting, {
|
||||||
|
acaoId: formData.acaoId ? (formData.acaoId as Id<'acoes'>) : undefined,
|
||||||
|
numeroSei: formData.numeroSei || undefined,
|
||||||
|
produtoIds: selectedProdutoIds.length ? (selectedProdutoIds as Id<'produtos'>[]) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
existingPedidos = result;
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
warning = `Atenção: encontramos ${result.length} pedido(s) em andamento que batem com os filtros informados. Você pode abrir um deles para adicionar itens.`;
|
||||||
|
} else {
|
||||||
|
warning = 'Nenhum pedido em andamento encontrado com esses filtros.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
checking = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
creating = true;
|
||||||
|
error = null;
|
||||||
|
try {
|
||||||
|
const pedidoId = await client.mutation(api.pedidos.create, {
|
||||||
|
numeroSei: formData.numeroSei || undefined,
|
||||||
|
acaoId: formData.acaoId ? (formData.acaoId as Id<'acoes'>) : undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectedProdutos.length > 0) {
|
||||||
|
await Promise.all(
|
||||||
|
selectedProdutos.map((item) =>
|
||||||
|
client.mutation(api.pedidos.addItem, {
|
||||||
|
pedidoId,
|
||||||
|
produtoId: item.produto._id,
|
||||||
|
valorEstimado: item.produto.valorEstimado,
|
||||||
|
quantidade: item.quantidade // Pass quantity
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
goto(resolve(`/pedidos/${pedidoId}`));
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
creating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto max-w-2xl p-6">
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Novo Pedido</h1>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-white p-6 shadow-md">
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-bold text-gray-700" for="numeroSei">
|
||||||
|
Número SEI (Opcional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
||||||
|
id="numeroSei"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.numeroSei}
|
||||||
|
placeholder="Ex: 12345.000000/2023-00"
|
||||||
|
onblur={checkExisting}
|
||||||
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-500">
|
||||||
|
Você pode adicionar o número SEI posteriormente, se necessário.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-bold text-gray-700" for="acao">
|
||||||
|
Ação (Opcional)
|
||||||
|
</label>
|
||||||
|
{#if loading}
|
||||||
|
<p class="text-sm text-gray-500">Carregando ações...</p>
|
||||||
|
{:else}
|
||||||
|
<select
|
||||||
|
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
||||||
|
id="acao"
|
||||||
|
bind:value={formData.acaoId}
|
||||||
|
onchange={checkExisting}
|
||||||
|
>
|
||||||
|
<option value="">Selecione uma ação...</option>
|
||||||
|
{#each acoes as acao (acao._id)}
|
||||||
|
<option value={acao._id}>{acao.nome} ({acao.tipo})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="mb-2 block text-sm font-bold text-gray-700" for="produtos">
|
||||||
|
Produtos (Opcional)
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="mb-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Buscar produtos..."
|
||||||
|
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if searchQuery.length > 0}
|
||||||
|
<div class="mb-4 rounded border bg-gray-50 p-2">
|
||||||
|
{#if searchResults === undefined}
|
||||||
|
<p class="text-sm text-gray-500">Carregando...</p>
|
||||||
|
{:else if searchResults.length === 0}
|
||||||
|
<p class="text-sm text-gray-500">Nenhum produto encontrado.</p>
|
||||||
|
{:else}
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{#each searchResults as produto (produto._id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-center justify-between rounded px-2 py-1 text-left hover:bg-gray-200"
|
||||||
|
onclick={() => addProduto(produto)}
|
||||||
|
>
|
||||||
|
<span>{produto.nome}</span>
|
||||||
|
<span class="text-xs text-gray-500">Adicionar</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if selectedProdutos.length > 0}
|
||||||
|
<div class="mt-2">
|
||||||
|
<p class="mb-2 text-sm font-semibold text-gray-700">Produtos Selecionados:</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each selectedProdutos as item (item.produto._id)}
|
||||||
|
<li
|
||||||
|
class="flex items-center justify-between rounded bg-blue-50 px-3 py-2 text-sm text-blue-900"
|
||||||
|
>
|
||||||
|
<span class="flex-1">{item.produto.nome}</span>
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<label class="flex items-center space-x-1 text-xs text-gray-600">
|
||||||
|
<span>Qtd:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
class="w-16 rounded border px-2 py-1 text-sm"
|
||||||
|
bind:value={item.quantidade}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-red-600 hover:text-red-800"
|
||||||
|
onclick={() => removeProduto(item.produto._id)}
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if warning}
|
||||||
|
<div
|
||||||
|
class="mb-4 rounded border border-yellow-400 bg-yellow-100 px-4 py-3 text-sm text-yellow-800"
|
||||||
|
>
|
||||||
|
{warning}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if checking}
|
||||||
|
<p class="mb-4 text-sm text-gray-500">Verificando pedidos existentes...</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if existingPedidos.length > 0}
|
||||||
|
<div class="mb-6 rounded border border-yellow-300 bg-yellow-50 p-4">
|
||||||
|
<p class="mb-2 text-sm text-yellow-800">
|
||||||
|
Os pedidos abaixo estão em rascunho/análise. Você pode abri-los para adicionar itens.
|
||||||
|
</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each existingPedidos as pedido (pedido._id)}
|
||||||
|
<li class="flex flex-col rounded bg-white px-3 py-2 shadow-sm">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-medium">
|
||||||
|
Pedido {pedido.numeroSei || 'sem número SEI'} — {formatStatus(pedido.status)}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
Ação: {getAcaoNome(pedido.acaoId)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href={resolve(`/pedidos/${pedido._id}`)}
|
||||||
|
class="text-sm font-medium text-blue-600 hover:text-blue-800"
|
||||||
|
>
|
||||||
|
Abrir pedido
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if getMatchingInfo(pedido)}
|
||||||
|
<div class="mt-1 text-xs font-semibold text-blue-700">
|
||||||
|
{getMatchingInfo(pedido)}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<a
|
||||||
|
href={resolve('/pedidos')}
|
||||||
|
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={creating || loading}
|
||||||
|
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creating ? 'Criando...' : 'Criar Pedido'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Trophy, Award, Building2, Workflow } from "lucide-svelte";
|
import { Trophy, Award, Building2, Workflow, Target } from "lucide-svelte";
|
||||||
import { resolve } from "$app/paths";
|
import { resolve } from "$app/paths";
|
||||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||||
</script>
|
</script>
|
||||||
@@ -43,6 +43,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={resolve('/programas-esportivos/acoes')}
|
||||||
|
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-accent"
|
||||||
|
>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<div class="p-2 bg-accent/10 rounded-lg">
|
||||||
|
<Target class="h-6 w-6 text-accent" strokeWidth={2} />
|
||||||
|
</div>
|
||||||
|
<h4 class="font-semibold">Ações</h4>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
Gerencie ações, projetos e leis relacionadas aos programas esportivos.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
<div class="card bg-base-100 shadow-md opacity-70">
|
<div class="card bg-base-100 shadow-md opacity-70">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id, Doc } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
import { Plus, Pencil, Trash2, X } from 'lucide-svelte';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Reactive query
|
||||||
|
const acoesQuery = useQuery(api.acoes.list, {});
|
||||||
|
const acoes = $derived(acoesQuery.data || []);
|
||||||
|
const loading = $derived(acoesQuery.isLoading);
|
||||||
|
const error = $derived(acoesQuery.error?.message || null);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
let showModal = $state(false);
|
||||||
|
let editingId: string | null = $state(null);
|
||||||
|
let formData = $state({
|
||||||
|
nome: '',
|
||||||
|
tipo: 'projeto' as 'projeto' | 'lei'
|
||||||
|
});
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
function openModal(acao?: Doc<'acoes'>) {
|
||||||
|
if (acao) {
|
||||||
|
editingId = acao._id;
|
||||||
|
formData = {
|
||||||
|
nome: acao.nome,
|
||||||
|
tipo: acao.tipo
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
editingId = null;
|
||||||
|
formData = {
|
||||||
|
nome: '',
|
||||||
|
tipo: 'projeto'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
showModal = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
showModal = false;
|
||||||
|
editingId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: Event) {
|
||||||
|
e.preventDefault();
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (editingId) {
|
||||||
|
await client.mutation(api.acoes.update, {
|
||||||
|
id: editingId as Id<'acoes'>,
|
||||||
|
...formData
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await client.mutation(api.acoes.create, formData);
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao salvar: ' + (e as Error).message);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: Id<'acoes'>) {
|
||||||
|
if (!confirm('Tem certeza que deseja excluir esta ação?')) return;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.acoes.remove, { id });
|
||||||
|
} catch (e) {
|
||||||
|
alert('Erro ao excluir: ' + (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<div class="mb-6 flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-bold">Ações</h1>
|
||||||
|
<button
|
||||||
|
onclick={() => openModal()}
|
||||||
|
class="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
Nova Ação
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Carregando...</p>
|
||||||
|
{:else if error}
|
||||||
|
<p class="text-red-600">{error}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-hidden rounded-lg bg-white shadow-md">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Nome</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Tipo</th
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
class="px-6 py-3 text-right text-xs font-medium tracking-wider text-gray-500 uppercase"
|
||||||
|
>Ações</th
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
{#each acoes as acao (acao._id)}
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">{acao.nome}</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold
|
||||||
|
{acao.tipo === 'projeto'
|
||||||
|
? 'bg-purple-100 text-purple-800'
|
||||||
|
: 'bg-orange-100 text-orange-800'}"
|
||||||
|
>
|
||||||
|
{acao.tipo === 'projeto' ? 'Projeto' : 'Lei'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 text-right text-sm font-medium whitespace-nowrap">
|
||||||
|
<button
|
||||||
|
onclick={() => openModal(acao)}
|
||||||
|
class="mr-4 text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
<Pencil size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => handleDelete(acao._id)}
|
||||||
|
class="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{#if acoes.length === 0}
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-6 py-4 text-center text-gray-500"
|
||||||
|
>Nenhuma ação cadastrada.</td
|
||||||
|
>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showModal}
|
||||||
|
<div
|
||||||
|
class="bg-opacity-50 fixed inset-0 z-50 flex h-full w-full items-center justify-center overflow-y-auto bg-gray-600"
|
||||||
|
>
|
||||||
|
<div class="relative w-full max-w-md rounded-lg bg-white p-8 shadow-xl">
|
||||||
|
<button
|
||||||
|
onclick={closeModal}
|
||||||
|
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
<h2 class="mb-6 text-xl font-bold">{editingId ? 'Editar' : 'Novo'} Ação</h2>
|
||||||
|
|
||||||
|
<form onsubmit={handleSubmit}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="mb-2 block text-sm font-bold text-gray-700" for="nome"> Nome </label>
|
||||||
|
<input
|
||||||
|
class="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
||||||
|
id="nome"
|
||||||
|
type="text"
|
||||||
|
bind:value={formData.nome}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="mb-2 block text-sm font-bold text-gray-700" for="tipo"> Tipo </label>
|
||||||
|
<select
|
||||||
|
class="focus:shadow-outline w-full rounded border px-3 py-2 leading-tight text-gray-700 shadow focus:outline-none"
|
||||||
|
id="tipo"
|
||||||
|
bind:value={formData.tipo}
|
||||||
|
>
|
||||||
|
<option value="projeto">Projeto</option>
|
||||||
|
<option value="lei">Lei</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={closeModal}
|
||||||
|
class="mr-2 rounded bg-gray-300 px-4 py-2 font-bold text-gray-800 hover:bg-gray-400"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
class="focus:shadow-outline rounded bg-blue-600 px-4 py-2 font-bold text-white hover:bg-blue-700 focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Salvando...' : 'Salvar'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -380,6 +380,15 @@
|
|||||||
palette: 'accent',
|
palette: 'accent',
|
||||||
icon: 'building'
|
icon: 'building'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Configurações Gerais',
|
||||||
|
description:
|
||||||
|
'Configure opções gerais do sistema, incluindo setor de compras e outras configurações administrativas.',
|
||||||
|
ctaLabel: 'Configurar',
|
||||||
|
href: '/(dashboard)/ti/configuracoes',
|
||||||
|
palette: 'secondary',
|
||||||
|
icon: 'control'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Documentação',
|
title: 'Documentação',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { useQuery, useConvexClient } from 'convex-svelte';
|
||||||
|
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||||
|
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||||
|
|
||||||
|
const client = useConvexClient();
|
||||||
|
|
||||||
|
// Reactive queries
|
||||||
|
const setoresQuery = useQuery(api.setores.list, {});
|
||||||
|
const configQuery = useQuery(api.config.getComprasSetor, {});
|
||||||
|
|
||||||
|
const setores = $derived(setoresQuery.data || []);
|
||||||
|
const config = $derived(configQuery.data);
|
||||||
|
const loading = $derived(setoresQuery.isLoading || configQuery.isLoading);
|
||||||
|
|
||||||
|
// Initialize selected setor from config - using boxed $state to avoid reactivity warning
|
||||||
|
let selectedSetorId = $state('');
|
||||||
|
|
||||||
|
// Update selectedSetorId when config changes
|
||||||
|
$effect(() => {
|
||||||
|
if (config?.comprasSetorId && !selectedSetorId) {
|
||||||
|
selectedSetorId = config.comprasSetorId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let error: string | null = $state(null);
|
||||||
|
let success: string | null = $state(null);
|
||||||
|
|
||||||
|
async function saveConfig() {
|
||||||
|
saving = true;
|
||||||
|
error = null;
|
||||||
|
success = null;
|
||||||
|
try {
|
||||||
|
await client.mutation(api.config.updateComprasSetor, {
|
||||||
|
setorId: selectedSetorId as Id<'setores'>
|
||||||
|
});
|
||||||
|
success = 'Configuração salva com sucesso!';
|
||||||
|
} catch (e) {
|
||||||
|
error = (e as Error).message;
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mx-auto p-6">
|
||||||
|
<h1 class="mb-6 text-2xl font-bold">Configurações Gerais</h1>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p>Carregando...</p>
|
||||||
|
{:else}
|
||||||
|
<div class="max-w-md rounded-lg bg-white p-6 shadow-md">
|
||||||
|
<h2 class="mb-4 text-xl font-semibold">Setor de Compras</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-600">
|
||||||
|
Selecione o setor responsável por receber e aprovar pedidos de compra.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if success}
|
||||||
|
<div class="mb-4 rounded border border-green-400 bg-green-100 px-4 py-3 text-green-700">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="setor" class="mb-1 block text-sm font-medium text-gray-700"> Setor </label>
|
||||||
|
<select
|
||||||
|
id="setor"
|
||||||
|
bind:value={selectedSetorId}
|
||||||
|
class="w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Selecione um setor...</option>
|
||||||
|
{#each setores as setor (setor._id)}
|
||||||
|
<option value={setor._id}>{setor.nome} ({setor.sigla})</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={saveConfig}
|
||||||
|
disabled={saving || !selectedSetorId}
|
||||||
|
class="w-full rounded-md bg-blue-600 px-4 py-2 text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'Salvando...' : 'Salvar Configuração'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
50
packages/backend/convex/_generated/api.d.ts
vendored
50
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,6 +8,7 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type * as acoes from "../acoes.js";
|
||||||
import type * as actions_email from "../actions/email.js";
|
import type * as actions_email from "../actions/email.js";
|
||||||
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
import type * as actions_linkPreview from "../actions/linkPreview.js";
|
||||||
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
import type * as actions_pushNotifications from "../actions/pushNotifications.js";
|
||||||
@@ -21,6 +22,7 @@ import type * as auth_utils from "../auth/utils.js";
|
|||||||
import type * as chamadas from "../chamadas.js";
|
import type * as chamadas from "../chamadas.js";
|
||||||
import type * as chamados from "../chamados.js";
|
import type * as chamados from "../chamados.js";
|
||||||
import type * as chat from "../chat.js";
|
import type * as chat from "../chat.js";
|
||||||
|
import type * as config from "../config.js";
|
||||||
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
import type * as configuracaoEmail from "../configuracaoEmail.js";
|
||||||
import type * as configuracaoJitsi from "../configuracaoJitsi.js";
|
import type * as configuracaoJitsi from "../configuracaoJitsi.js";
|
||||||
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
||||||
@@ -44,9 +46,11 @@ import type * as logsAcesso from "../logsAcesso.js";
|
|||||||
import type * as logsAtividades from "../logsAtividades.js";
|
import type * as logsAtividades from "../logsAtividades.js";
|
||||||
import type * as logsLogin from "../logsLogin.js";
|
import type * as logsLogin from "../logsLogin.js";
|
||||||
import type * as monitoramento from "../monitoramento.js";
|
import type * as monitoramento from "../monitoramento.js";
|
||||||
|
import type * as pedidos from "../pedidos.js";
|
||||||
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
import type * as permissoesAcoes from "../permissoesAcoes.js";
|
||||||
import type * as pontos from "../pontos.js";
|
import type * as pontos from "../pontos.js";
|
||||||
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
import type * as preferenciasNotificacao from "../preferenciasNotificacao.js";
|
||||||
|
import type * as produtos from "../produtos.js";
|
||||||
import type * as pushNotifications from "../pushNotifications.js";
|
import type * as pushNotifications from "../pushNotifications.js";
|
||||||
import type * as roles from "../roles.js";
|
import type * as roles from "../roles.js";
|
||||||
import type * as saldoFerias from "../saldoFerias.js";
|
import type * as saldoFerias from "../saldoFerias.js";
|
||||||
@@ -54,9 +58,28 @@ import type * as security from "../security.js";
|
|||||||
import type * as seed from "../seed.js";
|
import type * as seed from "../seed.js";
|
||||||
import type * as setores from "../setores.js";
|
import type * as setores from "../setores.js";
|
||||||
import type * as simbolos from "../simbolos.js";
|
import type * as simbolos from "../simbolos.js";
|
||||||
|
import type * as tables_atestados from "../tables/atestados.js";
|
||||||
|
import type * as tables_ausencias from "../tables/ausencias.js";
|
||||||
|
import type * as tables_auth from "../tables/auth.js";
|
||||||
|
import type * as tables_chat from "../tables/chat.js";
|
||||||
|
import type * as tables_contratos from "../tables/contratos.js";
|
||||||
|
import type * as tables_cursos from "../tables/cursos.js";
|
||||||
|
import type * as tables_empresas from "../tables/empresas.js";
|
||||||
|
import type * as tables_enderecos from "../tables/enderecos.js";
|
||||||
|
import type * as tables_ferias from "../tables/ferias.js";
|
||||||
|
import type * as tables_flows from "../tables/flows.js";
|
||||||
|
import type * as tables_funcionarios from "../tables/funcionarios.js";
|
||||||
|
import type * as tables_licencas from "../tables/licencas.js";
|
||||||
|
import type * as tables_pedidos from "../tables/pedidos.js";
|
||||||
|
import type * as tables_ponto from "../tables/ponto.js";
|
||||||
|
import type * as tables_produtos from "../tables/produtos.js";
|
||||||
|
import type * as tables_security from "../tables/security.js";
|
||||||
|
import type * as tables_setores from "../tables/setores.js";
|
||||||
|
import type * as tables_system from "../tables/system.js";
|
||||||
|
import type * as tables_tickets from "../tables/tickets.js";
|
||||||
|
import type * as tables_times from "../tables/times.js";
|
||||||
import type * as templatesMensagens from "../templatesMensagens.js";
|
import type * as templatesMensagens from "../templatesMensagens.js";
|
||||||
import type * as times from "../times.js";
|
import type * as times from "../times.js";
|
||||||
import type * as todos from "../todos.js";
|
|
||||||
import type * as usuarios from "../usuarios.js";
|
import type * as usuarios from "../usuarios.js";
|
||||||
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
|
import type * as utils_chatTemplateWrapper from "../utils/chatTemplateWrapper.js";
|
||||||
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
|
import type * as utils_emailTemplateWrapper from "../utils/emailTemplateWrapper.js";
|
||||||
@@ -71,6 +94,7 @@ import type {
|
|||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
acoes: typeof acoes;
|
||||||
"actions/email": typeof actions_email;
|
"actions/email": typeof actions_email;
|
||||||
"actions/linkPreview": typeof actions_linkPreview;
|
"actions/linkPreview": typeof actions_linkPreview;
|
||||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
"actions/pushNotifications": typeof actions_pushNotifications;
|
||||||
@@ -84,6 +108,7 @@ declare const fullApi: ApiFromModules<{
|
|||||||
chamadas: typeof chamadas;
|
chamadas: typeof chamadas;
|
||||||
chamados: typeof chamados;
|
chamados: typeof chamados;
|
||||||
chat: typeof chat;
|
chat: typeof chat;
|
||||||
|
config: typeof config;
|
||||||
configuracaoEmail: typeof configuracaoEmail;
|
configuracaoEmail: typeof configuracaoEmail;
|
||||||
configuracaoJitsi: typeof configuracaoJitsi;
|
configuracaoJitsi: typeof configuracaoJitsi;
|
||||||
configuracaoPonto: typeof configuracaoPonto;
|
configuracaoPonto: typeof configuracaoPonto;
|
||||||
@@ -107,9 +132,11 @@ declare const fullApi: ApiFromModules<{
|
|||||||
logsAtividades: typeof logsAtividades;
|
logsAtividades: typeof logsAtividades;
|
||||||
logsLogin: typeof logsLogin;
|
logsLogin: typeof logsLogin;
|
||||||
monitoramento: typeof monitoramento;
|
monitoramento: typeof monitoramento;
|
||||||
|
pedidos: typeof pedidos;
|
||||||
permissoesAcoes: typeof permissoesAcoes;
|
permissoesAcoes: typeof permissoesAcoes;
|
||||||
pontos: typeof pontos;
|
pontos: typeof pontos;
|
||||||
preferenciasNotificacao: typeof preferenciasNotificacao;
|
preferenciasNotificacao: typeof preferenciasNotificacao;
|
||||||
|
produtos: typeof produtos;
|
||||||
pushNotifications: typeof pushNotifications;
|
pushNotifications: typeof pushNotifications;
|
||||||
roles: typeof roles;
|
roles: typeof roles;
|
||||||
saldoFerias: typeof saldoFerias;
|
saldoFerias: typeof saldoFerias;
|
||||||
@@ -117,9 +144,28 @@ declare const fullApi: ApiFromModules<{
|
|||||||
seed: typeof seed;
|
seed: typeof seed;
|
||||||
setores: typeof setores;
|
setores: typeof setores;
|
||||||
simbolos: typeof simbolos;
|
simbolos: typeof simbolos;
|
||||||
|
"tables/atestados": typeof tables_atestados;
|
||||||
|
"tables/ausencias": typeof tables_ausencias;
|
||||||
|
"tables/auth": typeof tables_auth;
|
||||||
|
"tables/chat": typeof tables_chat;
|
||||||
|
"tables/contratos": typeof tables_contratos;
|
||||||
|
"tables/cursos": typeof tables_cursos;
|
||||||
|
"tables/empresas": typeof tables_empresas;
|
||||||
|
"tables/enderecos": typeof tables_enderecos;
|
||||||
|
"tables/ferias": typeof tables_ferias;
|
||||||
|
"tables/flows": typeof tables_flows;
|
||||||
|
"tables/funcionarios": typeof tables_funcionarios;
|
||||||
|
"tables/licencas": typeof tables_licencas;
|
||||||
|
"tables/pedidos": typeof tables_pedidos;
|
||||||
|
"tables/ponto": typeof tables_ponto;
|
||||||
|
"tables/produtos": typeof tables_produtos;
|
||||||
|
"tables/security": typeof tables_security;
|
||||||
|
"tables/setores": typeof tables_setores;
|
||||||
|
"tables/system": typeof tables_system;
|
||||||
|
"tables/tickets": typeof tables_tickets;
|
||||||
|
"tables/times": typeof tables_times;
|
||||||
templatesMensagens: typeof templatesMensagens;
|
templatesMensagens: typeof templatesMensagens;
|
||||||
times: typeof times;
|
times: typeof times;
|
||||||
todos: typeof todos;
|
|
||||||
usuarios: typeof usuarios;
|
usuarios: typeof usuarios;
|
||||||
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
|
"utils/chatTemplateWrapper": typeof utils_chatTemplateWrapper;
|
||||||
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
|
"utils/emailTemplateWrapper": typeof utils_emailTemplateWrapper;
|
||||||
|
|||||||
56
packages/backend/convex/acoes.ts
Normal file
56
packages/backend/convex/acoes.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query('acoes').collect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: v.union(v.literal('projeto'), v.literal('lei'))
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
return await ctx.db.insert('acoes', {
|
||||||
|
...args,
|
||||||
|
criadoPor: user._id,
|
||||||
|
criadoEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('acoes'),
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: v.union(v.literal('projeto'), v.literal('lei'))
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
nome: args.nome,
|
||||||
|
tipo: args.tipo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('acoes')
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
38
packages/backend/convex/config.ts
Normal file
38
packages/backend/convex/config.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
|
||||||
|
export const getComprasSetor = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query('config').first();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateComprasSetor = mutation({
|
||||||
|
args: {
|
||||||
|
setorId: v.id('setores')
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
// Check if user has permission (e.g., admin or TI) - For now, assuming any auth user can set it,
|
||||||
|
// but in production should be restricted.
|
||||||
|
|
||||||
|
const existingConfig = await ctx.db.query('config').first();
|
||||||
|
|
||||||
|
if (existingConfig) {
|
||||||
|
await ctx.db.patch(existingConfig._id, {
|
||||||
|
comprasSetorId: args.setorId,
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await ctx.db.insert('config', {
|
||||||
|
comprasSetorId: args.setorId,
|
||||||
|
criadoPor: user._id,
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,200 +1,198 @@
|
|||||||
import { mutation, query } from "./_generated/server";
|
import { mutation, query } from './_generated/server';
|
||||||
import { v } from "convex/values";
|
import { v } from 'convex/values';
|
||||||
import { situacaoContrato } from "./schema";
|
import { situacaoContrato } from './tables/contratos';
|
||||||
import { getCurrentUserFunction } from "./auth";
|
import { getCurrentUserFunction } from './auth';
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from './_generated/api';
|
||||||
|
|
||||||
export const listar = query({
|
export const listar = query({
|
||||||
args: {
|
args: {
|
||||||
responsavelId: v.optional(v.id("funcionarios")),
|
responsavelId: v.optional(v.id('funcionarios')),
|
||||||
dataInicio: v.optional(v.string()),
|
dataInicio: v.optional(v.string()),
|
||||||
dataFim: v.optional(v.string()),
|
dataFim: v.optional(v.string())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: "contratos",
|
recurso: 'contratos',
|
||||||
acao: "listar",
|
acao: 'listar'
|
||||||
});
|
});
|
||||||
|
|
||||||
let q = ctx.db.query("contratos");
|
let q = ctx.db.query('contratos');
|
||||||
|
|
||||||
if (args.responsavelId) {
|
if (args.responsavelId) {
|
||||||
q = q.withIndex("by_responsavel", (q) =>
|
q = q.withIndex('by_responsavel', (q) =>
|
||||||
q.eq("responsavelId", args.responsavelId!)
|
q.eq('responsavelId', args.responsavelId!)
|
||||||
) as typeof q;
|
) as typeof q;
|
||||||
}
|
}
|
||||||
|
|
||||||
const contratos = await q.collect();
|
const contratos = await q.collect();
|
||||||
|
|
||||||
// Filtros em memória para datas (já que Convex não tem filtro de range nativo eficiente combinado com outros índices sem setup complexo)
|
// Filtros em memória para datas (já que Convex não tem filtro de range nativo eficiente combinado com outros índices sem setup complexo)
|
||||||
// Se o volume for muito grande, ideal seria criar índices específicos ou usar search.
|
// Se o volume for muito grande, ideal seria criar índices específicos ou usar search.
|
||||||
let resultado = contratos;
|
let resultado = contratos;
|
||||||
|
|
||||||
if (args.dataInicio) {
|
if (args.dataInicio) {
|
||||||
resultado = resultado.filter(
|
resultado = resultado.filter((c) => c.dataInicioVigencia >= args.dataInicio!);
|
||||||
(c) => c.dataInicioVigencia >= args.dataInicio!
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.dataFim) {
|
if (args.dataFim) {
|
||||||
resultado = resultado.filter((c) => c.dataFimVigencia <= args.dataFim!);
|
resultado = resultado.filter((c) => c.dataFimVigencia <= args.dataFim!);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enriquecer com dados relacionados
|
// Enriquecer com dados relacionados
|
||||||
const contratosEnriquecidos = await Promise.all(
|
const contratosEnriquecidos = await Promise.all(
|
||||||
resultado.map(async (c) => {
|
resultado.map(async (c) => {
|
||||||
const contratada = await ctx.db.get(c.contratadaId);
|
const contratada = await ctx.db.get(c.contratadaId);
|
||||||
const responsavel = await ctx.db.get(c.responsavelId);
|
const responsavel = await ctx.db.get(c.responsavelId);
|
||||||
return {
|
return {
|
||||||
...c,
|
...c,
|
||||||
contratada,
|
contratada,
|
||||||
responsavel,
|
responsavel
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return contratosEnriquecidos;
|
return contratosEnriquecidos;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const obter = query({
|
export const obter = query({
|
||||||
args: { id: v.id("contratos") },
|
args: { id: v.id('contratos') },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: "contratos",
|
recurso: 'contratos',
|
||||||
acao: "ver",
|
acao: 'ver'
|
||||||
});
|
});
|
||||||
const contrato = await ctx.db.get(args.id);
|
const contrato = await ctx.db.get(args.id);
|
||||||
if (!contrato) return null;
|
if (!contrato) return null;
|
||||||
|
|
||||||
const contratada = await ctx.db.get(contrato.contratadaId);
|
const contratada = await ctx.db.get(contrato.contratadaId);
|
||||||
const responsavel = await ctx.db.get(contrato.responsavelId);
|
const responsavel = await ctx.db.get(contrato.responsavelId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...contrato,
|
...contrato,
|
||||||
contratada,
|
contratada,
|
||||||
responsavel,
|
responsavel
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const criar = mutation({
|
export const criar = mutation({
|
||||||
args: {
|
args: {
|
||||||
contratadaId: v.id("empresas"),
|
contratadaId: v.id('empresas'),
|
||||||
objeto: v.string(),
|
objeto: v.string(),
|
||||||
numeroNotaEmpenho: v.string(),
|
numeroNotaEmpenho: v.string(),
|
||||||
responsavelId: v.id("funcionarios"),
|
responsavelId: v.id('funcionarios'),
|
||||||
departamento: v.string(),
|
departamento: v.string(),
|
||||||
situacao: situacaoContrato,
|
situacao: situacaoContrato,
|
||||||
numeroProcessoLicitatorio: v.string(),
|
numeroProcessoLicitatorio: v.string(),
|
||||||
modalidade: v.string(),
|
modalidade: v.string(),
|
||||||
numeroContrato: v.string(),
|
numeroContrato: v.string(),
|
||||||
anoContrato: v.number(),
|
anoContrato: v.number(),
|
||||||
dataInicioVigencia: v.string(),
|
dataInicioVigencia: v.string(),
|
||||||
dataFimVigencia: v.string(),
|
dataFimVigencia: v.string(),
|
||||||
nomeFiscal: v.string(),
|
nomeFiscal: v.string(),
|
||||||
valorTotal: v.string(),
|
valorTotal: v.string(),
|
||||||
dataAditivoPrazo: v.optional(v.string()),
|
dataAditivoPrazo: v.optional(v.string()),
|
||||||
diasAvisoVencimento: v.number(),
|
diasAvisoVencimento: v.number()
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: "contratos",
|
recurso: 'contratos',
|
||||||
acao: "criar",
|
acao: 'criar'
|
||||||
});
|
});
|
||||||
|
|
||||||
const usuario = await getCurrentUserFunction(ctx);
|
const usuario = await getCurrentUserFunction(ctx);
|
||||||
if (!usuario) throw new Error("Não autenticado");
|
if (!usuario) throw new Error('Não autenticado');
|
||||||
|
|
||||||
const id = await ctx.db.insert("contratos", {
|
const id = await ctx.db.insert('contratos', {
|
||||||
...args,
|
...args,
|
||||||
criadoPor: usuario._id,
|
criadoPor: usuario._id,
|
||||||
criadoEm: Date.now(),
|
criadoEm: Date.now()
|
||||||
});
|
});
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const editar = mutation({
|
export const editar = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.id("contratos"),
|
id: v.id('contratos'),
|
||||||
contratadaId: v.optional(v.id("empresas")),
|
contratadaId: v.optional(v.id('empresas')),
|
||||||
objeto: v.optional(v.string()),
|
objeto: v.optional(v.string()),
|
||||||
numeroNotaEmpenho: v.optional(v.string()),
|
numeroNotaEmpenho: v.optional(v.string()),
|
||||||
responsavelId: v.optional(v.id("funcionarios")),
|
responsavelId: v.optional(v.id('funcionarios')),
|
||||||
departamento: v.optional(v.string()),
|
departamento: v.optional(v.string()),
|
||||||
situacao: v.optional(situacaoContrato),
|
situacao: v.optional(situacaoContrato),
|
||||||
numeroProcessoLicitatorio: v.optional(v.string()),
|
numeroProcessoLicitatorio: v.optional(v.string()),
|
||||||
modalidade: v.optional(v.string()),
|
modalidade: v.optional(v.string()),
|
||||||
numeroContrato: v.optional(v.string()),
|
numeroContrato: v.optional(v.string()),
|
||||||
anoContrato: v.optional(v.number()),
|
anoContrato: v.optional(v.number()),
|
||||||
dataInicioVigencia: v.optional(v.string()),
|
dataInicioVigencia: v.optional(v.string()),
|
||||||
dataFimVigencia: v.optional(v.string()),
|
dataFimVigencia: v.optional(v.string()),
|
||||||
nomeFiscal: v.optional(v.string()),
|
nomeFiscal: v.optional(v.string()),
|
||||||
valorTotal: v.optional(v.string()),
|
valorTotal: v.optional(v.string()),
|
||||||
dataAditivoPrazo: v.optional(v.string()),
|
dataAditivoPrazo: v.optional(v.string()),
|
||||||
diasAvisoVencimento: v.optional(v.number()),
|
diasAvisoVencimento: v.optional(v.number())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: "contratos",
|
recurso: 'contratos',
|
||||||
acao: "editar",
|
acao: 'editar'
|
||||||
});
|
});
|
||||||
|
|
||||||
const { id, ...campos } = args;
|
const { id, ...campos } = args;
|
||||||
|
|
||||||
await ctx.db.patch(id, {
|
await ctx.db.patch(id, {
|
||||||
...campos,
|
...campos,
|
||||||
atualizadoEm: Date.now(),
|
atualizadoEm: Date.now()
|
||||||
});
|
});
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const excluir = mutation({
|
export const excluir = mutation({
|
||||||
args: { id: v.id("contratos") },
|
args: { id: v.id('contratos') },
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
|
||||||
recurso: "contratos",
|
recurso: 'contratos',
|
||||||
acao: "excluir",
|
acao: 'excluir'
|
||||||
});
|
});
|
||||||
await ctx.db.delete(args.id);
|
await ctx.db.delete(args.id);
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const verificarVencimentos = query({
|
export const verificarVencimentos = query({
|
||||||
args: {},
|
args: {},
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
// Esta query pode ser usada por um componente de notificação ou cron job
|
// Esta query pode ser usada por um componente de notificação ou cron job
|
||||||
// Retorna contratos que estão próximos do vencimento baseados no diasAvisoVencimento
|
// Retorna contratos que estão próximos do vencimento baseados no diasAvisoVencimento
|
||||||
|
|
||||||
const hoje = new Date();
|
const hoje = new Date();
|
||||||
const hojeStr = hoje.toISOString().split("T")[0];
|
const hojeStr = hoje.toISOString().split('T')[0];
|
||||||
|
|
||||||
// Buscar contratos ativos (em execução ou aguardando assinatura)
|
// Buscar contratos ativos (em execução ou aguardando assinatura)
|
||||||
const contratos = await ctx.db
|
const contratos = await ctx.db
|
||||||
.query("contratos")
|
.query('contratos')
|
||||||
.filter((q) =>
|
.filter((q) =>
|
||||||
q.or(
|
q.or(
|
||||||
q.eq(q.field("situacao"), "em_execucao"),
|
q.eq(q.field('situacao'), 'em_execucao'),
|
||||||
q.eq(q.field("situacao"), "aguardando_assinatura")
|
q.eq(q.field('situacao'), 'aguardando_assinatura')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const proximosVencimento = contratos.filter((c) => {
|
const proximosVencimento = contratos.filter((c) => {
|
||||||
if (!c.dataFimVigencia) return false;
|
if (!c.dataFimVigencia) return false;
|
||||||
|
|
||||||
const dataFim = new Date(c.dataFimVigencia);
|
const dataFim = new Date(c.dataFimVigencia);
|
||||||
const dataAviso = new Date(dataFim);
|
const dataAviso = new Date(dataFim);
|
||||||
dataAviso.setDate(dataAviso.getDate() - c.diasAvisoVencimento);
|
dataAviso.setDate(dataAviso.getDate() - c.diasAvisoVencimento);
|
||||||
|
|
||||||
const dataAvisoStr = dataAviso.toISOString().split("T")[0];
|
const dataAvisoStr = dataAviso.toISOString().split('T')[0];
|
||||||
|
|
||||||
// Se hoje for maior ou igual a data de aviso e menor que a data fim
|
// Se hoje for maior ou igual a data de aviso e menor que a data fim
|
||||||
return hojeStr >= dataAvisoStr && hojeStr <= c.dataFimVigencia;
|
return hojeStr >= dataAvisoStr && hojeStr <= c.dataFimVigencia;
|
||||||
});
|
});
|
||||||
|
|
||||||
return proximosVencimento;
|
return proximosVencimento;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { v } from 'convex/values';
|
|||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
import type { Id, Doc } from './_generated/dataModel';
|
import type { Id, Doc } from './_generated/dataModel';
|
||||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { flowTemplateStatus, flowInstanceStatus, flowInstanceStepStatus } from './schema';
|
import { flowInstanceStatus, flowInstanceStepStatus, flowTemplateStatus } from './tables/flows';
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
@@ -852,7 +852,7 @@ export const getInstanceWithSteps = query({
|
|||||||
|
|
||||||
// Verificar permissão de visualização
|
// Verificar permissão de visualização
|
||||||
const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
|
const temPermissaoVerTodas = await verificarPermissaoVerTodasFluxos(ctx);
|
||||||
|
|
||||||
if (!temPermissaoVerTodas) {
|
if (!temPermissaoVerTodas) {
|
||||||
// Verificar se usuário pertence a algum setor do fluxo ou é o manager
|
// Verificar se usuário pertence a algum setor do fluxo ou é o manager
|
||||||
const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
|
const pertenceAoSetor = await usuarioPertenceAAlgumSetorDoFluxo(
|
||||||
@@ -860,7 +860,7 @@ export const getInstanceWithSteps = query({
|
|||||||
usuario._id,
|
usuario._id,
|
||||||
instance._id
|
instance._id
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!pertenceAoSetor && instance.managerId !== usuario._id) {
|
if (!pertenceAoSetor && instance.managerId !== usuario._id) {
|
||||||
return null; // Usuário não tem acesso
|
return null; // Usuário não tem acesso
|
||||||
}
|
}
|
||||||
@@ -1066,7 +1066,8 @@ export const instantiateFlow = mutation({
|
|||||||
|
|
||||||
for (let i = 0; i < templateSteps.length; i++) {
|
for (let i = 0; i < templateSteps.length; i++) {
|
||||||
const step = templateSteps[i];
|
const step = templateSteps[i];
|
||||||
const dueDate = now + cumulativeDays * 24 * 60 * 60 * 1000 + step.expectedDuration * 24 * 60 * 60 * 1000;
|
const dueDate =
|
||||||
|
now + cumulativeDays * 24 * 60 * 60 * 1000 + step.expectedDuration * 24 * 60 * 60 * 1000;
|
||||||
cumulativeDays += step.expectedDuration;
|
cumulativeDays += step.expectedDuration;
|
||||||
|
|
||||||
const instanceStepId = await ctx.db.insert('flowInstanceSteps', {
|
const instanceStepId = await ctx.db.insert('flowInstanceSteps', {
|
||||||
@@ -1202,7 +1203,12 @@ export const completeStep = mutation({
|
|||||||
if (nextSetor && nextFlowStep) {
|
if (nextSetor && nextFlowStep) {
|
||||||
const tituloProximoSetor = 'Nova Etapa de Fluxo Disponível';
|
const tituloProximoSetor = 'Nova Etapa de Fluxo Disponível';
|
||||||
const descricaoProximoSetor = `A etapa "${nextFlowStep.name}" do fluxo "${template?.name ?? 'Fluxo'}" está pronta para ser iniciada.`;
|
const descricaoProximoSetor = `A etapa "${nextFlowStep.name}" do fluxo "${template?.name ?? 'Fluxo'}" está pronta para ser iniciada.`;
|
||||||
await criarNotificacaoParaSetor(ctx, nextStepData.setorId, tituloProximoSetor, descricaoProximoSetor);
|
await criarNotificacaoParaSetor(
|
||||||
|
ctx,
|
||||||
|
nextStepData.setorId,
|
||||||
|
tituloProximoSetor,
|
||||||
|
descricaoProximoSetor
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1303,7 +1309,9 @@ export const alterarGestorFluxo = mutation({
|
|||||||
const eCriador = template?.createdBy === usuario._id;
|
const eCriador = template?.createdBy === usuario._id;
|
||||||
|
|
||||||
if (!eGestor && !temPermissao && !eCriador) {
|
if (!eGestor && !temPermissao && !eCriador) {
|
||||||
throw new Error('Somente o gestor atual, criador do template ou usuário com permissão pode alterar o gestor');
|
throw new Error(
|
||||||
|
'Somente o gestor atual, criador do template ou usuário com permissão pode alterar o gestor'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se novo gestor existe
|
// Verificar se novo gestor existe
|
||||||
@@ -1371,7 +1379,7 @@ export const reassignStep = mutation({
|
|||||||
if (!eCriador) {
|
if (!eCriador) {
|
||||||
// Se não for criador, verificar regra normal
|
// Se não for criador, verificar regra normal
|
||||||
const etapaAnterior = await obterEtapaAnterior(ctx, args.instanceStepId);
|
const etapaAnterior = await obterEtapaAnterior(ctx, args.instanceStepId);
|
||||||
|
|
||||||
if (etapaAnterior) {
|
if (etapaAnterior) {
|
||||||
// Se há etapa anterior, verificar se o usuário atual é a pessoa atribuída
|
// Se há etapa anterior, verificar se o usuário atual é a pessoa atribuída
|
||||||
if (etapaAnterior.assignedToId) {
|
if (etapaAnterior.assignedToId) {
|
||||||
@@ -1386,7 +1394,9 @@ export const reassignStep = mutation({
|
|||||||
if (instance.managerId !== usuario._id) {
|
if (instance.managerId !== usuario._id) {
|
||||||
const temPermissao = await verificarPermissaoVerTodasFluxos(ctx);
|
const temPermissao = await verificarPermissaoVerTodasFluxos(ctx);
|
||||||
if (!temPermissao) {
|
if (!temPermissao) {
|
||||||
throw new Error('Somente o gerente do fluxo ou pessoa da etapa anterior pode atribuir esta etapa');
|
throw new Error(
|
||||||
|
'Somente o gerente do fluxo ou pessoa da etapa anterior pode atribuir esta etapa'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1408,9 +1418,7 @@ export const reassignStep = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verificar se o usuário atribuído corresponde a um funcionário do setor
|
// Verificar se o usuário atribuído corresponde a um funcionário do setor
|
||||||
const funcionarioDoUsuario = funcionariosDoSetor.find(
|
const funcionarioDoUsuario = funcionariosDoSetor.find((f) => f.email === assignee.email);
|
||||||
(f) => f.email === assignee.email
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!funcionarioDoUsuario) {
|
if (!funcionarioDoUsuario) {
|
||||||
throw new Error('O funcionário atribuído não pertence ao setor deste passo');
|
throw new Error('O funcionário atribuído não pertence ao setor deste passo');
|
||||||
@@ -1441,7 +1449,7 @@ export const updateStepNotes = mutation({
|
|||||||
throw new Error('Passo não encontrado');
|
throw new Error('Passo não encontrado');
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.patch(args.instanceStepId, {
|
await ctx.db.patch(args.instanceStepId, {
|
||||||
notes: args.notes,
|
notes: args.notes,
|
||||||
notesUpdatedBy: usuario._id,
|
notesUpdatedBy: usuario._id,
|
||||||
notesUpdatedAt: Date.now()
|
notesUpdatedAt: Date.now()
|
||||||
@@ -1526,7 +1534,9 @@ export const listarSubEtapas = query({
|
|||||||
} else if (args.flowInstanceStepId) {
|
} else if (args.flowInstanceStepId) {
|
||||||
subEtapas = await ctx.db
|
subEtapas = await ctx.db
|
||||||
.query('flowSubSteps')
|
.query('flowSubSteps')
|
||||||
.withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
|
.withIndex('by_flowInstanceStepId', (q) =>
|
||||||
|
q.eq('flowInstanceStepId', args.flowInstanceStepId)
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
} else {
|
} else {
|
||||||
return [];
|
return [];
|
||||||
@@ -1607,7 +1617,9 @@ export const criarSubEtapa = mutation({
|
|||||||
} else if (args.flowInstanceStepId) {
|
} else if (args.flowInstanceStepId) {
|
||||||
const existingSubEtapas = await ctx.db
|
const existingSubEtapas = await ctx.db
|
||||||
.query('flowSubSteps')
|
.query('flowSubSteps')
|
||||||
.withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
|
.withIndex('by_flowInstanceStepId', (q) =>
|
||||||
|
q.eq('flowInstanceStepId', args.flowInstanceStepId)
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
if (existingSubEtapas.length > 0) {
|
if (existingSubEtapas.length > 0) {
|
||||||
maxPosition = Math.max(...existingSubEtapas.map((s) => s.position));
|
maxPosition = Math.max(...existingSubEtapas.map((s) => s.position));
|
||||||
@@ -1766,7 +1778,9 @@ export const listarNotas = query({
|
|||||||
} else if (args.flowInstanceStepId) {
|
} else if (args.flowInstanceStepId) {
|
||||||
notas = await ctx.db
|
notas = await ctx.db
|
||||||
.query('flowStepNotes')
|
.query('flowStepNotes')
|
||||||
.withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
|
.withIndex('by_flowInstanceStepId', (q) =>
|
||||||
|
q.eq('flowInstanceStepId', args.flowInstanceStepId)
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
} else if (args.flowSubStepId) {
|
} else if (args.flowSubStepId) {
|
||||||
notas = await ctx.db
|
notas = await ctx.db
|
||||||
@@ -1784,17 +1798,15 @@ export const listarNotas = query({
|
|||||||
const notasComDetalhes = await Promise.all(
|
const notasComDetalhes = await Promise.all(
|
||||||
notas.map(async (nota) => {
|
notas.map(async (nota) => {
|
||||||
const criador = await ctx.db.get(nota.criadoPor);
|
const criador = await ctx.db.get(nota.criadoPor);
|
||||||
|
|
||||||
// Obter informações dos arquivos
|
// Obter informações dos arquivos
|
||||||
const arquivosComNome = await Promise.all(
|
const arquivosComNome = await Promise.all(
|
||||||
nota.arquivos.map(async (storageId) => {
|
nota.arquivos.map(async (storageId) => {
|
||||||
// Buscar documento que referencia este storageId
|
// Buscar documento que referencia este storageId
|
||||||
// Como não temos uma tabela direta, vamos buscar nos flowInstanceDocuments
|
// Como não temos uma tabela direta, vamos buscar nos flowInstanceDocuments
|
||||||
const documentos = await ctx.db
|
const documentos = await ctx.db.query('flowInstanceDocuments').collect();
|
||||||
.query('flowInstanceDocuments')
|
|
||||||
.collect();
|
|
||||||
const documento = documentos.find((d) => d.storageId === storageId);
|
const documento = documentos.find((d) => d.storageId === storageId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
storageId,
|
storageId,
|
||||||
name: documento?.name ?? 'Arquivo'
|
name: documento?.name ?? 'Arquivo'
|
||||||
@@ -2003,7 +2015,9 @@ export const listDocumentsByStep = query({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const documents = await ctx.db
|
const documents = await ctx.db
|
||||||
.query('flowInstanceDocuments')
|
.query('flowInstanceDocuments')
|
||||||
.withIndex('by_flowInstanceStepId', (q) => q.eq('flowInstanceStepId', args.flowInstanceStepId))
|
.withIndex('by_flowInstanceStepId', (q) =>
|
||||||
|
q.eq('flowInstanceStepId', args.flowInstanceStepId)
|
||||||
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
const result: Array<{
|
const result: Array<{
|
||||||
@@ -2158,4 +2172,3 @@ export const getUsuariosBySetorForAssignment = query({
|
|||||||
return usuarios;
|
return usuarios;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Infer, v } from 'convex/values';
|
import { Infer, v } from 'convex/values';
|
||||||
import { query, mutation } from './_generated/server';
|
import { query, mutation } from './_generated/server';
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
import { simboloTipo } from './schema';
|
|
||||||
import { getCurrentUserFunction } from './auth';
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import { simboloTipo } from './tables/funcionarios';
|
||||||
|
|
||||||
// Validadores para campos opcionais
|
// Validadores para campos opcionais
|
||||||
const sexoValidator = v.optional(
|
const sexoValidator = v.optional(
|
||||||
@@ -60,7 +60,7 @@ export const getAll = query({
|
|||||||
recurso: 'funcionarios',
|
recurso: 'funcionarios',
|
||||||
acao: 'listar'
|
acao: 'listar'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Se não tiver permissão, retornar array vazio
|
// Se não tiver permissão, retornar array vazio
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,180 +1,180 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from 'convex/values';
|
||||||
import { mutation, query, QueryCtx, MutationCtx } from "./_generated/server";
|
import { MutationCtx, query } from './_generated/server';
|
||||||
import { Doc, Id } from "./_generated/dataModel";
|
import { Id } from './_generated/dataModel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper function para registrar atividades no sistema
|
* Helper function para registrar atividades no sistema
|
||||||
* Use em todas as mutations que modificam dados
|
* Use em todas as mutations que modificam dados
|
||||||
*/
|
*/
|
||||||
export async function registrarAtividade(
|
export async function registrarAtividade(
|
||||||
ctx: MutationCtx,
|
ctx: MutationCtx,
|
||||||
usuarioId: Id<"usuarios">,
|
usuarioId: Id<'usuarios'>,
|
||||||
acao: string,
|
acao: string,
|
||||||
recurso: string,
|
recurso: string,
|
||||||
detalhes?: string,
|
detalhes?: string,
|
||||||
recursoId?: string
|
recursoId?: string
|
||||||
) {
|
) {
|
||||||
await ctx.db.insert("logsAtividades", {
|
await ctx.db.insert('logsAtividades', {
|
||||||
usuarioId,
|
usuarioId,
|
||||||
acao,
|
acao,
|
||||||
recurso,
|
recurso,
|
||||||
recursoId,
|
recursoId,
|
||||||
detalhes,
|
detalhes,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lista atividades com filtros
|
* Lista atividades com filtros
|
||||||
*/
|
*/
|
||||||
export const listarAtividades = query({
|
export const listarAtividades = query({
|
||||||
args: {
|
args: {
|
||||||
usuarioId: v.optional(v.id("usuarios")),
|
usuarioId: v.optional(v.id('usuarios')),
|
||||||
acao: v.optional(v.string()),
|
acao: v.optional(v.string()),
|
||||||
recurso: v.optional(v.string()),
|
recurso: v.optional(v.string()),
|
||||||
dataInicio: v.optional(v.number()),
|
dataInicio: v.optional(v.number()),
|
||||||
dataFim: v.optional(v.number()),
|
dataFim: v.optional(v.number()),
|
||||||
limite: v.optional(v.number()),
|
limite: v.optional(v.number())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
let atividades;
|
let atividades;
|
||||||
|
|
||||||
if (args.usuarioId) {
|
if (args.usuarioId) {
|
||||||
atividades = await ctx.db
|
atividades = await ctx.db
|
||||||
.query("logsAtividades")
|
.query('logsAtividades')
|
||||||
.withIndex("by_usuario", (q) => q.eq("usuarioId", args.usuarioId!))
|
.withIndex('by_usuario', (q) => q.eq('usuarioId', args.usuarioId!))
|
||||||
.order("desc")
|
.order('desc')
|
||||||
.take(args.limite || 100);
|
.take(args.limite || 100);
|
||||||
} else if (args.acao) {
|
} else if (args.acao) {
|
||||||
atividades = await ctx.db
|
atividades = await ctx.db
|
||||||
.query("logsAtividades")
|
.query('logsAtividades')
|
||||||
.withIndex("by_acao", (q) => q.eq("acao", args.acao!))
|
.withIndex('by_acao', (q) => q.eq('acao', args.acao!))
|
||||||
.order("desc")
|
.order('desc')
|
||||||
.take(args.limite || 100);
|
.take(args.limite || 100);
|
||||||
} else if (args.recurso) {
|
} else if (args.recurso) {
|
||||||
atividades = await ctx.db
|
atividades = await ctx.db
|
||||||
.query("logsAtividades")
|
.query('logsAtividades')
|
||||||
.withIndex("by_recurso", (q) => q.eq("recurso", args.recurso!))
|
.withIndex('by_recurso', (q) => q.eq('recurso', args.recurso!))
|
||||||
.order("desc")
|
.order('desc')
|
||||||
.take(args.limite || 100);
|
.take(args.limite || 100);
|
||||||
} else {
|
} else {
|
||||||
atividades = await ctx.db
|
atividades = await ctx.db
|
||||||
.query("logsAtividades")
|
.query('logsAtividades')
|
||||||
.withIndex("by_timestamp")
|
.withIndex('by_timestamp')
|
||||||
.order("desc")
|
.order('desc')
|
||||||
.take(args.limite || 100);
|
.take(args.limite || 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrar por range de datas se fornecido
|
// Filtrar por range de datas se fornecido
|
||||||
if (args.dataInicio || args.dataFim) {
|
if (args.dataInicio || args.dataFim) {
|
||||||
atividades = atividades.filter((log) => {
|
atividades = atividades.filter((log) => {
|
||||||
if (args.dataInicio && log.timestamp < args.dataInicio) return false;
|
if (args.dataInicio && log.timestamp < args.dataInicio) return false;
|
||||||
if (args.dataFim && log.timestamp > args.dataFim) return false;
|
if (args.dataFim && log.timestamp > args.dataFim) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Buscar informações dos usuários
|
// Buscar informações dos usuários
|
||||||
const atividadesComUsuarios = await Promise.all(
|
const atividadesComUsuarios = await Promise.all(
|
||||||
atividades.map(async (atividade) => {
|
atividades.map(async (atividade) => {
|
||||||
const usuario = await ctx.db.get(atividade.usuarioId);
|
const usuario = await ctx.db.get(atividade.usuarioId);
|
||||||
let matricula = "N/A";
|
let matricula = 'N/A';
|
||||||
if (usuario?.funcionarioId) {
|
if (usuario?.funcionarioId) {
|
||||||
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
||||||
matricula = funcionario?.matricula || "N/A";
|
matricula = funcionario?.matricula || 'N/A';
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...atividade,
|
...atividade,
|
||||||
usuarioNome: usuario?.nome || "Usuário Desconhecido",
|
usuarioNome: usuario?.nome || 'Usuário Desconhecido',
|
||||||
usuarioMatricula: matricula,
|
usuarioMatricula: matricula
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return atividadesComUsuarios;
|
return atividadesComUsuarios;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtém estatísticas de atividades
|
* Obtém estatísticas de atividades
|
||||||
*/
|
*/
|
||||||
export const obterEstatisticasAtividades = query({
|
export const obterEstatisticasAtividades = query({
|
||||||
args: {
|
args: {
|
||||||
periodo: v.optional(v.number()), // dias (ex: 7, 30)
|
periodo: v.optional(v.number()) // dias (ex: 7, 30)
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const periodo = args.periodo || 30;
|
const periodo = args.periodo || 30;
|
||||||
const dataInicio = Date.now() - periodo * 24 * 60 * 60 * 1000;
|
const dataInicio = Date.now() - periodo * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const atividades = await ctx.db
|
const atividades = await ctx.db
|
||||||
.query("logsAtividades")
|
.query('logsAtividades')
|
||||||
.withIndex("by_timestamp")
|
.withIndex('by_timestamp')
|
||||||
.filter((q) => q.gte(q.field("timestamp"), dataInicio))
|
.filter((q) => q.gte(q.field('timestamp'), dataInicio))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Agrupar por ação
|
// Agrupar por ação
|
||||||
const porAcao: Record<string, number> = {};
|
const porAcao: Record<string, number> = {};
|
||||||
atividades.forEach((ativ) => {
|
atividades.forEach((ativ) => {
|
||||||
porAcao[ativ.acao] = (porAcao[ativ.acao] || 0) + 1;
|
porAcao[ativ.acao] = (porAcao[ativ.acao] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agrupar por recurso
|
// Agrupar por recurso
|
||||||
const porRecurso: Record<string, number> = {};
|
const porRecurso: Record<string, number> = {};
|
||||||
atividades.forEach((ativ) => {
|
atividades.forEach((ativ) => {
|
||||||
porRecurso[ativ.recurso] = (porRecurso[ativ.recurso] || 0) + 1;
|
porRecurso[ativ.recurso] = (porRecurso[ativ.recurso] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Agrupar por dia
|
// Agrupar por dia
|
||||||
const porDia: Record<string, number> = {};
|
const porDia: Record<string, number> = {};
|
||||||
atividades.forEach((ativ) => {
|
atividades.forEach((ativ) => {
|
||||||
const data = new Date(ativ.timestamp);
|
const data = new Date(ativ.timestamp);
|
||||||
const dia = data.toISOString().split("T")[0];
|
const dia = data.toISOString().split('T')[0];
|
||||||
porDia[dia] = (porDia[dia] || 0) + 1;
|
porDia[dia] = (porDia[dia] || 0) + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: atividades.length,
|
total: atividades.length,
|
||||||
porAcao,
|
porAcao,
|
||||||
porRecurso,
|
porRecurso,
|
||||||
porDia,
|
porDia
|
||||||
};
|
};
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtém histórico de atividades de um recurso específico
|
* Obtém histórico de atividades de um recurso específico
|
||||||
*/
|
*/
|
||||||
export const obterHistoricoRecurso = query({
|
export const obterHistoricoRecurso = query({
|
||||||
args: {
|
args: {
|
||||||
recurso: v.string(),
|
recurso: v.string(),
|
||||||
recursoId: v.string(),
|
recursoId: v.string()
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const atividades = await ctx.db
|
const atividades = await ctx.db
|
||||||
.query("logsAtividades")
|
.query('logsAtividades')
|
||||||
.withIndex("by_recurso_id", (q) =>
|
.withIndex('by_recurso_id', (q) =>
|
||||||
q.eq("recurso", args.recurso).eq("recursoId", args.recursoId)
|
q.eq('recurso', args.recurso).eq('recursoId', args.recursoId)
|
||||||
)
|
)
|
||||||
.order("desc")
|
.order('desc')
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Buscar informações dos usuários
|
// Buscar informações dos usuários
|
||||||
const atividadesComUsuarios = await Promise.all(
|
const atividadesComUsuarios = await Promise.all(
|
||||||
atividades.map(async (atividade) => {
|
atividades.map(async (atividade) => {
|
||||||
const usuario = await ctx.db.get(atividade.usuarioId);
|
const usuario = await ctx.db.get(atividade.usuarioId);
|
||||||
let matricula = "N/A";
|
let matricula = 'N/A';
|
||||||
if (usuario?.funcionarioId) {
|
if (usuario?.funcionarioId) {
|
||||||
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
const funcionario = await ctx.db.get(usuario.funcionarioId);
|
||||||
matricula = funcionario?.matricula || "N/A";
|
matricula = funcionario?.matricula || 'N/A';
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
...atividade,
|
...atividade,
|
||||||
usuarioNome: usuario?.nome || "Usuário Desconhecido",
|
usuarioNome: usuario?.nome || 'Usuário Desconhecido',
|
||||||
usuarioMatricula: matricula,
|
usuarioMatricula: matricula
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return atividadesComUsuarios;
|
return atividadesComUsuarios;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
596
packages/backend/convex/pedidos.ts
Normal file
596
packages/backend/convex/pedidos.ts
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
import { mutation, query, internalMutation } from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
import { api, internal } from './_generated/api';
|
||||||
|
import type { Doc, Id } from './_generated/dataModel';
|
||||||
|
import type { QueryCtx, MutationCtx } from './_generated/server';
|
||||||
|
|
||||||
|
// ========== HELPERS ==========
|
||||||
|
|
||||||
|
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== QUERIES ==========
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidos'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('em_rascunho'),
|
||||||
|
v.literal('aguardando_aceite'),
|
||||||
|
v.literal('em_analise'),
|
||||||
|
v.literal('precisa_ajustes'),
|
||||||
|
v.literal('cancelado'),
|
||||||
|
v.literal('concluido')
|
||||||
|
),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query('pedidos').collect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const get = query({
|
||||||
|
args: { id: v.id('pedidos') },
|
||||||
|
returns: v.union(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidos'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('em_rascunho'),
|
||||||
|
v.literal('aguardando_aceite'),
|
||||||
|
v.literal('em_analise'),
|
||||||
|
v.literal('precisa_ajustes'),
|
||||||
|
v.literal('cancelado'),
|
||||||
|
v.literal('concluido')
|
||||||
|
),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}),
|
||||||
|
v.null()
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db.get(args.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getItems = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidoItems'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
produtoId: v.id('produtos'),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
valorReal: v.optional(v.string()),
|
||||||
|
quantidade: v.number(),
|
||||||
|
adicionadoPor: v.id('funcionarios'),
|
||||||
|
adicionadoPorNome: v.string(),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('pedidoItems')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get employee names
|
||||||
|
const itemsWithNames = await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const funcionario = await ctx.db.get(item.adicionadoPor);
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
adicionadoPorNome: funcionario?.nome || 'Desconhecido'
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return itemsWithNames;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getHistory = query({
|
||||||
|
args: { pedidoId: v.id('pedidos') },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const history = await ctx.db
|
||||||
|
.query('historicoPedidos')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
||||||
|
.order('desc')
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Get user names
|
||||||
|
const historyWithNames = await Promise.all(
|
||||||
|
history.map(async (entry) => {
|
||||||
|
const usuario = await ctx.db.get(entry.usuarioId);
|
||||||
|
return {
|
||||||
|
_id: entry._id,
|
||||||
|
_creationTime: entry._creationTime,
|
||||||
|
pedidoId: entry.pedidoId,
|
||||||
|
usuarioId: entry.usuarioId,
|
||||||
|
usuarioNome: usuario?.nome || 'Desconhecido',
|
||||||
|
acao: entry.acao,
|
||||||
|
detalhes: entry.detalhes,
|
||||||
|
data: entry.data
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return historyWithNames;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const checkExisting = query({
|
||||||
|
args: {
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
produtoIds: v.optional(v.array(v.id('produtos')))
|
||||||
|
},
|
||||||
|
returns: v.array(
|
||||||
|
v.object({
|
||||||
|
_id: v.id('pedidos'),
|
||||||
|
_creationTime: v.number(),
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('em_rascunho'),
|
||||||
|
v.literal('aguardando_aceite'),
|
||||||
|
v.literal('em_analise'),
|
||||||
|
v.literal('precisa_ajustes'),
|
||||||
|
v.literal('cancelado'),
|
||||||
|
v.literal('concluido')
|
||||||
|
),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
matchingItems: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
produtoId: v.id('produtos'),
|
||||||
|
quantidade: v.number()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) return [];
|
||||||
|
|
||||||
|
const openStatuses: Array<
|
||||||
|
'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes'
|
||||||
|
> = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes'];
|
||||||
|
|
||||||
|
// 1) Buscar todos os pedidos "abertos" usando o índice by_status
|
||||||
|
let pedidosAbertos: Doc<'pedidos'>[] = [];
|
||||||
|
for (const status of openStatuses) {
|
||||||
|
const partial = await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_status', (q) => q.eq('status', status))
|
||||||
|
.collect();
|
||||||
|
pedidosAbertos = pedidosAbertos.concat(partial);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Filtros opcionais: acaoId e numeroSei
|
||||||
|
pedidosAbertos = pedidosAbertos.filter((p) => {
|
||||||
|
if (args.acaoId && p.acaoId !== args.acaoId) return false;
|
||||||
|
if (args.numeroSei && p.numeroSei !== args.numeroSei) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3) Filtro por produtos (se informado) e coleta de matchingItems
|
||||||
|
const resultados = [];
|
||||||
|
|
||||||
|
for (const pedido of pedidosAbertos) {
|
||||||
|
let include = true;
|
||||||
|
let matchingItems: { produtoId: Id<'produtos'>; quantidade: number }[] = [];
|
||||||
|
|
||||||
|
// Se houver filtro de produtos, verificamos se o pedido tem ALGUM dos produtos
|
||||||
|
if (args.produtoIds && args.produtoIds.length > 0) {
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('pedidoItems')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// const pedidoProdutoIds = new Set(items.map((i) => i.produtoId)); // Unused
|
||||||
|
const matching = items.filter((i) => args.produtoIds?.includes(i.produtoId));
|
||||||
|
|
||||||
|
if (matching.length > 0) {
|
||||||
|
matchingItems = matching.map((i) => ({
|
||||||
|
produtoId: i.produtoId,
|
||||||
|
quantidade: i.quantidade
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// Se foi pedido filtro por produtos e não tem nenhum match, ignoramos este pedido
|
||||||
|
// A MENOS que tenha dado match por numeroSei ou acaoId?
|
||||||
|
// A regra original era: "Filtro por produtos (se informado)"
|
||||||
|
// Se o usuário informou produtos, ele quer ver pedidos que tenham esses produtos.
|
||||||
|
// Mas se ele TAMBÉM informou numeroSei, talvez ele queira ver aquele pedido específico mesmo sem o produto?
|
||||||
|
// Vamos manter a lógica de "E": se informou produtos, tem que ter o produto.
|
||||||
|
include = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (include) {
|
||||||
|
resultados.push({
|
||||||
|
_id: pedido._id,
|
||||||
|
_creationTime: pedido._creationTime,
|
||||||
|
numeroSei: pedido.numeroSei,
|
||||||
|
status: pedido.status,
|
||||||
|
acaoId: pedido.acaoId,
|
||||||
|
criadoPor: pedido.criadoPor,
|
||||||
|
criadoEm: pedido.criadoEm,
|
||||||
|
atualizadoEm: pedido.atualizadoEm,
|
||||||
|
matchingItems: matchingItems.length > 0 ? matchingItems : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultados;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== MUTATIONS ==========
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
acaoId: v.optional(v.id('acoes'))
|
||||||
|
},
|
||||||
|
returns: v.id('pedidos'),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
// 1. Check Config
|
||||||
|
const config = await ctx.db.query('config').first();
|
||||||
|
if (!config || !config.comprasSetorId) {
|
||||||
|
throw new Error('Setor de Compras não configurado. Contate o administrador.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Existing (Double check)
|
||||||
|
if (args.acaoId) {
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.withIndex('by_acaoId', (q) => q.eq('acaoId', args.acaoId))
|
||||||
|
.filter((q) =>
|
||||||
|
q.or(
|
||||||
|
q.eq(q.field('status'), 'em_rascunho'),
|
||||||
|
q.eq(q.field('status'), 'aguardando_aceite'),
|
||||||
|
q.eq(q.field('status'), 'em_analise'),
|
||||||
|
q.eq(q.field('status'), 'precisa_ajustes')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Já existe um pedido em andamento para esta ação.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create Order
|
||||||
|
const pedidoId = await ctx.db.insert('pedidos', {
|
||||||
|
numeroSei: args.numeroSei,
|
||||||
|
status: 'em_rascunho',
|
||||||
|
acaoId: args.acaoId,
|
||||||
|
criadoPor: user._id,
|
||||||
|
criadoEm: Date.now(),
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Create History
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'criacao',
|
||||||
|
detalhes: JSON.stringify({ numeroSei: args.numeroSei, acaoId: args.acaoId }),
|
||||||
|
data: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return pedidoId;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateSeiNumber = mutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
numeroSei: v.string()
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
const pedido = await ctx.db.get(args.pedidoId);
|
||||||
|
if (!pedido) throw new Error('Pedido not found');
|
||||||
|
|
||||||
|
// Check if SEI number is already taken by another order
|
||||||
|
const existing = await ctx.db
|
||||||
|
.query('pedidos')
|
||||||
|
.filter((q) =>
|
||||||
|
q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId))
|
||||||
|
)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new Error('Este número SEI já está em uso por outro pedido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldSei = pedido.numeroSei;
|
||||||
|
|
||||||
|
await ctx.db.patch(args.pedidoId, {
|
||||||
|
numeroSei: args.numeroSei,
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'atualizacao_sei',
|
||||||
|
detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }),
|
||||||
|
data: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addItem = mutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
produtoId: v.id('produtos'),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
quantidade: v.number()
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
// Ensure user has a funcionarioId linked
|
||||||
|
if (!user.funcionarioId) {
|
||||||
|
throw new Error('Usuário não vinculado a um funcionário.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db.insert('pedidoItems', {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
produtoId: args.produtoId,
|
||||||
|
valorEstimado: args.valorEstimado,
|
||||||
|
quantidade: args.quantidade,
|
||||||
|
adicionadoPor: user.funcionarioId,
|
||||||
|
criadoEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'adicao_item',
|
||||||
|
detalhes: JSON.stringify({
|
||||||
|
produtoId: args.produtoId,
|
||||||
|
valor: args.valorEstimado,
|
||||||
|
quantidade: args.quantidade
|
||||||
|
}),
|
||||||
|
data: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateItemQuantity = mutation({
|
||||||
|
args: {
|
||||||
|
itemId: v.id('pedidoItems'),
|
||||||
|
novaQuantidade: v.number()
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
if (!user.funcionarioId) {
|
||||||
|
throw new Error('Usuário não vinculado a um funcionário.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = await ctx.db.get(args.itemId);
|
||||||
|
if (!item) throw new Error('Item não encontrado.');
|
||||||
|
|
||||||
|
const quantidadeAnterior = item.quantidade;
|
||||||
|
|
||||||
|
// Check permission: only item owner can decrease quantity
|
||||||
|
const isOwner = item.adicionadoPor === user.funcionarioId;
|
||||||
|
const isDecreasing = args.novaQuantidade < quantidadeAnterior;
|
||||||
|
|
||||||
|
if (isDecreasing && !isOwner) {
|
||||||
|
throw new Error(
|
||||||
|
'Apenas quem adicionou este item pode diminuir a quantidade. Você pode apenas aumentar.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update quantity
|
||||||
|
await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade });
|
||||||
|
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
|
||||||
|
|
||||||
|
// Create history entry
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId: item.pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'alteracao_quantidade',
|
||||||
|
detalhes: JSON.stringify({
|
||||||
|
produtoId: item.produtoId,
|
||||||
|
quantidadeAnterior,
|
||||||
|
novaQuantidade: args.novaQuantidade
|
||||||
|
}),
|
||||||
|
data: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const removeItem = mutation({
|
||||||
|
args: {
|
||||||
|
itemId: v.id('pedidoItems')
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
|
||||||
|
const item = await ctx.db.get(args.itemId);
|
||||||
|
if (!item) throw new Error('Item not found');
|
||||||
|
|
||||||
|
await ctx.db.delete(args.itemId);
|
||||||
|
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId: item.pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'remocao_item',
|
||||||
|
detalhes: JSON.stringify({ produtoId: item.produtoId, valor: item.valorEstimado }),
|
||||||
|
data: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const updateStatus = mutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
novoStatus: v.union(
|
||||||
|
v.literal('em_rascunho'),
|
||||||
|
v.literal('aguardando_aceite'),
|
||||||
|
v.literal('em_analise'),
|
||||||
|
v.literal('precisa_ajustes'),
|
||||||
|
v.literal('cancelado'),
|
||||||
|
v.literal('concluido')
|
||||||
|
)
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getUsuarioAutenticado(ctx);
|
||||||
|
const pedido = await ctx.db.get(args.pedidoId);
|
||||||
|
if (!pedido) throw new Error('Pedido not found');
|
||||||
|
|
||||||
|
const oldStatus = pedido.status;
|
||||||
|
|
||||||
|
await ctx.db.patch(args.pedidoId, {
|
||||||
|
status: args.novoStatus,
|
||||||
|
atualizadoEm: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.db.insert('historicoPedidos', {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
usuarioId: user._id,
|
||||||
|
acao: 'alteracao_status',
|
||||||
|
detalhes: JSON.stringify({ de: oldStatus, para: args.novoStatus }),
|
||||||
|
data: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trigger Notifications
|
||||||
|
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
|
||||||
|
pedidoId: args.pedidoId,
|
||||||
|
oldStatus,
|
||||||
|
newStatus: args.novoStatus,
|
||||||
|
actorId: user._id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== INTERNAL (NOTIFICATIONS) ==========
|
||||||
|
|
||||||
|
export const notifyStatusChange = internalMutation({
|
||||||
|
args: {
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
oldStatus: v.string(),
|
||||||
|
newStatus: v.string(),
|
||||||
|
actorId: v.id('usuarios')
|
||||||
|
},
|
||||||
|
returns: v.null(),
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const pedido = await ctx.db.get(args.pedidoId);
|
||||||
|
if (!pedido) return;
|
||||||
|
|
||||||
|
const actor = await ctx.db.get(args.actorId);
|
||||||
|
const actorName = actor ? actor.nome : 'Alguém';
|
||||||
|
|
||||||
|
const recipients = new Set<string>(); // Set of User IDs
|
||||||
|
|
||||||
|
// 1. If status is "aguardando_aceite", notify Purchasing Sector
|
||||||
|
if (args.newStatus === 'aguardando_aceite') {
|
||||||
|
const config = await ctx.db.query('config').first();
|
||||||
|
if (config && config.comprasSetorId) {
|
||||||
|
// Find all employees in this sector
|
||||||
|
const funcionarioSetores = await ctx.db
|
||||||
|
.query('funcionarioSetores')
|
||||||
|
.withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId);
|
||||||
|
|
||||||
|
// Find users linked to these employees
|
||||||
|
for (const fId of funcionarioIds) {
|
||||||
|
const user = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId))
|
||||||
|
.first();
|
||||||
|
if (user) recipients.add(user._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Notify "Involved" users (Creator + Item Adders)
|
||||||
|
// Always notify creator (unless they are the actor)
|
||||||
|
if (pedido.criadoPor !== args.actorId) {
|
||||||
|
recipients.add(pedido.criadoPor);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify item adders
|
||||||
|
const items = await ctx.db
|
||||||
|
.query('pedidoItems')
|
||||||
|
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const user = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor))
|
||||||
|
.first();
|
||||||
|
if (user && user._id !== args.actorId) {
|
||||||
|
recipients.add(user._id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Notifications
|
||||||
|
for (const recipientId of recipients) {
|
||||||
|
const recipientIdTyped = recipientId as Id<'usuarios'>;
|
||||||
|
|
||||||
|
// 1. In-App Notification
|
||||||
|
await ctx.db.insert('notificacoes', {
|
||||||
|
usuarioId: recipientIdTyped,
|
||||||
|
tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications
|
||||||
|
titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`,
|
||||||
|
descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`,
|
||||||
|
lida: false,
|
||||||
|
criadaEm: Date.now(),
|
||||||
|
remetenteId: args.actorId
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Email Notification (Async)
|
||||||
|
const recipientUser = await ctx.db.get(recipientIdTyped);
|
||||||
|
if (recipientUser && recipientUser.email) {
|
||||||
|
// Using enfileirarEmail directly
|
||||||
|
await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, {
|
||||||
|
destinatario: recipientUser.email,
|
||||||
|
destinatarioId: recipientIdTyped,
|
||||||
|
assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`,
|
||||||
|
corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`,
|
||||||
|
enviadoPor: args.actorId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -395,6 +395,100 @@ const PERMISSOES_BASE = {
|
|||||||
recurso: 'fluxos_documentos',
|
recurso: 'fluxos_documentos',
|
||||||
acao: 'excluir',
|
acao: 'excluir',
|
||||||
descricao: 'Excluir documentos de fluxos'
|
descricao: 'Excluir documentos de fluxos'
|
||||||
|
},
|
||||||
|
// Pedidos
|
||||||
|
{
|
||||||
|
nome: 'pedidos.listar',
|
||||||
|
recurso: 'pedidos',
|
||||||
|
acao: 'listar',
|
||||||
|
descricao: 'Listar pedidos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'pedidos.criar',
|
||||||
|
recurso: 'pedidos',
|
||||||
|
acao: 'criar',
|
||||||
|
descricao: 'Criar novos pedidos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'pedidos.ver',
|
||||||
|
recurso: 'pedidos',
|
||||||
|
acao: 'ver',
|
||||||
|
descricao: 'Visualizar detalhes de pedidos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'pedidos.editar_status',
|
||||||
|
recurso: 'pedidos',
|
||||||
|
acao: 'editar_status',
|
||||||
|
descricao: 'Alterar status de pedidos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'pedidos.adicionar_item',
|
||||||
|
recurso: 'pedidos',
|
||||||
|
acao: 'adicionar_item',
|
||||||
|
descricao: 'Adicionar itens ao pedido'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'pedidos.remover_item',
|
||||||
|
recurso: 'pedidos',
|
||||||
|
acao: 'remover_item',
|
||||||
|
descricao: 'Remover itens do pedido'
|
||||||
|
},
|
||||||
|
// Produtos
|
||||||
|
{
|
||||||
|
nome: 'produtos.listar',
|
||||||
|
recurso: 'produtos',
|
||||||
|
acao: 'listar',
|
||||||
|
descricao: 'Listar produtos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'produtos.criar',
|
||||||
|
recurso: 'produtos',
|
||||||
|
acao: 'criar',
|
||||||
|
descricao: 'Criar novos produtos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'produtos.editar',
|
||||||
|
recurso: 'produtos',
|
||||||
|
acao: 'editar',
|
||||||
|
descricao: 'Editar produtos'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'produtos.excluir',
|
||||||
|
recurso: 'produtos',
|
||||||
|
acao: 'excluir',
|
||||||
|
descricao: 'Excluir produtos'
|
||||||
|
},
|
||||||
|
// Ações
|
||||||
|
{
|
||||||
|
nome: 'acoes.listar',
|
||||||
|
recurso: 'acoes',
|
||||||
|
acao: 'listar',
|
||||||
|
descricao: 'Listar ações'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'acoes.criar',
|
||||||
|
recurso: 'acoes',
|
||||||
|
acao: 'criar',
|
||||||
|
descricao: 'Criar novas ações'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'acoes.editar',
|
||||||
|
recurso: 'acoes',
|
||||||
|
acao: 'editar',
|
||||||
|
descricao: 'Editar ações'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nome: 'acoes.excluir',
|
||||||
|
recurso: 'acoes',
|
||||||
|
acao: 'excluir',
|
||||||
|
descricao: 'Excluir ações'
|
||||||
|
},
|
||||||
|
// Configuração Compras
|
||||||
|
{
|
||||||
|
nome: 'config.compras.gerenciar',
|
||||||
|
recurso: 'config',
|
||||||
|
acao: 'gerenciar_compras',
|
||||||
|
descricao: 'Gerenciar configurações de compras'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
69
packages/backend/convex/produtos.ts
Normal file
69
packages/backend/convex/produtos.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { mutation, query } from './_generated/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { getCurrentUserFunction } from './auth';
|
||||||
|
|
||||||
|
export const list = query({
|
||||||
|
args: {},
|
||||||
|
handler: async (ctx) => {
|
||||||
|
return await ctx.db.query('produtos').collect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const search = query({
|
||||||
|
args: { query: v.string() },
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
return await ctx.db
|
||||||
|
.query('produtos')
|
||||||
|
.withSearchIndex('search_nome', (q) => q.search('nome', args.query))
|
||||||
|
.take(10);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
nome: v.string(),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo'))
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
return await ctx.db.insert('produtos', {
|
||||||
|
...args,
|
||||||
|
criadoPor: user._id,
|
||||||
|
criadoEm: Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const update = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('produtos'),
|
||||||
|
nome: v.string(),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo'))
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
await ctx.db.patch(args.id, {
|
||||||
|
nome: args.nome,
|
||||||
|
valorEstimado: args.valorEstimado,
|
||||||
|
tipo: args.tipo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const remove = mutation({
|
||||||
|
args: {
|
||||||
|
id: v.id('produtos')
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
const user = await getCurrentUserFunction(ctx);
|
||||||
|
if (!user) throw new Error('Unauthorized');
|
||||||
|
|
||||||
|
await ctx.db.delete(args.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,12 @@
|
|||||||
import { v } from 'convex/values';
|
import { v } from 'convex/values';
|
||||||
import {
|
import { internalMutation, mutation, query } from './_generated/server';
|
||||||
internalMutation,
|
|
||||||
mutation,
|
|
||||||
query
|
|
||||||
} from './_generated/server';
|
|
||||||
import { internal } from './_generated/api';
|
import { internal } from './_generated/api';
|
||||||
import type { Id } from './_generated/dataModel';
|
import type { Id } from './_generated/dataModel';
|
||||||
import type {
|
import type {
|
||||||
AtaqueCiberneticoTipo,
|
AtaqueCiberneticoTipo,
|
||||||
SeveridadeSeguranca,
|
SeveridadeSeguranca,
|
||||||
StatusEventoSeguranca
|
StatusEventoSeguranca
|
||||||
} from './schema';
|
} from './tables/security';
|
||||||
import type { MutationCtx, QueryCtx } from './_generated/server';
|
import type { MutationCtx, QueryCtx } from './_generated/server';
|
||||||
import { RateLimiter, SECOND } from '@convex-dev/rate-limiter';
|
import { RateLimiter, SECOND } from '@convex-dev/rate-limiter';
|
||||||
import { components } from './_generated/api';
|
import { components } from './_generated/api';
|
||||||
@@ -413,9 +409,9 @@ const acaoOrigemValidator = v.union(v.literal('automatico'), v.literal('manual')
|
|||||||
// Função para analisar string e detectar ataques
|
// Função para analisar string e detectar ataques
|
||||||
function analisarStringParaAtaques(texto: string): AtaqueCiberneticoTipo | null {
|
function analisarStringParaAtaques(texto: string): AtaqueCiberneticoTipo | null {
|
||||||
if (!texto) return null;
|
if (!texto) return null;
|
||||||
|
|
||||||
const textoLower = texto.toLowerCase();
|
const textoLower = texto.toLowerCase();
|
||||||
|
|
||||||
// Verificar cada tipo de ataque em ordem de prioridade
|
// Verificar cada tipo de ataque em ordem de prioridade
|
||||||
for (const tipo of ATAQUES_PRIORITARIOS) {
|
for (const tipo of ATAQUES_PRIORITARIOS) {
|
||||||
const patterns = KEYWORDS[tipo];
|
const patterns = KEYWORDS[tipo];
|
||||||
@@ -423,7 +419,7 @@ function analisarStringParaAtaques(texto: string): AtaqueCiberneticoTipo | null
|
|||||||
return tipo;
|
return tipo;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,14 +587,23 @@ export const registrarEventoSeguranca = mutation({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Aplicar rate limiting por IP se fornecido
|
// Aplicar rate limiting por IP se fornecido
|
||||||
if (args.origemIp) {
|
if (args.origemIp) {
|
||||||
const rateLimitResult = await aplicarRateLimit(ctx, 'ip', args.origemIp, 'registrarEventoSeguranca');
|
const rateLimitResult = await aplicarRateLimit(
|
||||||
|
ctx,
|
||||||
|
'ip',
|
||||||
|
args.origemIp,
|
||||||
|
'registrarEventoSeguranca'
|
||||||
|
);
|
||||||
if (!rateLimitResult.permitido) {
|
if (!rateLimitResult.permitido) {
|
||||||
throw new Error(rateLimitResult.motivo ?? 'Rate limit excedido');
|
throw new Error(rateLimitResult.motivo ?? 'Rate limit excedido');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tipo = inferirTipoAtaque(args);
|
const tipo = inferirTipoAtaque(args);
|
||||||
const severidade = calcularSeveridade(tipo, args.metricas ?? undefined, args.severidade ?? undefined);
|
const severidade = calcularSeveridade(
|
||||||
|
tipo,
|
||||||
|
args.metricas ?? undefined,
|
||||||
|
args.severidade ?? undefined
|
||||||
|
);
|
||||||
const status = statusInicial(severidade);
|
const status = statusInicial(severidade);
|
||||||
|
|
||||||
const duplicado = await ctx.db
|
const duplicado = await ctx.db
|
||||||
@@ -727,10 +732,18 @@ export const listarEventosSeguranca = query({
|
|||||||
const candidatos = await builder.order('desc').take(limit * 3);
|
const candidatos = await builder.order('desc').take(limit * 3);
|
||||||
const filtrados = candidatos
|
const filtrados = candidatos
|
||||||
.filter((evento) => {
|
.filter((evento) => {
|
||||||
if (args.severidades && args.severidades.length > 0 && !args.severidades.includes(evento.severidade)) {
|
if (
|
||||||
|
args.severidades &&
|
||||||
|
args.severidades.length > 0 &&
|
||||||
|
!args.severidades.includes(evento.severidade)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (args.tiposAtaque && args.tiposAtaque.length > 0 && !args.tiposAtaque.includes(evento.tipoAtaque)) {
|
if (
|
||||||
|
args.tiposAtaque &&
|
||||||
|
args.tiposAtaque.length > 0 &&
|
||||||
|
!args.tiposAtaque.includes(evento.tipoAtaque)
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (args.status && args.status.length > 0 && !args.status.includes(evento.status)) {
|
if (args.status && args.status.length > 0 && !args.status.includes(evento.status)) {
|
||||||
@@ -829,10 +842,7 @@ export const obterVisaoCamadas = query({
|
|||||||
for (const evento of eventos) {
|
for (const evento of eventos) {
|
||||||
const idx = Math.min(
|
const idx = Math.min(
|
||||||
bucketCount - 1,
|
bucketCount - 1,
|
||||||
Math.max(
|
Math.max(0, Math.floor((evento.timestamp - inicioJanela) / bucketSize))
|
||||||
0,
|
|
||||||
Math.floor((evento.timestamp - inicioJanela) / bucketSize)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
const bucket = series[idx];
|
const bucket = series[idx];
|
||||||
if (evento.severidade === 'critico') criticos += 1;
|
if (evento.severidade === 'critico') criticos += 1;
|
||||||
@@ -893,11 +903,12 @@ export const listarReputacoes = query({
|
|||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 500) : 200;
|
const limit = args.limit && args.limit > 0 ? Math.min(args.limit, 500) : 200;
|
||||||
|
|
||||||
const builder = args.lista === undefined
|
const builder =
|
||||||
? ctx.db.query('ipReputation')
|
args.lista === undefined
|
||||||
: args.lista === 'blacklist'
|
? ctx.db.query('ipReputation')
|
||||||
? ctx.db.query('ipReputation').withIndex('by_blacklist', (q) => q.eq('blacklist', true))
|
: args.lista === 'blacklist'
|
||||||
: ctx.db.query('ipReputation').withIndex('by_whitelist', (q) => q.eq('whitelist', true));
|
? ctx.db.query('ipReputation').withIndex('by_blacklist', (q) => q.eq('blacklist', true))
|
||||||
|
: ctx.db.query('ipReputation').withIndex('by_whitelist', (q) => q.eq('whitelist', true));
|
||||||
|
|
||||||
const docs = await builder.order('desc').take(limit * 2);
|
const docs = await builder.order('desc').take(limit * 2);
|
||||||
const filtrados = docs
|
const filtrados = docs
|
||||||
@@ -945,7 +956,12 @@ export const atualizarReputacaoIndicador = mutation({
|
|||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
// Aplicar rate limiting por usuário
|
// Aplicar rate limiting por usuário
|
||||||
const rateLimitResult = await aplicarRateLimit(ctx, 'usuario', args.usuarioId, 'atualizarReputacaoIndicador');
|
const rateLimitResult = await aplicarRateLimit(
|
||||||
|
ctx,
|
||||||
|
'usuario',
|
||||||
|
args.usuarioId,
|
||||||
|
'atualizarReputacaoIndicador'
|
||||||
|
);
|
||||||
if (!rateLimitResult.permitido) {
|
if (!rateLimitResult.permitido) {
|
||||||
throw new Error(rateLimitResult.motivo ?? 'Rate limit excedido');
|
throw new Error(rateLimitResult.motivo ?? 'Rate limit excedido');
|
||||||
}
|
}
|
||||||
@@ -1075,7 +1091,8 @@ export const configurarRegraPorta = mutation({
|
|||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const agora = Date.now();
|
const agora = Date.now();
|
||||||
const expiraEm = args.temporario && args.duracaoSegundos ? agora + args.duracaoSegundos * 1000 : undefined;
|
const expiraEm =
|
||||||
|
args.temporario && args.duracaoSegundos ? agora + args.duracaoSegundos * 1000 : undefined;
|
||||||
|
|
||||||
if (args.regraId) {
|
if (args.regraId) {
|
||||||
await ctx.db.patch(args.regraId, {
|
await ctx.db.patch(args.regraId, {
|
||||||
@@ -1172,7 +1189,14 @@ export const registrarAcaoIncidente = mutation({
|
|||||||
tipo: acaoIncidenteValidator,
|
tipo: acaoIncidenteValidator,
|
||||||
origem: acaoOrigemValidator,
|
origem: acaoOrigemValidator,
|
||||||
executadoPor: v.optional(v.id('usuarios')),
|
executadoPor: v.optional(v.id('usuarios')),
|
||||||
status: v.optional(v.union(v.literal('pendente'), v.literal('executando'), v.literal('concluido'), v.literal('falhou'))),
|
status: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('pendente'),
|
||||||
|
v.literal('executando'),
|
||||||
|
v.literal('concluido'),
|
||||||
|
v.literal('falhou')
|
||||||
|
)
|
||||||
|
),
|
||||||
detalhes: v.optional(v.string()),
|
detalhes: v.optional(v.string()),
|
||||||
resultado: v.optional(v.string()),
|
resultado: v.optional(v.string()),
|
||||||
relacionadoA: v.optional(v.id('ipReputation'))
|
relacionadoA: v.optional(v.id('ipReputation'))
|
||||||
@@ -1338,9 +1362,7 @@ export const processarRelatorioSegurancaInternal = internalMutation({
|
|||||||
const eventos = await ctx.db
|
const eventos = await ctx.db
|
||||||
.query('securityEvents')
|
.query('securityEvents')
|
||||||
.withIndex('by_timestamp', (q) =>
|
.withIndex('by_timestamp', (q) =>
|
||||||
q
|
q.gte('timestamp', relatorio.filtros.dataInicio).lte('timestamp', relatorio.filtros.dataFim)
|
||||||
.gte('timestamp', relatorio.filtros.dataInicio)
|
|
||||||
.lte('timestamp', relatorio.filtros.dataFim)
|
|
||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1350,14 +1372,14 @@ export const processarRelatorioSegurancaInternal = internalMutation({
|
|||||||
relatorio.filtros.severidades.length > 0 &&
|
relatorio.filtros.severidades.length > 0 &&
|
||||||
!relatorio.filtros.severidades.includes(evento.severidade)
|
!relatorio.filtros.severidades.includes(evento.severidade)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
relatorio.filtros.tiposAtaque &&
|
relatorio.filtros.tiposAtaque &&
|
||||||
relatorio.filtros.tiposAtaque.length > 0 &&
|
relatorio.filtros.tiposAtaque.length > 0 &&
|
||||||
!relatorio.filtros.tiposAtaque.includes(evento.tipoAtaque)
|
!relatorio.filtros.tiposAtaque.includes(evento.tipoAtaque)
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@@ -1509,7 +1531,10 @@ export const dispararAlertasInternos = internalMutation({
|
|||||||
const usuariosNotificados: Id<'usuarios'>[] = [];
|
const usuariosNotificados: Id<'usuarios'>[] = [];
|
||||||
|
|
||||||
for (const role of rolesTi) {
|
for (const role of rolesTi) {
|
||||||
const membros = await ctx.db.query('usuarios').withIndex('by_role', (q) => q.eq('roleId', role._id)).collect();
|
const membros = await ctx.db
|
||||||
|
.query('usuarios')
|
||||||
|
.withIndex('by_role', (q) => q.eq('roleId', role._id))
|
||||||
|
.collect();
|
||||||
for (const usuario of membros) {
|
for (const usuario of membros) {
|
||||||
usuariosNotificados.push(usuario._id);
|
usuariosNotificados.push(usuario._id);
|
||||||
}
|
}
|
||||||
@@ -1602,7 +1627,9 @@ async function aplicarRateLimit(
|
|||||||
): Promise<{ permitido: boolean; motivo?: string; retryAfter?: number }> {
|
): Promise<{ permitido: boolean; motivo?: string; retryAfter?: number }> {
|
||||||
const configs = await ctx.db
|
const configs = await ctx.db
|
||||||
.query('rateLimitConfig')
|
.query('rateLimitConfig')
|
||||||
.withIndex('by_tipo_identificador', (q) => q.eq('tipo', tipo).eq('identificador', identificador))
|
.withIndex('by_tipo_identificador', (q) =>
|
||||||
|
q.eq('tipo', tipo).eq('identificador', identificador)
|
||||||
|
)
|
||||||
.filter((q) => q.eq(q.field('ativo'), true))
|
.filter((q) => q.eq(q.field('ativo'), true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1611,7 +1638,9 @@ async function aplicarRateLimit(
|
|||||||
// Verificar configuração global
|
// Verificar configuração global
|
||||||
const globalConfigs = await ctx.db
|
const globalConfigs = await ctx.db
|
||||||
.query('rateLimitConfig')
|
.query('rateLimitConfig')
|
||||||
.withIndex('by_tipo_identificador', (q) => q.eq('tipo', 'global').eq('identificador', 'global'))
|
.withIndex('by_tipo_identificador', (q) =>
|
||||||
|
q.eq('tipo', 'global').eq('identificador', 'global')
|
||||||
|
)
|
||||||
.filter((q) => q.eq(q.field('ativo'), true))
|
.filter((q) => q.eq(q.field('ativo'), true))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1626,10 +1655,11 @@ async function aplicarRateLimit(
|
|||||||
|
|
||||||
// Converter janelaSegundos para período do rate-limiter
|
// Converter janelaSegundos para período do rate-limiter
|
||||||
const periodo = config.janelaSegundos * SECOND;
|
const periodo = config.janelaSegundos * SECOND;
|
||||||
|
|
||||||
// Determinar estratégia baseada na configuração
|
// Determinar estratégia baseada na configuração
|
||||||
// O rate-limiter suporta apenas 'token bucket' e 'fixed window'
|
// O rate-limiter suporta apenas 'token bucket' e 'fixed window'
|
||||||
const kind: 'token bucket' | 'fixed window' = config.estrategia === 'token_bucket' ? 'token bucket' : 'fixed window';
|
const kind: 'token bucket' | 'fixed window' =
|
||||||
|
config.estrategia === 'token_bucket' ? 'token bucket' : 'fixed window';
|
||||||
|
|
||||||
// Criar namespace único para este rate limit
|
// Criar namespace único para este rate limit
|
||||||
const namespace = `${tipo}:${identificador}:${endpoint ?? 'default'}`;
|
const namespace = `${tipo}:${identificador}:${endpoint ?? 'default'}`;
|
||||||
@@ -1643,7 +1673,10 @@ async function aplicarRateLimit(
|
|||||||
period: periodo,
|
period: periodo,
|
||||||
...(config.estrategia === 'token_bucket' ? { capacity: config.limite } : {})
|
...(config.estrategia === 'token_bucket' ? { capacity: config.limite } : {})
|
||||||
}
|
}
|
||||||
} as Record<string, { kind: 'token bucket' | 'fixed window'; rate: number; period: number; capacity?: number }>;
|
} as Record<
|
||||||
|
string,
|
||||||
|
{ kind: 'token bucket' | 'fixed window'; rate: number; period: number; capacity?: number }
|
||||||
|
>;
|
||||||
|
|
||||||
const rateLimiter = new RateLimiter(components.rateLimiter, rateLimiterConfig);
|
const rateLimiter = new RateLimiter(components.rateLimiter, rateLimiterConfig);
|
||||||
|
|
||||||
@@ -1654,7 +1687,7 @@ async function aplicarRateLimit(
|
|||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
const retryAfter = result.retryAfter ?? periodo;
|
const retryAfter = result.retryAfter ?? periodo;
|
||||||
|
|
||||||
if (config.acaoExcedido === 'bloquear') {
|
if (config.acaoExcedido === 'bloquear') {
|
||||||
return {
|
return {
|
||||||
permitido: false,
|
permitido: false,
|
||||||
@@ -1688,7 +1721,12 @@ export const criarConfigRateLimit = mutation({
|
|||||||
args: {
|
args: {
|
||||||
usuarioId: v.id('usuarios'),
|
usuarioId: v.id('usuarios'),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
tipo: v.union(v.literal('ip'), v.literal('usuario'), v.literal('endpoint'), v.literal('global')),
|
tipo: v.union(
|
||||||
|
v.literal('ip'),
|
||||||
|
v.literal('usuario'),
|
||||||
|
v.literal('endpoint'),
|
||||||
|
v.literal('global')
|
||||||
|
),
|
||||||
identificador: v.optional(v.string()),
|
identificador: v.optional(v.string()),
|
||||||
limite: v.number(),
|
limite: v.number(),
|
||||||
janelaSegundos: v.number(),
|
janelaSegundos: v.number(),
|
||||||
@@ -1737,13 +1775,11 @@ export const atualizarConfigRateLimit = mutation({
|
|||||||
limite: v.optional(v.number()),
|
limite: v.optional(v.number()),
|
||||||
janelaSegundos: v.optional(v.number()),
|
janelaSegundos: v.optional(v.number()),
|
||||||
estrategia: v.optional(
|
estrategia: v.optional(
|
||||||
v.union(
|
v.union(v.literal('fixed_window'), v.literal('sliding_window'), v.literal('token_bucket'))
|
||||||
v.literal('fixed_window'),
|
),
|
||||||
v.literal('sliding_window'),
|
acaoExcedido: v.optional(
|
||||||
v.literal('token_bucket')
|
v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar'))
|
||||||
)
|
|
||||||
),
|
),
|
||||||
acaoExcedido: v.optional(v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar'))),
|
|
||||||
bloqueioTemporarioSegundos: v.optional(v.number()),
|
bloqueioTemporarioSegundos: v.optional(v.number()),
|
||||||
ativo: v.optional(v.boolean()),
|
ativo: v.optional(v.boolean()),
|
||||||
prioridade: v.optional(v.number()),
|
prioridade: v.optional(v.number()),
|
||||||
@@ -1794,7 +1830,9 @@ export const atualizarConfigRateLimit = mutation({
|
|||||||
|
|
||||||
export const listarConfigsRateLimit = query({
|
export const listarConfigsRateLimit = query({
|
||||||
args: {
|
args: {
|
||||||
tipo: v.optional(v.union(v.literal('ip'), v.literal('usuario'), v.literal('endpoint'), v.literal('global'))),
|
tipo: v.optional(
|
||||||
|
v.union(v.literal('ip'), v.literal('usuario'), v.literal('endpoint'), v.literal('global'))
|
||||||
|
),
|
||||||
ativo: v.optional(v.boolean()),
|
ativo: v.optional(v.boolean()),
|
||||||
limit: v.optional(v.number())
|
limit: v.optional(v.number())
|
||||||
},
|
},
|
||||||
@@ -1802,7 +1840,12 @@ export const listarConfigsRateLimit = query({
|
|||||||
v.object({
|
v.object({
|
||||||
_id: v.id('rateLimitConfig'),
|
_id: v.id('rateLimitConfig'),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
tipo: v.union(v.literal('ip'), v.literal('usuario'), v.literal('endpoint'), v.literal('global')),
|
tipo: v.union(
|
||||||
|
v.literal('ip'),
|
||||||
|
v.literal('usuario'),
|
||||||
|
v.literal('endpoint'),
|
||||||
|
v.literal('global')
|
||||||
|
),
|
||||||
identificador: v.optional(v.string()),
|
identificador: v.optional(v.string()),
|
||||||
limite: v.number(),
|
limite: v.number(),
|
||||||
janelaSegundos: v.number(),
|
janelaSegundos: v.number(),
|
||||||
@@ -1882,8 +1925,12 @@ export const analisarRequisicaoHTTP = mutation({
|
|||||||
args.url,
|
args.url,
|
||||||
args.method,
|
args.method,
|
||||||
args.body ?? '',
|
args.body ?? '',
|
||||||
Object.entries(args.queryParams ?? {}).map(([k, v]) => `${k}=${v}`).join('&'),
|
Object.entries(args.queryParams ?? {})
|
||||||
Object.entries(args.headers ?? {}).map(([k, v]) => `${k}:${v}`).join('\n'),
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
|
.join('&'),
|
||||||
|
Object.entries(args.headers ?? {})
|
||||||
|
.map(([k, v]) => `${k}:${v}`)
|
||||||
|
.join('\n'),
|
||||||
args.userAgent ?? ''
|
args.userAgent ?? ''
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -1904,7 +1951,8 @@ export const analisarRequisicaoHTTP = mutation({
|
|||||||
|
|
||||||
// Permitir que o chamador informe o destino/protocolo via query string em cenários de dev/teste
|
// Permitir que o chamador informe o destino/protocolo via query string em cenários de dev/teste
|
||||||
const destinoIp =
|
const destinoIp =
|
||||||
(args.queryParams && (args.queryParams['dst'] || args.queryParams['dest'] || args.queryParams['destino'])) ||
|
(args.queryParams &&
|
||||||
|
(args.queryParams['dst'] || args.queryParams['dest'] || args.queryParams['destino'])) ||
|
||||||
undefined;
|
undefined;
|
||||||
const protocolo = (args.queryParams && (args.queryParams['proto'] as string)) || 'http';
|
const protocolo = (args.queryParams && (args.queryParams['proto'] as string)) || 'http';
|
||||||
|
|
||||||
@@ -1922,9 +1970,11 @@ export const analisarRequisicaoHTTP = mutation({
|
|||||||
protocolo,
|
protocolo,
|
||||||
transporte: 'tcp',
|
transporte: 'tcp',
|
||||||
detectadoPor: 'analisador_http_automatico',
|
detectadoPor: 'analisador_http_automatico',
|
||||||
fingerprint: args.userAgent ? {
|
fingerprint: args.userAgent
|
||||||
userAgent: args.userAgent
|
? {
|
||||||
} : undefined,
|
userAgent: args.userAgent
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
destinoIp: destinoIp ?? undefined,
|
destinoIp: destinoIp ?? undefined,
|
||||||
tags: ['detecção_automática', 'http', tipoAtaque],
|
tags: ['detecção_automática', 'http', tipoAtaque],
|
||||||
atualizadoEm: agora
|
atualizadoEm: agora
|
||||||
@@ -1978,19 +2028,13 @@ export const detectarBruteForce = internalMutation({
|
|||||||
tentativasFalhas = await ctx.db
|
tentativasFalhas = await ctx.db
|
||||||
.query('logsLogin')
|
.query('logsLogin')
|
||||||
.withIndex('by_ip', (q) => q.eq('ipAddress', args.ipAddress))
|
.withIndex('by_ip', (q) => q.eq('ipAddress', args.ipAddress))
|
||||||
.filter((q) =>
|
.filter((q) => q.gte(q.field('timestamp'), dataLimite) && q.eq(q.field('sucesso'), false))
|
||||||
q.gte(q.field('timestamp'), dataLimite) &&
|
|
||||||
q.eq(q.field('sucesso'), false)
|
|
||||||
)
|
|
||||||
.collect();
|
.collect();
|
||||||
} else if (args.usuarioId) {
|
} else if (args.usuarioId) {
|
||||||
tentativasFalhas = await ctx.db
|
tentativasFalhas = await ctx.db
|
||||||
.query('logsLogin')
|
.query('logsLogin')
|
||||||
.withIndex('by_usuario', (q) => q.eq('usuarioId', args.usuarioId))
|
.withIndex('by_usuario', (q) => q.eq('usuarioId', args.usuarioId))
|
||||||
.filter((q) =>
|
.filter((q) => q.gte(q.field('timestamp'), dataLimite) && q.eq(q.field('sucesso'), false))
|
||||||
q.gte(q.field('timestamp'), dataLimite) &&
|
|
||||||
q.eq(q.field('sucesso'), false)
|
|
||||||
)
|
|
||||||
.collect();
|
.collect();
|
||||||
} else {
|
} else {
|
||||||
// Buscar todas as tentativas falhas na janela
|
// Buscar todas as tentativas falhas na janela
|
||||||
@@ -2026,7 +2070,8 @@ export const detectarBruteForce = internalMutation({
|
|||||||
const eventosIds: Id<'securityEvents'>[] = [];
|
const eventosIds: Id<'securityEvents'>[] = [];
|
||||||
|
|
||||||
for (const { ip, count } of ipsSuspeitos) {
|
for (const { ip, count } of ipsSuspeitos) {
|
||||||
const severidade: SeveridadeSeguranca = count >= 8 ? 'alto' : count >= 5 ? 'moderado' : 'baixo';
|
const severidade: SeveridadeSeguranca =
|
||||||
|
count >= 8 ? 'alto' : count >= 5 ? 'moderado' : 'baixo';
|
||||||
const referencia = `brute_force_${ip}_${Date.now()}`;
|
const referencia = `brute_force_${ip}_${Date.now()}`;
|
||||||
const agora = Date.now();
|
const agora = Date.now();
|
||||||
|
|
||||||
@@ -2058,10 +2103,12 @@ export const detectarBruteForce = internalMutation({
|
|||||||
'ip',
|
'ip',
|
||||||
delta,
|
delta,
|
||||||
severidade,
|
severidade,
|
||||||
severidade === 'alto' ? {
|
severidade === 'alto'
|
||||||
blacklist: true,
|
? {
|
||||||
bloqueadoAte: agora + (60 * 60 * 1000) // Bloquear por 1 hora
|
blacklist: true,
|
||||||
} : undefined
|
bloqueadoAte: agora + 60 * 60 * 1000 // Bloquear por 1 hora
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2109,13 +2156,7 @@ export const criarEventosTeste = mutation({
|
|||||||
];
|
];
|
||||||
|
|
||||||
// IPs de teste
|
// IPs de teste
|
||||||
const ipsTeste = [
|
const ipsTeste = ['192.168.1.100', '10.0.0.50', '172.16.0.25', '203.0.113.42', '198.51.100.15'];
|
||||||
'192.168.1.100',
|
|
||||||
'10.0.0.50',
|
|
||||||
'172.16.0.25',
|
|
||||||
'203.0.113.42',
|
|
||||||
'198.51.100.15'
|
|
||||||
];
|
|
||||||
|
|
||||||
for (let i = 0; i < quantidade; i++) {
|
for (let i = 0; i < quantidade; i++) {
|
||||||
const tipoAtaque = tiposAtaque[i % tiposAtaque.length];
|
const tipoAtaque = tiposAtaque[i % tiposAtaque.length];
|
||||||
@@ -2124,7 +2165,7 @@ export const criarEventosTeste = mutation({
|
|||||||
|
|
||||||
const eventoId = await ctx.db.insert('securityEvents', {
|
const eventoId = await ctx.db.insert('securityEvents', {
|
||||||
referencia,
|
referencia,
|
||||||
timestamp: agora - (i * 60000), // Espaçar eventos em 1 minuto
|
timestamp: agora - i * 60000, // Espaçar eventos em 1 minuto
|
||||||
tipoAtaque: tipoAtaque.tipo,
|
tipoAtaque: tipoAtaque.tipo,
|
||||||
severidade: tipoAtaque.severidade,
|
severidade: tipoAtaque.severidade,
|
||||||
status: statusInicial(tipoAtaque.severidade),
|
status: statusInicial(tipoAtaque.severidade),
|
||||||
@@ -2140,7 +2181,7 @@ export const criarEventosTeste = mutation({
|
|||||||
pps: Math.floor(Math.random() * 50000)
|
pps: Math.floor(Math.random() * 50000)
|
||||||
},
|
},
|
||||||
tags: ['teste', 'validação', tipoAtaque.tipo],
|
tags: ['teste', 'validação', tipoAtaque.tipo],
|
||||||
atualizadoEm: agora - (i * 60000)
|
atualizadoEm: agora - i * 60000
|
||||||
});
|
});
|
||||||
|
|
||||||
eventosIds.push(eventoId);
|
eventosIds.push(eventoId);
|
||||||
@@ -2153,9 +2194,11 @@ export const criarEventosTeste = mutation({
|
|||||||
'ip',
|
'ip',
|
||||||
delta,
|
delta,
|
||||||
tipoAtaque.severidade,
|
tipoAtaque.severidade,
|
||||||
tipoAtaque.severidade === 'critico' || tipoAtaque.severidade === 'alto' ? {
|
tipoAtaque.severidade === 'critico' || tipoAtaque.severidade === 'alto'
|
||||||
blacklist: true
|
? {
|
||||||
} : undefined
|
blacklist: true
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2208,7 +2251,8 @@ export const monitorarLogsLogin = internalMutation({
|
|||||||
// Registrar eventos para cada IP suspeito
|
// Registrar eventos para cada IP suspeito
|
||||||
let ipsBloqueados = 0;
|
let ipsBloqueados = 0;
|
||||||
for (const { ip, count } of ipsSuspeitos) {
|
for (const { ip, count } of ipsSuspeitos) {
|
||||||
const severidade: SeveridadeSeguranca = count >= 8 ? 'alto' : count >= 5 ? 'moderado' : 'baixo';
|
const severidade: SeveridadeSeguranca =
|
||||||
|
count >= 8 ? 'alto' : count >= 5 ? 'moderado' : 'baixo';
|
||||||
const referencia = `brute_force_${ip}_${Date.now()}`;
|
const referencia = `brute_force_${ip}_${Date.now()}`;
|
||||||
const agora = Date.now();
|
const agora = Date.now();
|
||||||
|
|
||||||
@@ -2238,10 +2282,12 @@ export const monitorarLogsLogin = internalMutation({
|
|||||||
'ip',
|
'ip',
|
||||||
delta,
|
delta,
|
||||||
severidade,
|
severidade,
|
||||||
severidade === 'alto' ? {
|
severidade === 'alto'
|
||||||
blacklist: true,
|
? {
|
||||||
bloqueadoAte: agora + (60 * 60 * 1000)
|
blacklist: true,
|
||||||
} : undefined
|
bloqueadoAte: agora + 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
);
|
);
|
||||||
|
|
||||||
if (severidade === 'alto') {
|
if (severidade === 'alto') {
|
||||||
@@ -2302,7 +2348,12 @@ export const seedRateLimitDev = mutation({
|
|||||||
const existing = await ctx.db
|
const existing = await ctx.db
|
||||||
.query('rateLimitConfig')
|
.query('rateLimitConfig')
|
||||||
.withIndex('by_tipo_identificador', (q) =>
|
.withIndex('by_tipo_identificador', (q) =>
|
||||||
q.eq('tipo', params.tipo).eq('identificador', params.identificador ?? (params.tipo === 'global' ? 'global' : undefined)),
|
q
|
||||||
|
.eq('tipo', params.tipo)
|
||||||
|
.eq(
|
||||||
|
'identificador',
|
||||||
|
params.identificador ?? (params.tipo === 'global' ? 'global' : undefined)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.collect();
|
.collect();
|
||||||
const agora = Date.now();
|
const agora = Date.now();
|
||||||
@@ -2315,9 +2366,9 @@ export const seedRateLimitDev = mutation({
|
|||||||
estrategia: params.estrategia,
|
estrategia: params.estrategia,
|
||||||
acaoExcedido: params.acaoExcedido,
|
acaoExcedido: params.acaoExcedido,
|
||||||
ativo: true,
|
ativo: true,
|
||||||
prioridade: params.prioridade ?? (doc.prioridade ?? 0),
|
prioridade: params.prioridade ?? doc.prioridade ?? 0,
|
||||||
atualizadoEm: agora,
|
atualizadoEm: agora,
|
||||||
notas: params.notas,
|
notas: params.notas
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
await ctx.db.insert('rateLimitConfig', {
|
await ctx.db.insert('rateLimitConfig', {
|
||||||
@@ -2420,4 +2471,3 @@ export const deletarRegraPorta = mutation({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -618,13 +618,6 @@ export const clearDatabase = internalMutation({
|
|||||||
}
|
}
|
||||||
console.log(` ✅ ${roles.length} roles removidas`);
|
console.log(` ✅ ${roles.length} roles removidas`);
|
||||||
|
|
||||||
// 23. Todos (tabela de exemplo)
|
|
||||||
const todos = await ctx.db.query('todos').collect();
|
|
||||||
for (const todo of todos) {
|
|
||||||
await ctx.db.delete(todo._id);
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${todos.length} todos removidos`);
|
|
||||||
|
|
||||||
console.log('✨ Banco de dados completamente limpo!');
|
console.log('✨ Banco de dados completamente limpo!');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -841,13 +834,6 @@ export const limparBanco = mutation({
|
|||||||
}
|
}
|
||||||
console.log(` ✅ ${roles.length} roles removidas`);
|
console.log(` ✅ ${roles.length} roles removidas`);
|
||||||
|
|
||||||
// 23. Todos (tabela de exemplo)
|
|
||||||
const todos = await ctx.db.query('todos').collect();
|
|
||||||
for (const todo of todos) {
|
|
||||||
await ctx.db.delete(todo._id);
|
|
||||||
}
|
|
||||||
console.log(` ✅ ${todos.length} todos removidos`);
|
|
||||||
|
|
||||||
console.log('✨ Banco de dados completamente limpo!');
|
console.log('✨ Banco de dados completamente limpo!');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,188 +1,190 @@
|
|||||||
import { v } from "convex/values";
|
import { v } from 'convex/values';
|
||||||
import { query, mutation } from "./_generated/server";
|
import { query, mutation } from './_generated/server';
|
||||||
import { simboloTipo } from "./schema";
|
import { simboloTipo } from './tables/funcionarios';
|
||||||
|
|
||||||
export const getAll = query({
|
export const getAll = query({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.array(
|
returns: v.array(
|
||||||
v.object({
|
v.object({
|
||||||
_id: v.id("simbolos"),
|
_id: v.id('simbolos'),
|
||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
tipo: simboloTipo,
|
tipo: simboloTipo,
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
vencValor: v.string(),
|
vencValor: v.string(),
|
||||||
repValor: v.string(),
|
repValor: v.string(),
|
||||||
valor: v.string(),
|
valor: v.string()
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
return await ctx.db.query("simbolos").collect();
|
return await ctx.db.query('simbolos').collect();
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getById = query({
|
export const getById = query({
|
||||||
args: {
|
args: {
|
||||||
id: v.id("simbolos"),
|
id: v.id('simbolos')
|
||||||
},
|
},
|
||||||
returns: v.union(
|
returns: v.union(
|
||||||
v.object({
|
v.object({
|
||||||
_id: v.id("simbolos"),
|
_id: v.id('simbolos'),
|
||||||
_creationTime: v.number(),
|
_creationTime: v.number(),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
tipo: simboloTipo,
|
tipo: simboloTipo,
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
vencValor: v.string(),
|
vencValor: v.string(),
|
||||||
repValor: v.string(),
|
repValor: v.string(),
|
||||||
valor: v.string(),
|
valor: v.string()
|
||||||
}),
|
}),
|
||||||
v.null()
|
v.null()
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
return await ctx.db.get(args.id);
|
return await ctx.db.get(args.id);
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const create = mutation({
|
export const create = mutation({
|
||||||
args: {
|
args: {
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
tipo: simboloTipo,
|
tipo: simboloTipo,
|
||||||
refValor: v.string(),
|
refValor: v.string(),
|
||||||
vencValor: v.string(),
|
vencValor: v.string(),
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
valor: v.optional(v.string()),
|
valor: v.optional(v.string())
|
||||||
},
|
},
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
let refValor = args.refValor;
|
let refValor = args.refValor;
|
||||||
let vencValor = args.vencValor;
|
let vencValor = args.vencValor;
|
||||||
let valor = args.valor ?? "";
|
let valor = args.valor ?? '';
|
||||||
|
|
||||||
if (args.tipo === "cargo_comissionado") {
|
if (args.tipo === 'cargo_comissionado') {
|
||||||
if (!refValor || !vencValor) {
|
if (!refValor || !vencValor) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Valor de referência e valor de vencimento são obrigatórios para cargo comissionado"
|
'Valor de referência e valor de vencimento são obrigatórios para cargo comissionado'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
valor = (Number(refValor) + Number(vencValor)).toFixed(2);
|
valor = (Number(refValor) + Number(vencValor)).toFixed(2);
|
||||||
} else {
|
} else {
|
||||||
if (!args.valor) {
|
if (!args.valor) {
|
||||||
throw new Error("Valor é obrigatório para função gratificada");
|
throw new Error('Valor é obrigatório para função gratificada');
|
||||||
}
|
}
|
||||||
refValor = "";
|
refValor = '';
|
||||||
vencValor = "";
|
vencValor = '';
|
||||||
valor = args.valor;
|
valor = args.valor;
|
||||||
}
|
}
|
||||||
const novoSimboloId = await ctx.db.insert("simbolos", {
|
const novoSimboloId = await ctx.db.insert('simbolos', {
|
||||||
nome: args.nome,
|
nome: args.nome,
|
||||||
descricao: args.descricao,
|
descricao: args.descricao,
|
||||||
repValor: refValor,
|
repValor: refValor,
|
||||||
vencValor: vencValor,
|
vencValor: vencValor,
|
||||||
tipo: args.tipo,
|
tipo: args.tipo,
|
||||||
valor,
|
valor
|
||||||
});
|
});
|
||||||
return await ctx.db.get(novoSimboloId);
|
return await ctx.db.get(novoSimboloId);
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remove = mutation({
|
export const remove = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.id("simbolos"),
|
id: v.id('simbolos')
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
await ctx.db.delete(args.id);
|
await ctx.db.delete(args.id);
|
||||||
return null;
|
return null;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const update = mutation({
|
export const update = mutation({
|
||||||
args: {
|
args: {
|
||||||
id: v.id("simbolos"),
|
id: v.id('simbolos'),
|
||||||
nome: v.string(),
|
nome: v.string(),
|
||||||
tipo: simboloTipo,
|
tipo: simboloTipo,
|
||||||
refValor: v.string(),
|
refValor: v.string(),
|
||||||
vencValor: v.string(),
|
vencValor: v.string(),
|
||||||
descricao: v.string(),
|
descricao: v.string(),
|
||||||
valor: v.optional(v.string()),
|
valor: v.optional(v.string())
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
let refValor = args.refValor;
|
let refValor = args.refValor;
|
||||||
let vencValor = args.vencValor;
|
let vencValor = args.vencValor;
|
||||||
let valor = args.valor ?? "";
|
let valor = args.valor ?? '';
|
||||||
|
|
||||||
if (args.tipo === "cargo_comissionado") {
|
if (args.tipo === 'cargo_comissionado') {
|
||||||
if (!refValor || !vencValor) {
|
if (!refValor || !vencValor) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Valor de referência e valor de vencimento são obrigatórios para cargo comissionado"
|
'Valor de referência e valor de vencimento são obrigatórios para cargo comissionado'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
valor = (Number(refValor) + Number(vencValor)).toFixed(2);
|
valor = (Number(refValor) + Number(vencValor)).toFixed(2);
|
||||||
} else {
|
} else {
|
||||||
if (!args.valor) {
|
if (!args.valor) {
|
||||||
throw new Error("Valor é obrigatório para função gratificada");
|
throw new Error('Valor é obrigatório para função gratificada');
|
||||||
}
|
}
|
||||||
refValor = "";
|
refValor = '';
|
||||||
vencValor = "";
|
vencValor = '';
|
||||||
valor = args.valor;
|
valor = args.valor;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ctx.db.patch(args.id, {
|
await ctx.db.patch(args.id, {
|
||||||
nome: args.nome,
|
nome: args.nome,
|
||||||
descricao: args.descricao,
|
descricao: args.descricao,
|
||||||
repValor: refValor,
|
repValor: refValor,
|
||||||
vencValor: vencValor,
|
vencValor: vencValor,
|
||||||
tipo: args.tipo,
|
tipo: args.tipo,
|
||||||
valor,
|
valor
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
},
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove símbolos duplicados, mantendo apenas a primeira ocorrência de cada símbolo
|
* Remove símbolos duplicados, mantendo apenas a primeira ocorrência de cada símbolo
|
||||||
*/
|
*/
|
||||||
export const removerDuplicados = mutation({
|
export const removerDuplicados = mutation({
|
||||||
args: {},
|
args: {},
|
||||||
returns: v.object({
|
returns: v.object({
|
||||||
removidos: v.number(),
|
removidos: v.number(),
|
||||||
mantidos: v.number(),
|
mantidos: v.number()
|
||||||
}),
|
}),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
const todosSimbolos = await ctx.db.query("simbolos").collect();
|
const todosSimbolos = await ctx.db.query('simbolos').collect();
|
||||||
|
|
||||||
// Agrupar símbolos por nome
|
// Agrupar símbolos por nome
|
||||||
const simbolosPorNome = new Map<string, typeof todosSimbolos>();
|
const simbolosPorNome = new Map<string, typeof todosSimbolos>();
|
||||||
|
|
||||||
for (const simbolo of todosSimbolos) {
|
for (const simbolo of todosSimbolos) {
|
||||||
const key = simbolo.nome.trim().toLowerCase();
|
const key = simbolo.nome.trim().toLowerCase();
|
||||||
if (!simbolosPorNome.has(key)) {
|
if (!simbolosPorNome.has(key)) {
|
||||||
simbolosPorNome.set(key, []);
|
simbolosPorNome.set(key, []);
|
||||||
}
|
}
|
||||||
simbolosPorNome.get(key)!.push(simbolo);
|
simbolosPorNome.get(key)!.push(simbolo);
|
||||||
}
|
}
|
||||||
|
|
||||||
let removidos = 0;
|
let removidos = 0;
|
||||||
let mantidos = 0;
|
let mantidos = 0;
|
||||||
|
|
||||||
// Para cada grupo de símbolos com o mesmo nome
|
// Para cada grupo de símbolos com o mesmo nome
|
||||||
for (const [nome, simbolos] of simbolosPorNome) {
|
for (const [nome, simbolos] of simbolosPorNome) {
|
||||||
// Ordenar por _creationTime (mais antigo primeiro)
|
// Ordenar por _creationTime (mais antigo primeiro)
|
||||||
simbolos.sort((a, b) => a._creationTime - b._creationTime);
|
simbolos.sort((a, b) => a._creationTime - b._creationTime);
|
||||||
|
|
||||||
// Manter o primeiro (mais antigo) e remover os demais
|
// Manter o primeiro (mais antigo) e remover os demais
|
||||||
const [primeiro, ...duplicados] = simbolos;
|
const [primeiro, ...duplicados] = simbolos;
|
||||||
mantidos++;
|
mantidos++;
|
||||||
|
|
||||||
// Remover duplicados
|
// Remover duplicados
|
||||||
for (const duplicado of duplicados) {
|
for (const duplicado of duplicados) {
|
||||||
await ctx.db.delete(duplicado._id);
|
await ctx.db.delete(duplicado._id);
|
||||||
removidos++;
|
removidos++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✅ Remoção concluída: ${mantidos} símbolos mantidos, ${removidos} duplicados removidos`);
|
console.log(
|
||||||
|
`✅ Remoção concluída: ${mantidos} símbolos mantidos, ${removidos} duplicados removidos`
|
||||||
return { removidos, mantidos };
|
);
|
||||||
},
|
|
||||||
});
|
return { removidos, mantidos };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
20
packages/backend/convex/tables/atestados.ts
Normal file
20
packages/backend/convex/tables/atestados.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const atestadosTables = {
|
||||||
|
atestados: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
tipo: v.union(v.literal('atestado_medico'), v.literal('declaracao_comparecimento')),
|
||||||
|
dataInicio: v.string(),
|
||||||
|
dataFim: v.string(),
|
||||||
|
cid: v.optional(v.string()), // Apenas para atestado médico
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
documentoId: v.optional(v.id('_storage')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_data_inicio', ['dataInicio'])
|
||||||
|
.index('by_funcionario_and_tipo', ['funcionarioId', 'tipo'])
|
||||||
|
};
|
||||||
36
packages/backend/convex/tables/ausencias.ts
Normal file
36
packages/backend/convex/tables/ausencias.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const ausenciasTables = {
|
||||||
|
// Solicitações de Ausências
|
||||||
|
solicitacoesAusencias: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
dataInicio: v.string(),
|
||||||
|
dataFim: v.string(),
|
||||||
|
motivo: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('aguardando_aprovacao'),
|
||||||
|
v.literal('aprovado'),
|
||||||
|
v.literal('reprovado')
|
||||||
|
),
|
||||||
|
gestorId: v.optional(v.id('usuarios')),
|
||||||
|
dataAprovacao: v.optional(v.number()),
|
||||||
|
dataReprovacao: v.optional(v.number()),
|
||||||
|
motivoReprovacao: v.optional(v.string()),
|
||||||
|
observacao: v.optional(v.string()),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_funcionario_and_status', ['funcionarioId', 'status']),
|
||||||
|
|
||||||
|
notificacoesAusencias: defineTable({
|
||||||
|
destinatarioId: v.id('usuarios'),
|
||||||
|
solicitacaoAusenciaId: v.id('solicitacoesAusencias'),
|
||||||
|
tipo: v.union(v.literal('nova_solicitacao'), v.literal('aprovado'), v.literal('reprovado')),
|
||||||
|
lida: v.boolean(),
|
||||||
|
mensagem: v.string()
|
||||||
|
})
|
||||||
|
.index('by_destinatario', ['destinatarioId'])
|
||||||
|
.index('by_destinatario_and_lida', ['destinatarioId', 'lida'])
|
||||||
|
};
|
||||||
172
packages/backend/convex/tables/auth.ts
Normal file
172
packages/backend/convex/tables/auth.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const authTables = {
|
||||||
|
// Sistema de Autenticação e Controle de Acesso
|
||||||
|
usuarios: defineTable({
|
||||||
|
authId: v.string(),
|
||||||
|
nome: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
funcionarioId: v.optional(v.id('funcionarios')),
|
||||||
|
roleId: v.id('roles'),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
primeiroAcesso: v.boolean(),
|
||||||
|
ultimoAcesso: v.optional(v.number()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
|
||||||
|
// Controle de Bloqueio e Segurança
|
||||||
|
bloqueado: v.optional(v.boolean()),
|
||||||
|
motivoBloqueio: v.optional(v.string()),
|
||||||
|
dataBloqueio: v.optional(v.number()),
|
||||||
|
tentativasLogin: v.optional(v.number()), // contador de tentativas falhas
|
||||||
|
ultimaTentativaLogin: v.optional(v.number()), // timestamp da última tentativa
|
||||||
|
|
||||||
|
// Campos de Chat e Perfil
|
||||||
|
|
||||||
|
fotoPerfil: v.optional(v.id('_storage')),
|
||||||
|
avatar: v.optional(v.string()), // URL do avatar gerado (ex: DiceBear)
|
||||||
|
setor: v.optional(v.string()),
|
||||||
|
statusMensagem: v.optional(v.string()), // max 100 chars
|
||||||
|
statusPresenca: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('online'),
|
||||||
|
v.literal('offline'),
|
||||||
|
v.literal('ausente'),
|
||||||
|
v.literal('externo'),
|
||||||
|
v.literal('em_reuniao')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
ultimaAtividade: v.optional(v.number()), // timestamp
|
||||||
|
notificacoesAtivadas: v.optional(v.boolean()),
|
||||||
|
somNotificacao: v.optional(v.boolean()),
|
||||||
|
temaPreferido: v.optional(v.string()) // tema de aparência escolhido pelo usuário
|
||||||
|
})
|
||||||
|
.index('by_email', ['email'])
|
||||||
|
.index('by_role', ['roleId'])
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_status_presenca', ['statusPresenca'])
|
||||||
|
.index('by_bloqueado', ['bloqueado'])
|
||||||
|
.index('by_funcionarioId', ['funcionarioId'])
|
||||||
|
.index('authId', ['authId']),
|
||||||
|
|
||||||
|
roles: defineTable({
|
||||||
|
nome: v.string(), // "admin", "ti_master", "ti_usuario", "usuario_avancado", "usuario"
|
||||||
|
descricao: v.string(),
|
||||||
|
nivel: v.number(), // 0 = admin, 1 = ti_master, 2 = ti_usuario, 3+ = customizado
|
||||||
|
setor: v.optional(v.string()), // "ti", "rh", "financeiro", etc.
|
||||||
|
customizado: v.optional(v.boolean()), // se é um perfil customizado criado por TI_MASTER
|
||||||
|
criadoPor: v.optional(v.id('usuarios')), // usuário TI_MASTER que criou este perfil
|
||||||
|
editavel: v.optional(v.boolean()) // se pode ser editado (false para roles fixas)
|
||||||
|
})
|
||||||
|
.index('by_nome', ['nome'])
|
||||||
|
.index('by_nivel', ['nivel'])
|
||||||
|
.index('by_setor', ['setor'])
|
||||||
|
.index('by_customizado', ['customizado']),
|
||||||
|
|
||||||
|
permissoes: defineTable({
|
||||||
|
nome: v.string(), // "funcionarios.criar", "simbolos.editar", etc.
|
||||||
|
descricao: v.string(),
|
||||||
|
recurso: v.string(), // "funcionarios", "simbolos", "usuarios", etc.
|
||||||
|
acao: v.string() // "criar", "ler", "editar", "excluir"
|
||||||
|
})
|
||||||
|
.index('by_recurso', ['recurso'])
|
||||||
|
.index('by_recurso_e_acao', ['recurso', 'acao'])
|
||||||
|
.index('by_nome', ['nome']),
|
||||||
|
|
||||||
|
rolePermissoes: defineTable({
|
||||||
|
roleId: v.id('roles'),
|
||||||
|
permissaoId: v.id('permissoes')
|
||||||
|
})
|
||||||
|
.index('by_role', ['roleId'])
|
||||||
|
.index('by_permissao', ['permissaoId']),
|
||||||
|
|
||||||
|
sessoes: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
token: v.string(),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
expiraEm: v.number(),
|
||||||
|
ativo: v.boolean()
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId'])
|
||||||
|
.index('by_token', ['token'])
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_expiracao', ['expiraEm']),
|
||||||
|
|
||||||
|
logsAcesso: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('login'),
|
||||||
|
v.literal('logout'),
|
||||||
|
v.literal('acesso_negado'),
|
||||||
|
v.literal('senha_alterada'),
|
||||||
|
v.literal('sessao_expirada')
|
||||||
|
),
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
detalhes: v.optional(v.string()),
|
||||||
|
timestamp: v.number()
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_timestamp', ['timestamp']),
|
||||||
|
|
||||||
|
// Histórico de Bloqueios
|
||||||
|
bloqueiosUsuarios: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
motivo: v.string(),
|
||||||
|
bloqueadoPor: v.id('usuarios'), // ID do TI_MASTER que bloqueou
|
||||||
|
dataInicio: v.number(),
|
||||||
|
dataFim: v.optional(v.number()), // quando foi desbloqueado
|
||||||
|
desbloqueadoPor: v.optional(v.id('usuarios')),
|
||||||
|
ativo: v.boolean() // se é o bloqueio atual ativo
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId'])
|
||||||
|
.index('by_bloqueado_por', ['bloqueadoPor'])
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_data_inicio', ['dataInicio']),
|
||||||
|
|
||||||
|
configuracaoAcesso: defineTable({
|
||||||
|
chave: v.string(), // "sessao_duracao", "max_tentativas_login", etc.
|
||||||
|
valor: v.string(),
|
||||||
|
descricao: v.string()
|
||||||
|
}).index('by_chave', ['chave']),
|
||||||
|
|
||||||
|
// Logs de Login Detalhados
|
||||||
|
logsLogin: defineTable({
|
||||||
|
usuarioId: v.optional(v.id('usuarios')), // pode ser null se falha antes de identificar usuário
|
||||||
|
matriculaOuEmail: v.string(), // tentativa de login
|
||||||
|
sucesso: v.boolean(),
|
||||||
|
motivoFalha: v.optional(v.string()), // "senha_incorreta", "usuario_bloqueado", "usuario_inexistente"
|
||||||
|
// Informações de Rede
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
ipPublico: v.optional(v.string()),
|
||||||
|
ipLocal: v.optional(v.string()),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
device: v.optional(v.string()),
|
||||||
|
browser: v.optional(v.string()),
|
||||||
|
sistema: v.optional(v.string()),
|
||||||
|
// Informações de Localização (por IP)
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number()),
|
||||||
|
endereco: v.optional(v.string()),
|
||||||
|
cidade: v.optional(v.string()),
|
||||||
|
estado: v.optional(v.string()),
|
||||||
|
pais: v.optional(v.string()),
|
||||||
|
// Informações de Localização (GPS do navegador)
|
||||||
|
latitudeGPS: v.optional(v.number()),
|
||||||
|
longitudeGPS: v.optional(v.number()),
|
||||||
|
precisaoGPS: v.optional(v.number()),
|
||||||
|
enderecoGPS: v.optional(v.string()),
|
||||||
|
cidadeGPS: v.optional(v.string()),
|
||||||
|
estadoGPS: v.optional(v.string()),
|
||||||
|
paisGPS: v.optional(v.string()),
|
||||||
|
timestamp: v.number()
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId'])
|
||||||
|
.index('by_sucesso', ['sucesso'])
|
||||||
|
.index('by_timestamp', ['timestamp'])
|
||||||
|
.index('by_ip', ['ipAddress'])
|
||||||
|
};
|
||||||
173
packages/backend/convex/tables/chat.ts
Normal file
173
packages/backend/convex/tables/chat.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const chatTables = {
|
||||||
|
// Sistema de Chat
|
||||||
|
conversas: defineTable({
|
||||||
|
tipo: v.union(v.literal('individual'), v.literal('grupo'), v.literal('sala_reuniao')),
|
||||||
|
nome: v.optional(v.string()), // nome do grupo/sala
|
||||||
|
|
||||||
|
participantes: v.array(v.id('usuarios')), // IDs dos participantes
|
||||||
|
administradores: v.optional(v.array(v.id('usuarios'))), // IDs dos administradores (apenas para sala_reuniao)
|
||||||
|
ultimaMensagem: v.optional(v.string()),
|
||||||
|
ultimaMensagemTimestamp: v.optional(v.number()),
|
||||||
|
ultimaMensagemRemetenteId: v.optional(v.id('usuarios')), // ID do remetente da última mensagem
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_criado_por', ['criadoPor'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_ultima_mensagem', ['ultimaMensagemTimestamp']),
|
||||||
|
|
||||||
|
mensagens: defineTable({
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
remetenteId: v.id('usuarios'),
|
||||||
|
tipo: v.union(v.literal('texto'), v.literal('arquivo'), v.literal('imagem')),
|
||||||
|
conteudo: v.string(), // texto ou nome do arquivo
|
||||||
|
conteudoBusca: v.optional(v.string()), // versão normalizada para busca
|
||||||
|
arquivoId: v.optional(v.id('_storage')),
|
||||||
|
arquivoNome: v.optional(v.string()),
|
||||||
|
arquivoTamanho: v.optional(v.number()),
|
||||||
|
arquivoTipo: v.optional(v.string()),
|
||||||
|
linkPreview: v.optional(
|
||||||
|
v.object({
|
||||||
|
url: v.string(),
|
||||||
|
titulo: v.optional(v.string()),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
imagem: v.optional(v.string()),
|
||||||
|
site: v.optional(v.string())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
reagiuPor: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
emoji: v.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
mencoes: v.optional(v.array(v.id('usuarios'))),
|
||||||
|
respostaPara: v.optional(v.id('mensagens')), // ID da mensagem que está respondendo
|
||||||
|
agendadaPara: v.optional(v.number()), // timestamp
|
||||||
|
enviadaEm: v.number(),
|
||||||
|
editadaEm: v.optional(v.number()),
|
||||||
|
deletada: v.optional(v.boolean()),
|
||||||
|
lidaPor: v.optional(v.array(v.id('usuarios'))) // IDs dos usuários que leram a mensagem
|
||||||
|
})
|
||||||
|
.index('by_conversa', ['conversaId', 'enviadaEm'])
|
||||||
|
.index('by_remetente', ['remetenteId'])
|
||||||
|
.index('by_agendamento', ['agendadaPara'])
|
||||||
|
.index('by_resposta', ['respostaPara']),
|
||||||
|
|
||||||
|
leituras: defineTable({
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
ultimaMensagemLida: v.id('mensagens'),
|
||||||
|
lidaEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_conversa_usuario', ['conversaId', 'usuarioId'])
|
||||||
|
.index('by_usuario', ['usuarioId']),
|
||||||
|
|
||||||
|
// Sistema de Chamadas de Áudio/Vídeo
|
||||||
|
chamadas: defineTable({
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
tipo: v.union(v.literal('audio'), v.literal('video')),
|
||||||
|
roomName: v.string(), // Nome único da sala Jitsi
|
||||||
|
criadoPor: v.id('usuarios'), // Anfitrião/criador
|
||||||
|
participantes: v.array(v.id('usuarios')),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('aguardando'),
|
||||||
|
v.literal('em_andamento'),
|
||||||
|
v.literal('finalizada'),
|
||||||
|
v.literal('cancelada')
|
||||||
|
),
|
||||||
|
iniciadaEm: v.optional(v.number()),
|
||||||
|
finalizadaEm: v.optional(v.number()),
|
||||||
|
duracaoSegundos: v.optional(v.number()),
|
||||||
|
gravando: v.boolean(),
|
||||||
|
gravacaoIniciadaPor: v.optional(v.id('usuarios')),
|
||||||
|
gravacaoIniciadaEm: v.optional(v.number()),
|
||||||
|
gravacaoFinalizadaEm: v.optional(v.number()),
|
||||||
|
configuracoes: v.optional(
|
||||||
|
v.object({
|
||||||
|
audioHabilitado: v.boolean(),
|
||||||
|
videoHabilitado: v.boolean(),
|
||||||
|
participantesConfig: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
audioHabilitado: v.boolean(),
|
||||||
|
videoHabilitado: v.boolean(),
|
||||||
|
forcadoPeloAnfitriao: v.optional(v.boolean()) // Se foi forçado pelo anfitrião
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_conversa', ['conversaId', 'status'])
|
||||||
|
.index('by_criado_por', ['criadoPor'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_room_name', ['roomName']),
|
||||||
|
|
||||||
|
notificacoes: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('nova_mensagem'),
|
||||||
|
v.literal('mencao'),
|
||||||
|
v.literal('grupo_criado'),
|
||||||
|
v.literal('adicionado_grupo'),
|
||||||
|
v.literal('alerta_seguranca'),
|
||||||
|
v.literal('etapa_fluxo_concluida')
|
||||||
|
),
|
||||||
|
conversaId: v.optional(v.id('conversas')),
|
||||||
|
mensagemId: v.optional(v.id('mensagens')),
|
||||||
|
remetenteId: v.optional(v.id('usuarios')),
|
||||||
|
titulo: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
lida: v.boolean(),
|
||||||
|
criadaEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId', 'lida', 'criadaEm'])
|
||||||
|
.index('by_usuario_lida', ['usuarioId', 'lida']),
|
||||||
|
|
||||||
|
digitando: defineTable({
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
iniciouEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_conversa', ['conversaId', 'iniciouEm'])
|
||||||
|
.index('by_usuario', ['usuarioId']),
|
||||||
|
|
||||||
|
// Push Notifications
|
||||||
|
pushSubscriptions: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
endpoint: v.string(), // URL do serviço de push
|
||||||
|
keys: v.object({
|
||||||
|
p256dh: v.string(), // Chave pública
|
||||||
|
auth: v.string() // Chave de autenticação
|
||||||
|
}),
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
ultimaAtividade: v.number(),
|
||||||
|
ativo: v.boolean()
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId', 'ativo'])
|
||||||
|
.index('by_endpoint', ['endpoint']),
|
||||||
|
|
||||||
|
// Preferências de Notificação por Conversa
|
||||||
|
preferenciasNotificacaoConversa: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
conversaId: v.id('conversas'),
|
||||||
|
pushAtivado: v.boolean(), // Receber push notifications
|
||||||
|
emailAtivado: v.boolean(), // Receber emails quando offline
|
||||||
|
somAtivado: v.boolean(), // Tocar som
|
||||||
|
silenciado: v.boolean(), // Silenciar completamente
|
||||||
|
apenasMencoes: v.boolean(), // Notificar apenas quando mencionado
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_usuario_conversa', ['usuarioId', 'conversaId'])
|
||||||
|
.index('by_conversa', ['conversaId'])
|
||||||
|
};
|
||||||
37
packages/backend/convex/tables/contratos.ts
Normal file
37
packages/backend/convex/tables/contratos.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const situacaoContrato = v.union(
|
||||||
|
v.literal('em_execucao'),
|
||||||
|
v.literal('rescendido'),
|
||||||
|
v.literal('aguardando_assinatura'),
|
||||||
|
v.literal('finalizado')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const contratosTables = {
|
||||||
|
contratos: defineTable({
|
||||||
|
contratadaId: v.id('empresas'),
|
||||||
|
objeto: v.string(),
|
||||||
|
numeroNotaEmpenho: v.string(),
|
||||||
|
responsavelId: v.id('funcionarios'),
|
||||||
|
departamento: v.string(),
|
||||||
|
situacao: situacaoContrato,
|
||||||
|
numeroProcessoLicitatorio: v.string(),
|
||||||
|
modalidade: v.string(),
|
||||||
|
numeroContrato: v.string(),
|
||||||
|
anoContrato: v.number(),
|
||||||
|
dataInicioVigencia: v.string(),
|
||||||
|
dataFimVigencia: v.string(),
|
||||||
|
nomeFiscal: v.string(),
|
||||||
|
valorTotal: v.string(),
|
||||||
|
dataAditivoPrazo: v.optional(v.string()),
|
||||||
|
diasAvisoVencimento: v.number(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.optional(v.number())
|
||||||
|
})
|
||||||
|
.index('by_responsavel', ['responsavelId'])
|
||||||
|
.index('by_situacao', ['situacao'])
|
||||||
|
.index('by_vigencia_inicio', ['dataInicioVigencia'])
|
||||||
|
.index('by_vigencia_fim', ['dataFimVigencia'])
|
||||||
|
};
|
||||||
11
packages/backend/convex/tables/cursos.ts
Normal file
11
packages/backend/convex/tables/cursos.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const cursosTables = {
|
||||||
|
cursos: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
descricao: v.string(),
|
||||||
|
data: v.string(),
|
||||||
|
certificadoId: v.optional(v.id('_storage'))
|
||||||
|
}).index('by_funcionario', ['funcionarioId'])
|
||||||
|
};
|
||||||
29
packages/backend/convex/tables/empresas.ts
Normal file
29
packages/backend/convex/tables/empresas.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const empresasTable = {
|
||||||
|
empresas: defineTable({
|
||||||
|
razao_social: v.string(),
|
||||||
|
nome_fantasia: v.optional(v.string()),
|
||||||
|
cnpj: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
enderecoId: v.optional(v.id('enderecos')),
|
||||||
|
criadoPor: v.optional(v.id('usuarios'))
|
||||||
|
})
|
||||||
|
.index('by_razao_social', ['razao_social'])
|
||||||
|
.index('by_cnpj', ['cnpj']),
|
||||||
|
|
||||||
|
contatosEmpresa: defineTable({
|
||||||
|
empresaId: v.id('empresas'),
|
||||||
|
nome: v.string(),
|
||||||
|
funcao: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
adicionadoPor: v.optional(v.id('usuarios')),
|
||||||
|
descricao: v.optional(v.string())
|
||||||
|
})
|
||||||
|
.index('by_empresa', ['empresaId'])
|
||||||
|
.index('by_email', ['email'])
|
||||||
|
};
|
||||||
16
packages/backend/convex/tables/enderecos.ts
Normal file
16
packages/backend/convex/tables/enderecos.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const enderecosTables = {
|
||||||
|
enderecos: defineTable({
|
||||||
|
cep: v.string(),
|
||||||
|
logradouro: v.string(),
|
||||||
|
numero: v.string(),
|
||||||
|
complemento: v.optional(v.string()),
|
||||||
|
bairro: v.string(),
|
||||||
|
cidade: v.string(),
|
||||||
|
uf: v.string(),
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
atualizadoPor: v.optional(v.id('usuarios'))
|
||||||
|
}).index('by_cep', ['cep'])
|
||||||
|
};
|
||||||
55
packages/backend/convex/tables/ferias.ts
Normal file
55
packages/backend/convex/tables/ferias.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const feriasTables = {
|
||||||
|
ferias: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
anoReferencia: v.number(),
|
||||||
|
dataInicio: v.string(),
|
||||||
|
dataFim: v.string(),
|
||||||
|
diasFerias: v.number(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('aguardando_aprovacao'),
|
||||||
|
v.literal('aprovado'),
|
||||||
|
v.literal('reprovado'),
|
||||||
|
v.literal('data_ajustada_aprovada'),
|
||||||
|
v.literal('EmFérias'),
|
||||||
|
v.literal('Cancelado_RH')
|
||||||
|
),
|
||||||
|
gestorId: v.optional(v.id('usuarios')),
|
||||||
|
observacao: v.optional(v.string()),
|
||||||
|
motivoReprovacao: v.optional(v.string()),
|
||||||
|
dataAprovacao: v.optional(v.number()),
|
||||||
|
dataReprovacao: v.optional(v.number()),
|
||||||
|
diasAbono: v.number(),
|
||||||
|
historicoAlteracoes: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
data: v.number(),
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
acao: v.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_funcionario_and_ano', ['funcionarioId', 'anoReferencia'])
|
||||||
|
.index('by_funcionario_and_status', ['funcionarioId', 'status'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_ano', ['anoReferencia']),
|
||||||
|
|
||||||
|
notificacoesFerias: defineTable({
|
||||||
|
destinatarioId: v.id('usuarios'),
|
||||||
|
feriasId: v.id('ferias'),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('nova_solicitacao'),
|
||||||
|
v.literal('aprovado'),
|
||||||
|
v.literal('reprovado'),
|
||||||
|
v.literal('data_ajustada')
|
||||||
|
),
|
||||||
|
lida: v.boolean(),
|
||||||
|
mensagem: v.string()
|
||||||
|
})
|
||||||
|
.index('by_destinatario', ['destinatarioId'])
|
||||||
|
.index('by_destinatario_and_lida', ['destinatarioId', 'lida'])
|
||||||
|
};
|
||||||
132
packages/backend/convex/tables/flows.ts
Normal file
132
packages/backend/convex/tables/flows.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { Infer, v } from 'convex/values';
|
||||||
|
|
||||||
|
// Status de templates de fluxo
|
||||||
|
export const flowTemplateStatus = v.union(
|
||||||
|
v.literal('draft'),
|
||||||
|
v.literal('published'),
|
||||||
|
v.literal('archived')
|
||||||
|
);
|
||||||
|
export type FlowTemplateStatus = Infer<typeof flowTemplateStatus>;
|
||||||
|
|
||||||
|
// Status de instâncias de fluxo
|
||||||
|
export const flowInstanceStatus = v.union(
|
||||||
|
v.literal('active'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('cancelled')
|
||||||
|
);
|
||||||
|
export type FlowInstanceStatus = Infer<typeof flowInstanceStatus>;
|
||||||
|
|
||||||
|
// Status de passos de instância de fluxo
|
||||||
|
export const flowInstanceStepStatus = v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('in_progress'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('blocked')
|
||||||
|
);
|
||||||
|
export type FlowInstanceStepStatus = Infer<typeof flowInstanceStepStatus>;
|
||||||
|
|
||||||
|
export const flowsTables = {
|
||||||
|
// Templates de fluxo
|
||||||
|
flowTemplates: defineTable({
|
||||||
|
name: v.string(),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
status: flowTemplateStatus,
|
||||||
|
createdBy: v.id('usuarios'),
|
||||||
|
createdAt: v.number()
|
||||||
|
})
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_createdBy', ['createdBy']),
|
||||||
|
|
||||||
|
// Passos de template de fluxo
|
||||||
|
flowSteps: defineTable({
|
||||||
|
flowTemplateId: v.id('flowTemplates'),
|
||||||
|
name: v.string(),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
position: v.number(),
|
||||||
|
expectedDuration: v.number(), // em dias
|
||||||
|
setorId: v.id('setores'),
|
||||||
|
defaultAssigneeId: v.optional(v.id('usuarios')),
|
||||||
|
requiredDocuments: v.optional(v.array(v.string()))
|
||||||
|
})
|
||||||
|
.index('by_flowTemplateId', ['flowTemplateId'])
|
||||||
|
.index('by_flowTemplateId_and_position', ['flowTemplateId', 'position']),
|
||||||
|
|
||||||
|
// Instâncias de fluxo
|
||||||
|
flowInstances: defineTable({
|
||||||
|
flowTemplateId: v.id('flowTemplates'),
|
||||||
|
contratoId: v.optional(v.id('contratos')),
|
||||||
|
managerId: v.id('usuarios'),
|
||||||
|
status: flowInstanceStatus,
|
||||||
|
startedAt: v.number(),
|
||||||
|
finishedAt: v.optional(v.number()),
|
||||||
|
currentStepId: v.optional(v.id('flowInstanceSteps'))
|
||||||
|
})
|
||||||
|
.index('by_flowTemplateId', ['flowTemplateId'])
|
||||||
|
.index('by_contratoId', ['contratoId'])
|
||||||
|
.index('by_managerId', ['managerId'])
|
||||||
|
.index('by_status', ['status']),
|
||||||
|
|
||||||
|
// Passos de instância de fluxo
|
||||||
|
flowInstanceSteps: defineTable({
|
||||||
|
flowInstanceId: v.id('flowInstances'),
|
||||||
|
flowStepId: v.id('flowSteps'),
|
||||||
|
setorId: v.id('setores'),
|
||||||
|
assignedToId: v.optional(v.id('usuarios')),
|
||||||
|
status: flowInstanceStepStatus,
|
||||||
|
startedAt: v.optional(v.number()),
|
||||||
|
finishedAt: v.optional(v.number()),
|
||||||
|
notes: v.optional(v.string()),
|
||||||
|
notesUpdatedBy: v.optional(v.id('usuarios')),
|
||||||
|
notesUpdatedAt: v.optional(v.number()),
|
||||||
|
dueDate: v.optional(v.number())
|
||||||
|
})
|
||||||
|
.index('by_flowInstanceId', ['flowInstanceId'])
|
||||||
|
.index('by_flowInstanceId_and_status', ['flowInstanceId', 'status'])
|
||||||
|
.index('by_setorId', ['setorId'])
|
||||||
|
.index('by_assignedToId', ['assignedToId']),
|
||||||
|
|
||||||
|
// Documentos de instância de fluxo
|
||||||
|
flowInstanceDocuments: defineTable({
|
||||||
|
flowInstanceStepId: v.id('flowInstanceSteps'),
|
||||||
|
uploadedById: v.id('usuarios'),
|
||||||
|
storageId: v.id('_storage'),
|
||||||
|
name: v.string(),
|
||||||
|
uploadedAt: v.number()
|
||||||
|
})
|
||||||
|
.index('by_flowInstanceStepId', ['flowInstanceStepId'])
|
||||||
|
.index('by_uploadedById', ['uploadedById']),
|
||||||
|
|
||||||
|
// Sub-etapas de fluxo (para templates e instâncias)
|
||||||
|
flowSubSteps: defineTable({
|
||||||
|
flowStepId: v.optional(v.id('flowSteps')), // Para templates
|
||||||
|
flowInstanceStepId: v.optional(v.id('flowInstanceSteps')), // Para instâncias
|
||||||
|
name: v.string(),
|
||||||
|
description: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pending'),
|
||||||
|
v.literal('in_progress'),
|
||||||
|
v.literal('completed'),
|
||||||
|
v.literal('blocked')
|
||||||
|
),
|
||||||
|
position: v.number(),
|
||||||
|
createdBy: v.id('usuarios'),
|
||||||
|
createdAt: v.number()
|
||||||
|
})
|
||||||
|
.index('by_flowStepId', ['flowStepId'])
|
||||||
|
.index('by_flowInstanceStepId', ['flowInstanceStepId']),
|
||||||
|
|
||||||
|
// Notas de steps e sub-etapas
|
||||||
|
flowStepNotes: defineTable({
|
||||||
|
flowStepId: v.optional(v.id('flowSteps')),
|
||||||
|
flowInstanceStepId: v.optional(v.id('flowInstanceSteps')),
|
||||||
|
flowSubStepId: v.optional(v.id('flowSubSteps')),
|
||||||
|
texto: v.string(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
arquivos: v.array(v.id('_storage'))
|
||||||
|
})
|
||||||
|
.index('by_flowStepId', ['flowStepId'])
|
||||||
|
.index('by_flowInstanceStepId', ['flowInstanceStepId'])
|
||||||
|
.index('by_flowSubStepId', ['flowSubStepId'])
|
||||||
|
};
|
||||||
172
packages/backend/convex/tables/funcionarios.ts
Normal file
172
packages/backend/convex/tables/funcionarios.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { Infer, v } from 'convex/values';
|
||||||
|
|
||||||
|
export const simboloTipo = v.union(
|
||||||
|
v.literal('cargo_comissionado'),
|
||||||
|
v.literal('funcao_gratificada')
|
||||||
|
);
|
||||||
|
export type SimboloTipo = Infer<typeof simboloTipo>;
|
||||||
|
|
||||||
|
export const funcionariosTables = {
|
||||||
|
simbolos: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: simboloTipo,
|
||||||
|
descricao: v.string(),
|
||||||
|
vencValor: v.string(),
|
||||||
|
repValor: v.string(),
|
||||||
|
valor: v.string()
|
||||||
|
}),
|
||||||
|
|
||||||
|
funcionarios: defineTable({
|
||||||
|
// Campos obrigatórios existentes
|
||||||
|
nome: v.string(),
|
||||||
|
nascimento: v.string(),
|
||||||
|
rg: v.string(),
|
||||||
|
cpf: v.string(),
|
||||||
|
endereco: v.string(),
|
||||||
|
cep: v.string(),
|
||||||
|
cidade: v.string(),
|
||||||
|
uf: v.string(),
|
||||||
|
telefone: v.string(),
|
||||||
|
email: v.string(),
|
||||||
|
matricula: v.optional(v.string()),
|
||||||
|
admissaoData: v.optional(v.string()),
|
||||||
|
desligamentoData: v.optional(v.string()),
|
||||||
|
simboloId: v.id('simbolos'),
|
||||||
|
simboloTipo: simboloTipo,
|
||||||
|
gestorId: v.optional(v.id('usuarios')),
|
||||||
|
statusFerias: v.optional(v.union(v.literal('ativo'), v.literal('em_ferias'))),
|
||||||
|
|
||||||
|
// Regime de trabalho (para cálculo correto de férias)
|
||||||
|
regimeTrabalho: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('clt'), // CLT - Consolidação das Leis do Trabalho
|
||||||
|
v.literal('estatutario_pe'), // Servidor Público Estadual de Pernambuco
|
||||||
|
v.literal('estatutario_federal'), // Servidor Público Federal
|
||||||
|
v.literal('estatutario_municipal') // Servidor Público Municipal
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Dados Pessoais Adicionais (opcionais)
|
||||||
|
nomePai: v.optional(v.string()),
|
||||||
|
nomeMae: v.optional(v.string()),
|
||||||
|
naturalidade: v.optional(v.string()),
|
||||||
|
naturalidadeUF: v.optional(v.string()),
|
||||||
|
sexo: v.optional(v.union(v.literal('masculino'), v.literal('feminino'), v.literal('outro'))),
|
||||||
|
estadoCivil: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('solteiro'),
|
||||||
|
v.literal('casado'),
|
||||||
|
v.literal('divorciado'),
|
||||||
|
v.literal('viuvo'),
|
||||||
|
v.literal('uniao_estavel')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
nacionalidade: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Documentos Pessoais
|
||||||
|
rgOrgaoExpedidor: v.optional(v.string()),
|
||||||
|
rgDataEmissao: v.optional(v.string()),
|
||||||
|
carteiraProfissionalNumero: v.optional(v.string()),
|
||||||
|
carteiraProfissionalSerie: v.optional(v.string()),
|
||||||
|
carteiraProfissionalDataEmissao: v.optional(v.string()),
|
||||||
|
reservistaNumero: v.optional(v.string()),
|
||||||
|
reservistaSerie: v.optional(v.string()),
|
||||||
|
tituloEleitorNumero: v.optional(v.string()),
|
||||||
|
tituloEleitorZona: v.optional(v.string()),
|
||||||
|
tituloEleitorSecao: v.optional(v.string()),
|
||||||
|
pisNumero: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Formação e Saúde
|
||||||
|
grauInstrucao: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('fundamental'),
|
||||||
|
v.literal('medio'),
|
||||||
|
v.literal('superior'),
|
||||||
|
v.literal('pos_graduacao'),
|
||||||
|
v.literal('mestrado'),
|
||||||
|
v.literal('doutorado')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
formacao: v.optional(v.string()),
|
||||||
|
formacaoRegistro: v.optional(v.string()),
|
||||||
|
grupoSanguineo: v.optional(
|
||||||
|
v.union(v.literal('A'), v.literal('B'), v.literal('AB'), v.literal('O'))
|
||||||
|
),
|
||||||
|
fatorRH: v.optional(v.union(v.literal('positivo'), v.literal('negativo'))),
|
||||||
|
|
||||||
|
// Cargo e Vínculo
|
||||||
|
descricaoCargo: v.optional(v.string()),
|
||||||
|
nomeacaoPortaria: v.optional(v.string()),
|
||||||
|
nomeacaoData: v.optional(v.string()),
|
||||||
|
nomeacaoDOE: v.optional(v.string()),
|
||||||
|
pertenceOrgaoPublico: v.optional(v.boolean()),
|
||||||
|
orgaoOrigem: v.optional(v.string()),
|
||||||
|
aposentado: v.optional(v.union(v.literal('nao'), v.literal('funape_ipsep'), v.literal('inss'))),
|
||||||
|
|
||||||
|
// Dados Bancários
|
||||||
|
contaBradescoNumero: v.optional(v.string()),
|
||||||
|
contaBradescoDV: v.optional(v.string()),
|
||||||
|
contaBradescoAgencia: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Documentos Anexos (Storage IDs)
|
||||||
|
certidaoAntecedentesPF: v.optional(v.id('_storage')),
|
||||||
|
certidaoAntecedentesJFPE: v.optional(v.id('_storage')),
|
||||||
|
certidaoAntecedentesSDS: v.optional(v.id('_storage')),
|
||||||
|
certidaoAntecedentesTJPE: v.optional(v.id('_storage')),
|
||||||
|
certidaoImprobidade: v.optional(v.id('_storage')),
|
||||||
|
rgFrente: v.optional(v.id('_storage')),
|
||||||
|
rgVerso: v.optional(v.id('_storage')),
|
||||||
|
cpfFrente: v.optional(v.id('_storage')),
|
||||||
|
cpfVerso: v.optional(v.id('_storage')),
|
||||||
|
situacaoCadastralCPF: v.optional(v.id('_storage')),
|
||||||
|
tituloEleitorFrente: v.optional(v.id('_storage')),
|
||||||
|
tituloEleitorVerso: v.optional(v.id('_storage')),
|
||||||
|
comprovanteVotacao: v.optional(v.id('_storage')),
|
||||||
|
carteiraProfissionalFrente: v.optional(v.id('_storage')),
|
||||||
|
carteiraProfissionalVerso: v.optional(v.id('_storage')),
|
||||||
|
comprovantePIS: v.optional(v.id('_storage')),
|
||||||
|
certidaoRegistroCivil: v.optional(v.id('_storage')),
|
||||||
|
certidaoNascimentoDependentes: v.optional(v.id('_storage')),
|
||||||
|
cpfDependentes: v.optional(v.id('_storage')),
|
||||||
|
reservistaDoc: v.optional(v.id('_storage')),
|
||||||
|
comprovanteEscolaridade: v.optional(v.id('_storage')),
|
||||||
|
comprovanteResidencia: v.optional(v.id('_storage')),
|
||||||
|
comprovanteContaBradesco: v.optional(v.id('_storage')),
|
||||||
|
|
||||||
|
// Dependentes do funcionário (uploads opcionais)
|
||||||
|
dependentes: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
parentesco: v.union(
|
||||||
|
v.literal('filho'),
|
||||||
|
v.literal('filha'),
|
||||||
|
v.literal('conjuge'),
|
||||||
|
v.literal('outro')
|
||||||
|
),
|
||||||
|
nome: v.string(),
|
||||||
|
cpf: v.string(),
|
||||||
|
nascimento: v.string(),
|
||||||
|
documentoId: v.optional(v.id('_storage')),
|
||||||
|
// Benefícios/declarações por dependente
|
||||||
|
salarioFamilia: v.optional(v.boolean()),
|
||||||
|
impostoRenda: v.optional(v.boolean())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
|
// Declarações (Storage IDs)
|
||||||
|
declaracaoAcumulacaoCargo: v.optional(v.id('_storage')),
|
||||||
|
declaracaoDependentesIR: v.optional(v.id('_storage')),
|
||||||
|
declaracaoIdoneidade: v.optional(v.id('_storage')),
|
||||||
|
termoNepotismo: v.optional(v.id('_storage')),
|
||||||
|
termoOpcaoRemuneracao: v.optional(v.id('_storage'))
|
||||||
|
})
|
||||||
|
.index('by_matricula', ['matricula'])
|
||||||
|
.index('by_nome', ['nome'])
|
||||||
|
.index('by_simboloId', ['simboloId'])
|
||||||
|
.index('by_simboloTipo', ['simboloTipo'])
|
||||||
|
.index('by_cpf', ['cpf'])
|
||||||
|
.index('by_rg', ['rg'])
|
||||||
|
.index('by_gestor', ['gestorId'])
|
||||||
|
};
|
||||||
22
packages/backend/convex/tables/licencas.ts
Normal file
22
packages/backend/convex/tables/licencas.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const licencasTables = {
|
||||||
|
licencas: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
tipo: v.union(v.literal('maternidade'), v.literal('paternidade')),
|
||||||
|
dataInicio: v.string(),
|
||||||
|
dataFim: v.string(),
|
||||||
|
documentoId: v.optional(v.id('_storage')),
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
licencaOriginalId: v.optional(v.id('licencas')), // Para prorrogações
|
||||||
|
ehProrrogacao: v.boolean(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_data_inicio', ['dataInicio'])
|
||||||
|
.index('by_licenca_original', ['licencaOriginalId'])
|
||||||
|
.index('by_funcionario_and_tipo', ['funcionarioId', 'tipo'])
|
||||||
|
};
|
||||||
48
packages/backend/convex/tables/pedidos.ts
Normal file
48
packages/backend/convex/tables/pedidos.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const pedidosTables = {
|
||||||
|
pedidos: defineTable({
|
||||||
|
numeroSei: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('em_rascunho'),
|
||||||
|
v.literal('aguardando_aceite'),
|
||||||
|
v.literal('em_analise'),
|
||||||
|
v.literal('precisa_ajustes'),
|
||||||
|
v.literal('cancelado'),
|
||||||
|
v.literal('concluido')
|
||||||
|
),
|
||||||
|
acaoId: v.optional(v.id('acoes')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_numeroSei', ['numeroSei'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_criadoPor', ['criadoPor'])
|
||||||
|
.index('by_acaoId', ['acaoId']),
|
||||||
|
|
||||||
|
pedidoItems: defineTable({
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
produtoId: v.id('produtos'),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
valorReal: v.optional(v.string()),
|
||||||
|
quantidade: v.number(),
|
||||||
|
adicionadoPor: v.id('funcionarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
|
.index('by_produtoId', ['produtoId'])
|
||||||
|
.index('by_adicionadoPor', ['adicionadoPor']),
|
||||||
|
|
||||||
|
historicoPedidos: defineTable({
|
||||||
|
pedidoId: v.id('pedidos'),
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
acao: v.string(), // "criacao", "alteracao_status", "adicao_item", "remocao_item", "edicao_item"
|
||||||
|
detalhes: v.optional(v.string()), // JSON string
|
||||||
|
data: v.number()
|
||||||
|
})
|
||||||
|
.index('by_pedidoId', ['pedidoId'])
|
||||||
|
.index('by_usuarioId', ['usuarioId'])
|
||||||
|
.index('by_data', ['data'])
|
||||||
|
};
|
||||||
266
packages/backend/convex/tables/ponto.ts
Normal file
266
packages/backend/convex/tables/ponto.ts
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const pontoTables = {
|
||||||
|
// Sistema de Controle de Ponto
|
||||||
|
registrosPonto: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('entrada'),
|
||||||
|
v.literal('saida_almoco'),
|
||||||
|
v.literal('retorno_almoco'),
|
||||||
|
v.literal('saida')
|
||||||
|
),
|
||||||
|
data: v.string(), // YYYY-MM-DD
|
||||||
|
hora: v.number(),
|
||||||
|
minuto: v.number(),
|
||||||
|
segundo: v.number(),
|
||||||
|
timestamp: v.number(), // Timestamp completo para ordenação
|
||||||
|
imagemId: v.optional(v.id('_storage')),
|
||||||
|
sincronizadoComServidor: v.boolean(),
|
||||||
|
toleranciaMinutos: v.number(),
|
||||||
|
dentroDoPrazo: v.boolean(),
|
||||||
|
|
||||||
|
// Informações de Rede
|
||||||
|
ipAddress: v.optional(v.string()),
|
||||||
|
ipPublico: v.optional(v.string()),
|
||||||
|
ipLocal: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações do Navegador
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
browser: v.optional(v.string()),
|
||||||
|
browserVersion: v.optional(v.string()),
|
||||||
|
engine: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações do Sistema
|
||||||
|
sistemaOperacional: v.optional(v.string()),
|
||||||
|
osVersion: v.optional(v.string()),
|
||||||
|
arquitetura: v.optional(v.string()),
|
||||||
|
plataforma: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações de Localização
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number()),
|
||||||
|
precisao: v.optional(v.number()),
|
||||||
|
altitude: v.optional(v.union(v.number(), v.null())),
|
||||||
|
altitudeAccuracy: v.optional(v.union(v.number(), v.null())),
|
||||||
|
heading: v.optional(v.union(v.number(), v.null())),
|
||||||
|
speed: v.optional(v.union(v.number(), v.null())),
|
||||||
|
confiabilidadeGPS: v.optional(v.number()), // 0-1 (frontend)
|
||||||
|
scoreConfiancaBackend: v.optional(v.number()), // 0-1 (backend)
|
||||||
|
suspeitaSpoofing: v.optional(v.boolean()),
|
||||||
|
motivoSuspeita: v.optional(v.string()),
|
||||||
|
avisosValidacao: v.optional(v.array(v.string())), // Array de avisos detalhados da validação
|
||||||
|
distanciaIPvsGPS: v.optional(v.number()), // Distância em metros entre IP geolocation e GPS
|
||||||
|
velocidadeUltimoRegistro: v.optional(v.number()), // Velocidade em km/h do último registro
|
||||||
|
distanciaUltimoRegistro: v.optional(v.number()), // Distância em metros do último registro
|
||||||
|
tempoDecorridoHoras: v.optional(v.number()), // Tempo em horas desde o último registro
|
||||||
|
// Informações de Geofencing
|
||||||
|
enderecoMarcacaoEsperado: v.optional(v.id('enderecosMarcacao')), // Endereço mais próximo esperado
|
||||||
|
distanciaEnderecoEsperado: v.optional(v.number()), // Distância em metros do endereço esperado
|
||||||
|
dentroRaioPermitido: v.optional(v.boolean()), // Se está dentro do raio permitido
|
||||||
|
enderecoMarcacaoUsado: v.optional(v.id('enderecosMarcacao')), // Qual endereço foi usado na validação
|
||||||
|
raioToleranciaUsado: v.optional(v.number()), // Raio usado na validação em metros
|
||||||
|
endereco: v.optional(v.string()),
|
||||||
|
cidade: v.optional(v.string()),
|
||||||
|
estado: v.optional(v.string()),
|
||||||
|
pais: v.optional(v.string()),
|
||||||
|
timezone: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações do Dispositivo
|
||||||
|
deviceType: v.optional(v.string()),
|
||||||
|
deviceModel: v.optional(v.string()),
|
||||||
|
screenResolution: v.optional(v.string()),
|
||||||
|
coresTela: v.optional(v.number()),
|
||||||
|
idioma: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações Adicionais
|
||||||
|
isMobile: v.optional(v.boolean()),
|
||||||
|
isTablet: v.optional(v.boolean()),
|
||||||
|
isDesktop: v.optional(v.boolean()),
|
||||||
|
connectionType: v.optional(v.string()),
|
||||||
|
memoryInfo: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Informações de Sensores (Acelerômetro e Giroscópio)
|
||||||
|
acelerometroX: v.optional(v.number()),
|
||||||
|
acelerometroY: v.optional(v.number()),
|
||||||
|
acelerometroZ: v.optional(v.number()),
|
||||||
|
movimentoDetectado: v.optional(v.boolean()),
|
||||||
|
magnitudeMovimento: v.optional(v.number()),
|
||||||
|
variacaoAcelerometro: v.optional(v.number()),
|
||||||
|
giroscopioAlpha: v.optional(v.number()),
|
||||||
|
giroscopioBeta: v.optional(v.number()),
|
||||||
|
giroscopioGamma: v.optional(v.number()),
|
||||||
|
sensorDisponivel: v.optional(v.boolean()),
|
||||||
|
permissaoSensorNegada: v.optional(v.boolean()),
|
||||||
|
|
||||||
|
// Justificativa opcional para o registro
|
||||||
|
justificativa: v.optional(v.string()),
|
||||||
|
|
||||||
|
// Campos para homologação
|
||||||
|
editadoPorGestor: v.optional(v.boolean()),
|
||||||
|
homologacaoId: v.optional(v.id('homologacoesPonto')),
|
||||||
|
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario_data', ['funcionarioId', 'data'])
|
||||||
|
.index('by_data', ['data'])
|
||||||
|
.index('by_dentro_prazo', ['dentroDoPrazo', 'data'])
|
||||||
|
.index('by_funcionario_timestamp', ['funcionarioId', 'timestamp']),
|
||||||
|
|
||||||
|
// Endereços de Marcação - Locais permitidos para registro de ponto
|
||||||
|
enderecosMarcacao: defineTable({
|
||||||
|
nome: v.string(), // Ex: "Sede Principal", "Home Office João Silva", "Cliente ABC"
|
||||||
|
descricao: v.optional(v.string()), // Descrição opcional
|
||||||
|
// Coordenadas (obrigatórias)
|
||||||
|
latitude: v.number(),
|
||||||
|
longitude: v.number(),
|
||||||
|
// Endereço físico (para exibição)
|
||||||
|
endereco: v.string(), // Ex: "Rua Exemplo, 123"
|
||||||
|
bairro: v.optional(v.string()), // Bairro do endereço
|
||||||
|
cep: v.optional(v.string()),
|
||||||
|
cidade: v.string(),
|
||||||
|
estado: v.string(),
|
||||||
|
pais: v.optional(v.string()), // Padrão: "Brasil"
|
||||||
|
// Configurações
|
||||||
|
raioMetros: v.number(), // Raio de tolerância em metros (ex: 100m, 500m, 1000m)
|
||||||
|
ativo: v.boolean(),
|
||||||
|
// Tipos de uso
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('sede'), // Sede principal (para todos)
|
||||||
|
v.literal('home_office'), // Home office específico
|
||||||
|
v.literal('deslocamento'), // Deslocamento temporário
|
||||||
|
v.literal('cliente') // Local de cliente
|
||||||
|
),
|
||||||
|
// Metadados
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoPor: v.optional(v.id('usuarios')),
|
||||||
|
atualizadoEm: v.optional(v.number())
|
||||||
|
})
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_cidade', ['cidade']),
|
||||||
|
|
||||||
|
// Associação Funcionário ↔ Endereço de Marcação
|
||||||
|
funcionarioEnderecosMarcacao: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
enderecoMarcacaoId: v.id('enderecosMarcacao'),
|
||||||
|
// Configurações específicas do funcionário
|
||||||
|
raioMetrosPersonalizado: v.optional(v.number()), // Pode ter raio diferente do padrão
|
||||||
|
// Período de validade (para deslocamentos temporários)
|
||||||
|
dataInicio: v.optional(v.string()), // YYYY-MM-DD
|
||||||
|
dataFim: v.optional(v.string()), // YYYY-MM-DD
|
||||||
|
// Status
|
||||||
|
ativo: v.boolean(),
|
||||||
|
// Metadados
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_endereco', ['enderecoMarcacaoId'])
|
||||||
|
.index('by_funcionario_ativo', ['funcionarioId', 'ativo'])
|
||||||
|
.index('by_endereco_ativo', ['enderecoMarcacaoId', 'ativo']),
|
||||||
|
|
||||||
|
configuracaoPonto: defineTable({
|
||||||
|
horarioEntrada: v.string(), // HH:mm
|
||||||
|
horarioSaidaAlmoco: v.string(), // HH:mm
|
||||||
|
horarioRetornoAlmoco: v.string(), // HH:mm
|
||||||
|
horarioSaida: v.string(), // HH:mm
|
||||||
|
toleranciaMinutos: v.number(),
|
||||||
|
// Nomes personalizados dos tipos de registro
|
||||||
|
nomeEntrada: v.optional(v.string()), // Padrão: "Entrada 1"
|
||||||
|
nomeSaidaAlmoco: v.optional(v.string()), // Padrão: "Saída 1"
|
||||||
|
nomeRetornoAlmoco: v.optional(v.string()), // Padrão: "Entrada 2"
|
||||||
|
nomeSaida: v.optional(v.string()), // Padrão: "Saída 2"
|
||||||
|
// Ajuste de fuso horário (GMT offset em horas)
|
||||||
|
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
|
||||||
|
// Configurações de geofencing
|
||||||
|
validarLocalizacao: v.optional(v.boolean()), // Habilitar/desabilitar validação de localização
|
||||||
|
toleranciaDistanciaMetros: v.optional(v.number()), // Raio padrão global em metros
|
||||||
|
ativo: v.boolean(),
|
||||||
|
atualizadoPor: v.id('usuarios'),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}).index('by_ativo', ['ativo']),
|
||||||
|
|
||||||
|
configuracaoRelogio: defineTable({
|
||||||
|
servidorNTP: v.optional(v.string()),
|
||||||
|
portaNTP: v.optional(v.number()),
|
||||||
|
usarServidorExterno: v.boolean(),
|
||||||
|
fallbackParaPC: v.boolean(),
|
||||||
|
ultimaSincronizacao: v.optional(v.number()),
|
||||||
|
offsetSegundos: v.optional(v.number()),
|
||||||
|
// Ajuste de fuso horário (GMT offset em horas)
|
||||||
|
gmtOffset: v.optional(v.number()), // Padrão: 0 (UTC)
|
||||||
|
atualizadoPor: v.id('usuarios'),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}).index('by_ativo', ['usarServidorExterno']),
|
||||||
|
|
||||||
|
// Banco de Horas - Saldo diário de horas trabalhadas
|
||||||
|
bancoHoras: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
data: v.string(), // YYYY-MM-DD
|
||||||
|
cargaHorariaDiaria: v.number(), // Horas esperadas do dia (em minutos)
|
||||||
|
horasTrabalhadas: v.number(), // Horas realmente trabalhadas (em minutos)
|
||||||
|
saldoMinutos: v.number(), // Saldo do dia (positivo = horas extras, negativo = déficit)
|
||||||
|
registrosPontoIds: v.array(v.id('registrosPonto')), // IDs dos registros do dia
|
||||||
|
calculadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario_data', ['funcionarioId', 'data'])
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_data', ['data']),
|
||||||
|
|
||||||
|
// Homologações de Ponto - Edições e ajustes realizados pelo gestor
|
||||||
|
homologacoesPonto: defineTable({
|
||||||
|
registroId: v.optional(v.id('registrosPonto')), // ID do registro editado (se for edição)
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
gestorId: v.id('usuarios'),
|
||||||
|
// Dados do registro original (se for edição)
|
||||||
|
horaAnterior: v.optional(v.number()),
|
||||||
|
minutoAnterior: v.optional(v.number()),
|
||||||
|
// Dados do registro novo (se for edição)
|
||||||
|
horaNova: v.optional(v.number()),
|
||||||
|
minutoNova: v.optional(v.number()),
|
||||||
|
// Motivo e observações
|
||||||
|
motivoId: v.optional(v.string()), // ID do motivo (referência a atestados/declarações)
|
||||||
|
motivoTipo: v.optional(v.string()), // Tipo do motivo (atestado, declaracao, etc)
|
||||||
|
motivoDescricao: v.optional(v.string()), // Descrição do motivo
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
// Tipo de ajuste (se for ajuste de banco de horas)
|
||||||
|
tipoAjuste: v.optional(
|
||||||
|
v.union(v.literal('compensar'), v.literal('abonar'), v.literal('descontar'))
|
||||||
|
),
|
||||||
|
// Período do ajuste (se for ajuste de banco de horas)
|
||||||
|
periodoDias: v.optional(v.number()),
|
||||||
|
periodoHoras: v.optional(v.number()),
|
||||||
|
periodoMinutos: v.optional(v.number()),
|
||||||
|
// Ajuste em minutos (calculado)
|
||||||
|
ajusteMinutos: v.optional(v.number()),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_gestor', ['gestorId'])
|
||||||
|
.index('by_registro', ['registroId'])
|
||||||
|
.index('by_data', ['criadoEm']),
|
||||||
|
|
||||||
|
// Dispensas de Registro - Períodos onde funcionário está dispensado de registrar ponto
|
||||||
|
dispensasRegistro: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
gestorId: v.id('usuarios'),
|
||||||
|
dataInicio: v.string(), // YYYY-MM-DD
|
||||||
|
horaInicio: v.number(),
|
||||||
|
minutoInicio: v.number(),
|
||||||
|
dataFim: v.string(), // YYYY-MM-DD
|
||||||
|
horaFim: v.number(),
|
||||||
|
minutoFim: v.number(),
|
||||||
|
motivo: v.string(),
|
||||||
|
isento: v.boolean(), // Se true, não expira (casos excepcionais)
|
||||||
|
ativo: v.boolean(),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_gestor', ['gestorId'])
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_data_inicio', ['dataInicio'])
|
||||||
|
.index('by_data_fim', ['dataFim'])
|
||||||
|
};
|
||||||
24
packages/backend/convex/tables/produtos.ts
Normal file
24
packages/backend/convex/tables/produtos.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const produtosTables = {
|
||||||
|
produtos: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
valorEstimado: v.string(),
|
||||||
|
tipo: v.union(v.literal('servico'), v.literal('estrutura'), v.literal('insumo')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.searchIndex('search_nome', { searchField: 'nome' })
|
||||||
|
.index('by_nome', ['nome'])
|
||||||
|
.index('by_tipo', ['tipo']),
|
||||||
|
|
||||||
|
acoes: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: v.union(v.literal('projeto'), v.literal('lei')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_nome', ['nome'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
};
|
||||||
330
packages/backend/convex/tables/security.ts
Normal file
330
packages/backend/convex/tables/security.ts
Normal file
@@ -0,0 +1,330 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { Infer, v } from 'convex/values';
|
||||||
|
|
||||||
|
export const ataqueCiberneticoTipo = v.union(
|
||||||
|
v.literal('phishing'),
|
||||||
|
v.literal('malware'),
|
||||||
|
v.literal('ransomware'),
|
||||||
|
v.literal('brute_force'),
|
||||||
|
v.literal('credential_stuffing'),
|
||||||
|
v.literal('sql_injection'),
|
||||||
|
v.literal('xss'),
|
||||||
|
v.literal('path_traversal'),
|
||||||
|
v.literal('command_injection'),
|
||||||
|
v.literal('nosql_injection'),
|
||||||
|
v.literal('xxe'),
|
||||||
|
v.literal('man_in_the_middle'),
|
||||||
|
v.literal('ddos'),
|
||||||
|
v.literal('engenharia_social'),
|
||||||
|
v.literal('cve_exploit'),
|
||||||
|
v.literal('apt'),
|
||||||
|
v.literal('zero_day'),
|
||||||
|
v.literal('supply_chain'),
|
||||||
|
v.literal('fileless_malware'),
|
||||||
|
v.literal('polymorphic_malware'),
|
||||||
|
v.literal('ransomware_lateral'),
|
||||||
|
v.literal('deepfake_phishing'),
|
||||||
|
v.literal('adversarial_ai'),
|
||||||
|
v.literal('side_channel'),
|
||||||
|
v.literal('firmware_bootloader'),
|
||||||
|
v.literal('bec'),
|
||||||
|
v.literal('botnet'),
|
||||||
|
v.literal('ot_ics'),
|
||||||
|
v.literal('quantum_attack')
|
||||||
|
);
|
||||||
|
export type AtaqueCiberneticoTipo = Infer<typeof ataqueCiberneticoTipo>;
|
||||||
|
|
||||||
|
export const severidadeSeguranca = v.union(
|
||||||
|
v.literal('informativo'),
|
||||||
|
v.literal('baixo'),
|
||||||
|
v.literal('moderado'),
|
||||||
|
v.literal('alto'),
|
||||||
|
v.literal('critico')
|
||||||
|
);
|
||||||
|
export type SeveridadeSeguranca = Infer<typeof severidadeSeguranca>;
|
||||||
|
|
||||||
|
export const statusEventoSeguranca = v.union(
|
||||||
|
v.literal('detectado'),
|
||||||
|
v.literal('investigando'),
|
||||||
|
v.literal('contido'),
|
||||||
|
v.literal('falso_positivo'),
|
||||||
|
v.literal('escalado'),
|
||||||
|
v.literal('resolvido')
|
||||||
|
);
|
||||||
|
export type StatusEventoSeguranca = Infer<typeof statusEventoSeguranca>;
|
||||||
|
|
||||||
|
export const sensorSegurancaTipo = v.union(
|
||||||
|
v.literal('network'),
|
||||||
|
v.literal('endpoint'),
|
||||||
|
v.literal('application'),
|
||||||
|
v.literal('gateway'),
|
||||||
|
v.literal('ot'),
|
||||||
|
v.literal('honeypot')
|
||||||
|
);
|
||||||
|
export type SensorSegurancaTipo = Infer<typeof sensorSegurancaTipo>;
|
||||||
|
|
||||||
|
export const sensorSegurancaStatus = v.union(
|
||||||
|
v.literal('ativo'),
|
||||||
|
v.literal('inativo'),
|
||||||
|
v.literal('degradado'),
|
||||||
|
v.literal('manutencao')
|
||||||
|
);
|
||||||
|
export type SensorSegurancaStatus = Infer<typeof sensorSegurancaStatus>;
|
||||||
|
|
||||||
|
export const threatIntelTipo = v.union(
|
||||||
|
v.literal('open_source'),
|
||||||
|
v.literal('commercial'),
|
||||||
|
v.literal('internal'),
|
||||||
|
v.literal('gov'),
|
||||||
|
v.literal('research')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const threatIntelFormato = v.union(
|
||||||
|
v.literal('json'),
|
||||||
|
v.literal('stix'),
|
||||||
|
v.literal('csv'),
|
||||||
|
v.literal('text'),
|
||||||
|
v.literal('custom')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const acaoIncidenteTipo = v.union(
|
||||||
|
v.literal('block_ip'),
|
||||||
|
v.literal('unblock_ip'),
|
||||||
|
v.literal('block_port'),
|
||||||
|
v.literal('liberar_porta'),
|
||||||
|
v.literal('notificar'),
|
||||||
|
v.literal('isolar_host'),
|
||||||
|
v.literal('gerar_relatorio'),
|
||||||
|
v.literal('criar_ticket'),
|
||||||
|
v.literal('ajuste_regra'),
|
||||||
|
v.literal('custom')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const acaoIncidenteStatus = v.union(
|
||||||
|
v.literal('pendente'),
|
||||||
|
v.literal('executando'),
|
||||||
|
v.literal('concluido'),
|
||||||
|
v.literal('falhou')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const reportStatus = v.union(
|
||||||
|
v.literal('pendente'),
|
||||||
|
v.literal('processando'),
|
||||||
|
v.literal('concluido'),
|
||||||
|
v.literal('falhou')
|
||||||
|
);
|
||||||
|
|
||||||
|
export const securityTables = {
|
||||||
|
// Sistema de Segurança Cibernética
|
||||||
|
networkSensors: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: sensorSegurancaTipo,
|
||||||
|
status: sensorSegurancaStatus,
|
||||||
|
escopo: v.optional(v.string()),
|
||||||
|
ipMonitorado: v.optional(v.string()),
|
||||||
|
hostname: v.optional(v.string()),
|
||||||
|
regioes: v.optional(v.array(v.string())),
|
||||||
|
portasMonitoradas: v.optional(v.array(v.number())),
|
||||||
|
protocolos: v.optional(v.array(v.string())),
|
||||||
|
capacidades: v.optional(v.array(v.string())),
|
||||||
|
ultimaSincronizacao: v.number(),
|
||||||
|
ultimoHeartbeat: v.optional(v.number()),
|
||||||
|
latenciaMs: v.optional(v.number()),
|
||||||
|
errosConsecutivos: v.optional(v.number()),
|
||||||
|
agenteVersao: v.optional(v.string()),
|
||||||
|
notas: v.optional(v.string())
|
||||||
|
})
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_hostname', ['hostname']),
|
||||||
|
|
||||||
|
ipReputation: defineTable({
|
||||||
|
indicador: v.string(),
|
||||||
|
categoria: v.union(
|
||||||
|
v.literal('ip'),
|
||||||
|
v.literal('dominio'),
|
||||||
|
v.literal('hash'),
|
||||||
|
v.literal('email')
|
||||||
|
),
|
||||||
|
reputacao: v.number(), // -100 (malicioso) até 100 (confiável)
|
||||||
|
severidadeMax: severidadeSeguranca,
|
||||||
|
whitelist: v.boolean(),
|
||||||
|
blacklist: v.boolean(),
|
||||||
|
ocorrencias: v.number(),
|
||||||
|
primeiroRegistro: v.number(),
|
||||||
|
ultimoRegistro: v.number(),
|
||||||
|
bloqueadoAte: v.optional(v.number()),
|
||||||
|
origem: v.optional(v.string()),
|
||||||
|
comentarios: v.optional(v.string()),
|
||||||
|
classificacoes: v.optional(v.array(v.string())),
|
||||||
|
ultimaAcaoId: v.optional(v.id('incidentActions'))
|
||||||
|
})
|
||||||
|
.index('by_indicador', ['indicador'])
|
||||||
|
.index('by_reputacao', ['reputacao'])
|
||||||
|
.index('by_blacklist', ['blacklist'])
|
||||||
|
.index('by_whitelist', ['whitelist']),
|
||||||
|
|
||||||
|
portRules: defineTable({
|
||||||
|
porta: v.number(),
|
||||||
|
protocolo: v.union(
|
||||||
|
v.literal('tcp'),
|
||||||
|
v.literal('udp'),
|
||||||
|
v.literal('icmp'),
|
||||||
|
v.literal('quic'),
|
||||||
|
v.literal('any')
|
||||||
|
),
|
||||||
|
acao: v.union(
|
||||||
|
v.literal('permitir'),
|
||||||
|
v.literal('bloquear'),
|
||||||
|
v.literal('monitorar'),
|
||||||
|
v.literal('rate_limit')
|
||||||
|
),
|
||||||
|
temporario: v.boolean(),
|
||||||
|
severidadeMin: severidadeSeguranca,
|
||||||
|
duracaoSegundos: v.optional(v.number()),
|
||||||
|
expiraEm: v.optional(v.number()),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
atualizadoPor: v.optional(v.id('usuarios')),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
notas: v.optional(v.string()),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
listaReferencia: v.optional(v.id('ipReputation'))
|
||||||
|
})
|
||||||
|
.index('by_porta_protocolo', ['porta', 'protocolo'])
|
||||||
|
.index('by_acao', ['acao'])
|
||||||
|
.index('by_expiracao', ['expiraEm']),
|
||||||
|
|
||||||
|
threatIntelFeeds: defineTable({
|
||||||
|
nomeFonte: v.string(),
|
||||||
|
tipo: threatIntelTipo,
|
||||||
|
formato: threatIntelFormato,
|
||||||
|
url: v.optional(v.string()),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
prioridade: v.union(
|
||||||
|
v.literal('baixa'),
|
||||||
|
v.literal('media'),
|
||||||
|
v.literal('alta'),
|
||||||
|
v.literal('critica')
|
||||||
|
),
|
||||||
|
ultimaSincronizacao: v.optional(v.number()),
|
||||||
|
entradasProcessadas: v.optional(v.number()),
|
||||||
|
errosConsecutivos: v.optional(v.number()),
|
||||||
|
autenticacaoNecessaria: v.optional(v.boolean()),
|
||||||
|
configuracao: v.optional(
|
||||||
|
v.object({
|
||||||
|
tokenId: v.optional(v.id('_storage')),
|
||||||
|
escopo: v.optional(v.string())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
atualizadoPor: v.optional(v.id('usuarios')),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_prioridade', ['prioridade']),
|
||||||
|
|
||||||
|
securityEvents: defineTable({
|
||||||
|
referencia: v.string(),
|
||||||
|
timestamp: v.number(),
|
||||||
|
tipoAtaque: ataqueCiberneticoTipo,
|
||||||
|
severidade: severidadeSeguranca,
|
||||||
|
status: statusEventoSeguranca,
|
||||||
|
descricao: v.string(),
|
||||||
|
origemIp: v.optional(v.string()),
|
||||||
|
origemRegiao: v.optional(v.string()),
|
||||||
|
origemAsn: v.optional(v.string()),
|
||||||
|
destinoIp: v.optional(v.string()),
|
||||||
|
destinoPorta: v.optional(v.number()),
|
||||||
|
protocolo: v.optional(v.string()),
|
||||||
|
transporte: v.optional(v.string()),
|
||||||
|
sensorId: v.optional(v.id('networkSensors')),
|
||||||
|
detectadoPor: v.optional(v.string()),
|
||||||
|
mitreTechnique: v.optional(v.string()),
|
||||||
|
geolocalizacao: v.optional(
|
||||||
|
v.object({
|
||||||
|
pais: v.optional(v.string()),
|
||||||
|
regiao: v.optional(v.string()),
|
||||||
|
cidade: v.optional(v.string()),
|
||||||
|
latitude: v.optional(v.number()),
|
||||||
|
longitude: v.optional(v.number())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
fingerprint: v.optional(
|
||||||
|
v.object({
|
||||||
|
userAgent: v.optional(v.string()),
|
||||||
|
deviceId: v.optional(v.string()),
|
||||||
|
ja3: v.optional(v.string()),
|
||||||
|
tlsVersion: v.optional(v.string())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
indicadores: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
tipo: v.string(),
|
||||||
|
valor: v.string(),
|
||||||
|
confianca: v.optional(v.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
metricas: v.optional(
|
||||||
|
v.object({
|
||||||
|
pps: v.optional(v.number()),
|
||||||
|
bps: v.optional(v.number()),
|
||||||
|
rpm: v.optional(v.number()),
|
||||||
|
errosPorSegundo: v.optional(v.number()),
|
||||||
|
hostsAfetados: v.optional(v.number())
|
||||||
|
})
|
||||||
|
),
|
||||||
|
correlacoes: v.optional(v.array(v.id('securityEvents'))),
|
||||||
|
referenciasExternas: v.optional(v.array(v.string())),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_referencia', ['referencia'])
|
||||||
|
.index('by_timestamp', ['timestamp'])
|
||||||
|
.index('by_tipo', ['tipoAtaque', 'timestamp'])
|
||||||
|
.index('by_severidade', ['severidade', 'timestamp'])
|
||||||
|
.index('by_status', ['status', 'timestamp']),
|
||||||
|
|
||||||
|
incidentActions: defineTable({
|
||||||
|
eventoId: v.id('securityEvents'),
|
||||||
|
tipo: acaoIncidenteTipo,
|
||||||
|
origem: v.union(v.literal('automatico'), v.literal('manual')),
|
||||||
|
status: acaoIncidenteStatus,
|
||||||
|
executadoPor: v.optional(v.id('usuarios')),
|
||||||
|
detalhes: v.optional(v.string()),
|
||||||
|
resultado: v.optional(v.string()),
|
||||||
|
relacionadoA: v.optional(v.id('ipReputation')),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_evento', ['eventoId', 'status'])
|
||||||
|
.index('by_tipo', ['tipo', 'status']),
|
||||||
|
|
||||||
|
reportRequests: defineTable({
|
||||||
|
solicitanteId: v.id('usuarios'),
|
||||||
|
filtros: v.object({
|
||||||
|
dataInicio: v.number(),
|
||||||
|
dataFim: v.number(),
|
||||||
|
severidades: v.optional(v.array(severidadeSeguranca)),
|
||||||
|
tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)),
|
||||||
|
incluirIndicadores: v.optional(v.boolean()),
|
||||||
|
incluirMetricas: v.optional(v.boolean()),
|
||||||
|
incluirAcoes: v.optional(v.boolean())
|
||||||
|
}),
|
||||||
|
status: reportStatus,
|
||||||
|
resultadoId: v.optional(v.id('_storage')),
|
||||||
|
observacoes: v.optional(v.string()),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
|
erro: v.optional(v.string())
|
||||||
|
})
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_solicitante', ['solicitanteId', 'status'])
|
||||||
|
.index('by_criado_em', ['criadoEm'])
|
||||||
|
};
|
||||||
24
packages/backend/convex/tables/setores.ts
Normal file
24
packages/backend/convex/tables/setores.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const setoresTables = {
|
||||||
|
// Setores da organização
|
||||||
|
setores: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
sigla: v.string(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
createdAt: v.number()
|
||||||
|
})
|
||||||
|
.index('by_nome', ['nome'])
|
||||||
|
.index('by_sigla', ['sigla']),
|
||||||
|
|
||||||
|
// Relação muitos-para-muitos entre funcionários e setores
|
||||||
|
funcionarioSetores: defineTable({
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
setorId: v.id('setores'),
|
||||||
|
createdAt: v.number()
|
||||||
|
})
|
||||||
|
.index('by_funcionarioId', ['funcionarioId'])
|
||||||
|
.index('by_setorId', ['setorId'])
|
||||||
|
.index('by_funcionarioId_and_setorId', ['funcionarioId', 'setorId'])
|
||||||
|
};
|
||||||
220
packages/backend/convex/tables/system.ts
Normal file
220
packages/backend/convex/tables/system.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
import { ataqueCiberneticoTipo, severidadeSeguranca } from './security';
|
||||||
|
|
||||||
|
export const systemTables = {
|
||||||
|
// Logs de Atividades do Sistema
|
||||||
|
logsAtividades: defineTable({
|
||||||
|
usuarioId: v.id('usuarios'),
|
||||||
|
acao: v.string(),
|
||||||
|
recurso: v.string(),
|
||||||
|
recursoId: v.optional(v.string()),
|
||||||
|
detalhes: v.optional(v.string()),
|
||||||
|
timestamp: v.number()
|
||||||
|
})
|
||||||
|
.index('by_usuario', ['usuarioId'])
|
||||||
|
.index('by_acao', ['acao'])
|
||||||
|
.index('by_recurso', ['recurso'])
|
||||||
|
.index('by_timestamp', ['timestamp'])
|
||||||
|
.index('by_recurso_id', ['recurso', 'recursoId']),
|
||||||
|
|
||||||
|
// Configuração de Email/SMTP
|
||||||
|
configuracaoEmail: defineTable({
|
||||||
|
servidor: v.string(), // smtp.gmail.com
|
||||||
|
porta: v.number(), // 587, 465, etc.
|
||||||
|
usuario: v.string(),
|
||||||
|
senhaHash: v.string(), // senha criptografada reversível (AES-GCM) - necessário para descriptografar e usar no SMTP
|
||||||
|
emailRemetente: v.string(),
|
||||||
|
nomeRemetente: v.string(),
|
||||||
|
usarSSL: v.boolean(),
|
||||||
|
usarTLS: v.boolean(),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
testadoEm: v.optional(v.number()),
|
||||||
|
configuradoPor: v.id('usuarios'),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}).index('by_ativo', ['ativo']),
|
||||||
|
|
||||||
|
// Fila de Emails
|
||||||
|
notificacoesEmail: defineTable({
|
||||||
|
destinatario: v.string(), // email
|
||||||
|
destinatarioId: v.optional(v.id('usuarios')),
|
||||||
|
assunto: v.string(),
|
||||||
|
corpo: v.string(), // HTML ou texto
|
||||||
|
templateId: v.optional(v.id('templatesMensagens')),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pendente'),
|
||||||
|
v.literal('enviando'),
|
||||||
|
v.literal('enviado'),
|
||||||
|
v.literal('falha')
|
||||||
|
),
|
||||||
|
tentativas: v.number(),
|
||||||
|
ultimaTentativa: v.optional(v.number()),
|
||||||
|
erroDetalhes: v.optional(v.string()),
|
||||||
|
enviadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
enviadoEm: v.optional(v.number()),
|
||||||
|
agendadaPara: v.optional(v.number()) // timestamp para agendamento
|
||||||
|
})
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_destinatario', ['destinatarioId'])
|
||||||
|
.index('by_enviado_por', ['enviadoPor'])
|
||||||
|
.index('by_criado_em', ['criadoEm'])
|
||||||
|
.index('by_agendamento', ['agendadaPara']),
|
||||||
|
|
||||||
|
// Rate Limiting de Emails
|
||||||
|
rateLimitEmails: defineTable({
|
||||||
|
remetenteId: v.id('usuarios'),
|
||||||
|
timestamp: v.number(),
|
||||||
|
contador: v.number(), // quantidade de emails enviados neste período
|
||||||
|
periodo: v.union(
|
||||||
|
v.literal('minuto'), // último minuto
|
||||||
|
v.literal('hora') // última hora
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.index('by_remetente_periodo', ['remetenteId', 'periodo', 'timestamp'])
|
||||||
|
.index('by_timestamp', ['timestamp']),
|
||||||
|
|
||||||
|
// Tabelas de Monitoramento do Sistema
|
||||||
|
systemMetrics: defineTable({
|
||||||
|
timestamp: v.number(),
|
||||||
|
// Métricas de Sistema
|
||||||
|
cpuUsage: v.optional(v.number()),
|
||||||
|
memoryUsage: v.optional(v.number()),
|
||||||
|
networkLatency: v.optional(v.number()),
|
||||||
|
storageUsed: v.optional(v.number()),
|
||||||
|
// Métricas de Aplicação
|
||||||
|
usuariosOnline: v.optional(v.number()),
|
||||||
|
mensagensPorMinuto: v.optional(v.number()),
|
||||||
|
tempoRespostaMedio: v.optional(v.number()),
|
||||||
|
errosCount: v.optional(v.number())
|
||||||
|
}).index('by_timestamp', ['timestamp']),
|
||||||
|
|
||||||
|
alertConfigurations: defineTable({
|
||||||
|
metricName: v.string(),
|
||||||
|
threshold: v.number(),
|
||||||
|
operator: v.union(
|
||||||
|
v.literal('>'),
|
||||||
|
v.literal('<'),
|
||||||
|
v.literal('>='),
|
||||||
|
v.literal('<='),
|
||||||
|
v.literal('==')
|
||||||
|
),
|
||||||
|
enabled: v.boolean(),
|
||||||
|
notifyByEmail: v.boolean(),
|
||||||
|
notifyByChat: v.boolean(),
|
||||||
|
createdBy: v.id('usuarios'),
|
||||||
|
lastModified: v.number()
|
||||||
|
}).index('by_enabled', ['enabled']),
|
||||||
|
|
||||||
|
alertHistory: defineTable({
|
||||||
|
configId: v.id('alertConfigurations'),
|
||||||
|
metricName: v.string(),
|
||||||
|
metricValue: v.number(),
|
||||||
|
threshold: v.number(),
|
||||||
|
timestamp: v.number(),
|
||||||
|
status: v.union(v.literal('triggered'), v.literal('resolved')),
|
||||||
|
notificationsSent: v.object({
|
||||||
|
email: v.boolean(),
|
||||||
|
chat: v.boolean()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.index('by_timestamp', ['timestamp'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_config', ['configId', 'timestamp']),
|
||||||
|
|
||||||
|
rateLimitConfig: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('ip'),
|
||||||
|
v.literal('usuario'),
|
||||||
|
v.literal('endpoint'),
|
||||||
|
v.literal('global')
|
||||||
|
),
|
||||||
|
identificador: v.optional(v.string()),
|
||||||
|
limite: v.number(),
|
||||||
|
janelaSegundos: v.number(),
|
||||||
|
estrategia: v.union(
|
||||||
|
v.literal('fixed_window'),
|
||||||
|
v.literal('sliding_window'),
|
||||||
|
v.literal('token_bucket')
|
||||||
|
),
|
||||||
|
acaoExcedido: v.union(v.literal('bloquear'), v.literal('throttle'), v.literal('alertar')),
|
||||||
|
bloqueioTemporarioSegundos: v.optional(v.number()),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
prioridade: v.number(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
atualizadoPor: v.optional(v.id('usuarios')),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number(),
|
||||||
|
notas: v.optional(v.string()),
|
||||||
|
tags: v.optional(v.array(v.string()))
|
||||||
|
})
|
||||||
|
.index('by_tipo_identificador', ['tipo', 'identificador'])
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_prioridade', ['prioridade']),
|
||||||
|
|
||||||
|
alertConfigs: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
canais: v.object({
|
||||||
|
email: v.boolean(),
|
||||||
|
chat: v.boolean()
|
||||||
|
}),
|
||||||
|
emails: v.array(v.string()),
|
||||||
|
chatUsers: v.array(v.string()),
|
||||||
|
severidadeMin: severidadeSeguranca,
|
||||||
|
tiposAtaque: v.optional(v.array(ataqueCiberneticoTipo)),
|
||||||
|
reenvioMin: v.number(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}).index('by_criadoEm', ['criadoEm']),
|
||||||
|
|
||||||
|
// Configurações Gerais
|
||||||
|
config: defineTable({
|
||||||
|
comprasSetorId: v.optional(v.id('setores')),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Templates de Mensagens
|
||||||
|
templatesMensagens: defineTable({
|
||||||
|
codigo: v.string(), // "USUARIO_BLOQUEADO", "SENHA_RESETADA", etc.
|
||||||
|
nome: v.string(),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('sistema'), // predefinido, não editável
|
||||||
|
v.literal('customizado') // criado por TI_MASTER
|
||||||
|
),
|
||||||
|
titulo: v.string(),
|
||||||
|
corpo: v.string(), // pode ter variáveis {{variavel}}
|
||||||
|
htmlCorpo: v.optional(v.string()), // versão HTML do corpo (com wrapper)
|
||||||
|
variaveis: v.optional(v.array(v.string())), // ["motivo", "senha", etc.]
|
||||||
|
categoria: v.optional(v.union(v.literal('email'), v.literal('chat'), v.literal('ambos'))), // categoria do template
|
||||||
|
tags: v.optional(v.array(v.string())), // tags para organização
|
||||||
|
criadoPor: v.optional(v.id('usuarios')),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_codigo', ['codigo'])
|
||||||
|
.index('by_tipo', ['tipo'])
|
||||||
|
.index('by_criado_por', ['criadoPor'])
|
||||||
|
.index('by_categoria', ['categoria']),
|
||||||
|
|
||||||
|
// Configuração de Jitsi Meet
|
||||||
|
configuracaoJitsi: defineTable({
|
||||||
|
domain: v.string(), // Domínio do servidor Jitsi (ex: "localhost:8443" ou "meet.example.com")
|
||||||
|
appId: v.string(), // ID da aplicação Jitsi
|
||||||
|
roomPrefix: v.string(), // Prefixo para nomes de salas
|
||||||
|
useHttps: v.boolean(), // Usar HTTPS
|
||||||
|
acceptSelfSignedCert: v.optional(v.boolean()), // Aceitar certificados autoassinados (útil para desenvolvimento)
|
||||||
|
ativo: v.boolean(), // Configuração ativa
|
||||||
|
testadoEm: v.optional(v.number()), // Timestamp do último teste de conexão
|
||||||
|
configuradoEm: v.optional(v.number()), // Timestamp da última configuração do servidor Docker
|
||||||
|
configuradoNoServidor: v.optional(v.boolean()), // Indica se a configuração foi aplicada no servidor
|
||||||
|
configuradoNoServidorEm: v.optional(v.number()), // Timestamp de quando foi configurado no servidor
|
||||||
|
configuradoPor: v.id('usuarios'), // Usuário que configurou
|
||||||
|
atualizadoEm: v.number(), // Timestamp de atualização
|
||||||
|
jitsiConfigPath: v.optional(v.string()), // Caminho da configuração do Jitsi no servidor (ex: "~/.jitsi-meet-cfg")
|
||||||
|
sshUsername: v.optional(v.string()), // Usuário SSH para acesso ao servidor
|
||||||
|
sshPasswordHash: v.optional(v.string()), // Hash da senha SSH (criptografada)
|
||||||
|
sshPort: v.optional(v.number()) // Porta SSH (padrão: 22)
|
||||||
|
}).index('by_ativo', ['ativo'])
|
||||||
|
};
|
||||||
165
packages/backend/convex/tables/tickets.ts
Normal file
165
packages/backend/convex/tables/tickets.ts
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const ticketsTables = {
|
||||||
|
tickets: defineTable({
|
||||||
|
numero: v.string(),
|
||||||
|
titulo: v.string(),
|
||||||
|
descricao: v.string(),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('reclamacao'),
|
||||||
|
v.literal('elogio'),
|
||||||
|
v.literal('sugestao'),
|
||||||
|
v.literal('chamado')
|
||||||
|
),
|
||||||
|
categoria: v.optional(v.string()),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('aberto'),
|
||||||
|
v.literal('em_andamento'),
|
||||||
|
v.literal('aguardando_usuario'),
|
||||||
|
v.literal('resolvido'),
|
||||||
|
v.literal('encerrado'),
|
||||||
|
v.literal('cancelado')
|
||||||
|
),
|
||||||
|
prioridade: v.union(
|
||||||
|
v.literal('baixa'),
|
||||||
|
v.literal('media'),
|
||||||
|
v.literal('alta'),
|
||||||
|
v.literal('critica')
|
||||||
|
),
|
||||||
|
solicitanteId: v.id('usuarios'),
|
||||||
|
solicitanteNome: v.string(),
|
||||||
|
solicitanteEmail: v.string(),
|
||||||
|
responsavelId: v.optional(v.id('usuarios')),
|
||||||
|
setorResponsavel: v.optional(v.string()),
|
||||||
|
slaConfigId: v.optional(v.id('slaConfigs')),
|
||||||
|
conversaId: v.optional(v.id('conversas')),
|
||||||
|
prazoResposta: v.optional(v.number()),
|
||||||
|
prazoConclusao: v.optional(v.number()),
|
||||||
|
prazoEncerramento: v.optional(v.number()),
|
||||||
|
timeline: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
etapa: v.string(),
|
||||||
|
status: v.union(
|
||||||
|
v.literal('pendente'),
|
||||||
|
v.literal('em_andamento'),
|
||||||
|
v.literal('concluido'),
|
||||||
|
v.literal('vencido')
|
||||||
|
),
|
||||||
|
prazo: v.optional(v.number()),
|
||||||
|
concluidoEm: v.optional(v.number()),
|
||||||
|
observacao: v.optional(v.string())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
alertasEmitidos: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
tipo: v.union(v.literal('resposta'), v.literal('conclusao'), v.literal('encerramento')),
|
||||||
|
emitidoEm: v.number()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
anexos: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
arquivoId: v.id('_storage'),
|
||||||
|
nome: v.optional(v.string()),
|
||||||
|
tipo: v.optional(v.string()),
|
||||||
|
tamanho: v.optional(v.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
tags: v.optional(v.array(v.string())),
|
||||||
|
canalOrigem: v.optional(v.string()),
|
||||||
|
ultimaInteracaoEm: v.number(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_numero', ['numero'])
|
||||||
|
.index('by_status', ['status'])
|
||||||
|
.index('by_solicitante', ['solicitanteId', 'status'])
|
||||||
|
.index('by_responsavel', ['responsavelId', 'status'])
|
||||||
|
.index('by_setor', ['setorResponsavel', 'status']),
|
||||||
|
|
||||||
|
ticketInteractions: defineTable({
|
||||||
|
ticketId: v.id('tickets'),
|
||||||
|
autorId: v.optional(v.id('usuarios')),
|
||||||
|
origem: v.union(v.literal('usuario'), v.literal('ti'), v.literal('sistema')),
|
||||||
|
tipo: v.union(
|
||||||
|
v.literal('mensagem'),
|
||||||
|
v.literal('status'),
|
||||||
|
v.literal('anexo'),
|
||||||
|
v.literal('alerta')
|
||||||
|
),
|
||||||
|
conteudo: v.string(),
|
||||||
|
anexos: v.optional(
|
||||||
|
v.array(
|
||||||
|
v.object({
|
||||||
|
arquivoId: v.id('_storage'),
|
||||||
|
nome: v.optional(v.string()),
|
||||||
|
tipo: v.optional(v.string()),
|
||||||
|
tamanho: v.optional(v.number())
|
||||||
|
})
|
||||||
|
)
|
||||||
|
),
|
||||||
|
statusAnterior: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('aberto'),
|
||||||
|
v.literal('em_andamento'),
|
||||||
|
v.literal('aguardando_usuario'),
|
||||||
|
v.literal('resolvido'),
|
||||||
|
v.literal('encerrado'),
|
||||||
|
v.literal('cancelado')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
statusNovo: v.optional(
|
||||||
|
v.union(
|
||||||
|
v.literal('aberto'),
|
||||||
|
v.literal('em_andamento'),
|
||||||
|
v.literal('aguardando_usuario'),
|
||||||
|
v.literal('resolvido'),
|
||||||
|
v.literal('encerrado'),
|
||||||
|
v.literal('cancelado')
|
||||||
|
)
|
||||||
|
),
|
||||||
|
visibilidade: v.union(v.literal('publico'), v.literal('interno')),
|
||||||
|
criadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_ticket', ['ticketId'])
|
||||||
|
.index('by_ticket_type', ['ticketId', 'tipo'])
|
||||||
|
.index('by_autor', ['autorId']),
|
||||||
|
|
||||||
|
slaConfigs: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
prioridade: v.optional(
|
||||||
|
v.union(v.literal('baixa'), v.literal('media'), v.literal('alta'), v.literal('critica'))
|
||||||
|
),
|
||||||
|
tempoRespostaHoras: v.number(),
|
||||||
|
tempoConclusaoHoras: v.number(),
|
||||||
|
tempoEncerramentoHoras: v.optional(v.number()),
|
||||||
|
alertaAntecedenciaHoras: v.number(),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
criadoPor: v.id('usuarios'),
|
||||||
|
atualizadoPor: v.optional(v.id('usuarios')),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
atualizadoEm: v.number()
|
||||||
|
})
|
||||||
|
.index('by_ativo', ['ativo'])
|
||||||
|
.index('by_prioridade', ['prioridade', 'ativo'])
|
||||||
|
.index('by_nome', ['nome']),
|
||||||
|
|
||||||
|
ticketAssignments: defineTable({
|
||||||
|
ticketId: v.id('tickets'),
|
||||||
|
responsavelId: v.id('usuarios'),
|
||||||
|
atribuidoPor: v.id('usuarios'),
|
||||||
|
motivo: v.optional(v.string()),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
criadoEm: v.number(),
|
||||||
|
encerradoEm: v.optional(v.number())
|
||||||
|
})
|
||||||
|
.index('by_ticket', ['ticketId', 'ativo'])
|
||||||
|
.index('by_responsavel', ['responsavelId', 'ativo'])
|
||||||
|
};
|
||||||
26
packages/backend/convex/tables/times.ts
Normal file
26
packages/backend/convex/tables/times.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { defineTable } from 'convex/server';
|
||||||
|
import { v } from 'convex/values';
|
||||||
|
|
||||||
|
export const timesTables = {
|
||||||
|
times: defineTable({
|
||||||
|
nome: v.string(),
|
||||||
|
descricao: v.optional(v.string()),
|
||||||
|
gestorId: v.id('usuarios'),
|
||||||
|
gestorSuperiorId: v.optional(v.id('usuarios')),
|
||||||
|
ativo: v.boolean(),
|
||||||
|
cor: v.optional(v.string()) // Cor para identificação visual
|
||||||
|
})
|
||||||
|
.index('by_gestor', ['gestorId'])
|
||||||
|
.index('by_gestor_superior', ['gestorSuperiorId']),
|
||||||
|
|
||||||
|
timesMembros: defineTable({
|
||||||
|
timeId: v.id('times'),
|
||||||
|
funcionarioId: v.id('funcionarios'),
|
||||||
|
dataEntrada: v.number(),
|
||||||
|
dataSaida: v.optional(v.number()),
|
||||||
|
ativo: v.boolean()
|
||||||
|
})
|
||||||
|
.index('by_time', ['timeId'])
|
||||||
|
.index('by_funcionario', ['funcionarioId'])
|
||||||
|
.index('by_time_and_ativo', ['timeId', 'ativo'])
|
||||||
|
};
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { query, mutation } from "./_generated/server";
|
|
||||||
import { v } from "convex/values";
|
|
||||||
|
|
||||||
export const getAll = query({
|
|
||||||
handler: async (ctx) => {
|
|
||||||
return await ctx.db.query("todos").collect();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const create = mutation({
|
|
||||||
args: {
|
|
||||||
text: v.string(),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
const newTodoId = await ctx.db.insert("todos", {
|
|
||||||
text: args.text,
|
|
||||||
completed: false,
|
|
||||||
});
|
|
||||||
return await ctx.db.get(newTodoId);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const toggle = mutation({
|
|
||||||
args: {
|
|
||||||
id: v.id("todos"),
|
|
||||||
completed: v.boolean(),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await ctx.db.patch(args.id, { completed: args.completed });
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export const deleteTodo = mutation({
|
|
||||||
args: {
|
|
||||||
id: v.id("todos"),
|
|
||||||
},
|
|
||||||
handler: async (ctx, args) => {
|
|
||||||
await ctx.db.delete(args.id);
|
|
||||||
return { success: true };
|
|
||||||
},
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user