feat: Implement initial pedido (order) management, product catalog, and TI configuration features.
This commit is contained in:
127
.agent/rules/convex-svelte-best-practices.md
Normal file
127
.agent/rules/convex-svelte-best-practices.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
trigger: glob
|
||||
globs: **/*.svelte.ts,**/*.svelte
|
||||
---
|
||||
|
||||
# Convex + Svelte Best Practices
|
||||
|
||||
This document outlines the mandatory rules and best practices for integrating Convex with Svelte in this project.
|
||||
|
||||
## 1. Imports
|
||||
|
||||
Always use the following import paths. Do NOT use `$lib/convex` or relative paths for generated files unless specifically required by a local override.
|
||||
|
||||
### Correct Imports:
|
||||
|
||||
```typescript
|
||||
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';
|
||||
```
|
||||
|
||||
### Incorrect Imports (Avoid):
|
||||
|
||||
```typescript
|
||||
import { convex } from '$lib/convex'; // Avoid direct client usage for queries
|
||||
import { api } from '$lib/convex/_generated/api'; // Incorrect path
|
||||
import { api } from '../convex/_generated/api'; // Relative path
|
||||
```
|
||||
|
||||
## 2. Data Fetching
|
||||
|
||||
### Use `useQuery` for Reactivity
|
||||
|
||||
Instead of manually fetching data inside `onMount`, use the `useQuery` hook. This ensures your data is reactive and automatically updates when the backend data changes.
|
||||
|
||||
**Preferred Pattern:**
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { useQuery } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
const tasksQuery = useQuery(api.tasks.list, { status: 'pending' });
|
||||
const tasks = $derived(tasksQuery.data || []);
|
||||
const isLoading = $derived(tasksQuery.isLoading);
|
||||
</script>
|
||||
```
|
||||
|
||||
**Avoid Pattern:**
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { convex } from '$lib/convex';
|
||||
|
||||
let tasks = [];
|
||||
|
||||
onMount(async () => {
|
||||
// This is not reactive!
|
||||
tasks = await convex.query(api.tasks.list, { status: 'pending' });
|
||||
});
|
||||
</script>
|
||||
```
|
||||
|
||||
### Mutations
|
||||
|
||||
Use `useConvexClient` to access the client for mutations.
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import { useConvexClient } from 'convex-svelte';
|
||||
import { api } from '@sgse-app/backend/convex/_generated/api';
|
||||
|
||||
const client = useConvexClient();
|
||||
|
||||
async function completeTask(id) {
|
||||
await client.mutation(api.tasks.complete, { id });
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## 3. Type Safety
|
||||
|
||||
### No `any`
|
||||
|
||||
Strictly avoid using `any`. The Convex generated data model provides precise types for all your tables.
|
||||
|
||||
### Use Generated Types
|
||||
|
||||
Use `Doc<"tableName">` for full document objects and `Id<"tableName">` for IDs.
|
||||
|
||||
**Correct:**
|
||||
|
||||
```typescript
|
||||
import type { Doc, Id } from '@sgse-app/backend/convex/_generated/dataModel';
|
||||
|
||||
let selectedTask: Doc<'tasks'> | null = $state(null);
|
||||
let taskId: Id<'tasks'>;
|
||||
```
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```typescript
|
||||
let selectedTask: any = $state(null);
|
||||
let taskId: string;
|
||||
```
|
||||
|
||||
### Union Types for Enums
|
||||
|
||||
When dealing with status fields or other enums, define the specific union type instead of casting to `any`.
|
||||
|
||||
**Correct:**
|
||||
|
||||
```typescript
|
||||
async function updateStatus(newStatus: 'pending' | 'completed' | 'archived') {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Incorrect:**
|
||||
|
||||
```typescript
|
||||
async function updateStatus(newStatus: string) {
|
||||
// ...
|
||||
status: newStatus as any; // Avoid this
|
||||
}
|
||||
```
|
||||
@@ -2,16 +2,16 @@
|
||||
|
||||
/** Remove all non-digit characters from string */
|
||||
export const onlyDigits = (value: string): string => {
|
||||
return (value || "").replace(/\D/g, "");
|
||||
return (value || '').replace(/\D/g, '');
|
||||
};
|
||||
|
||||
/** Format CPF: 000.000.000-00 */
|
||||
export const maskCPF = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 11);
|
||||
return digits
|
||||
.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')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d{1,2})$/, '$1-$2');
|
||||
};
|
||||
|
||||
/** Validate CPF format and checksum */
|
||||
@@ -40,17 +40,17 @@ export const validateCPF = (value: string): boolean => {
|
||||
/** Format CEP: 00000-000 */
|
||||
export const maskCEP = (value: string): string => {
|
||||
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 */
|
||||
export const maskCNPJ = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 14);
|
||||
return digits
|
||||
.replace(/(\d{2})(\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{2})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1.$2')
|
||||
.replace(/(\d{3})(\d)/, '$1/$2')
|
||||
.replace(/(\d{4})(\d{1,2})$/, '$1-$2');
|
||||
};
|
||||
|
||||
/** Format phone: (00) 0000-0000 or (00) 00000-0000 */
|
||||
@@ -58,22 +58,16 @@ export const maskPhone = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 11);
|
||||
|
||||
if (digits.length <= 10) {
|
||||
return digits
|
||||
.replace(/(\d{2})(\d)/, "($1) $2")
|
||||
.replace(/(\d{4})(\d{1,4})$/, "$1-$2");
|
||||
return digits.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 */
|
||||
export const maskDate = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 8);
|
||||
return digits
|
||||
.replace(/(\d{2})(\d)/, "$1/$2")
|
||||
.replace(/(\d{2})(\d{1,4})$/, "$1/$2");
|
||||
return digits.replace(/(\d{2})(\d)/, '$1/$2').replace(/(\d{2})(\d{1,4})$/, '$1/$2');
|
||||
};
|
||||
|
||||
/** Validate date in format dd/mm/aaaa */
|
||||
@@ -87,16 +81,15 @@ export const validateDate = (value: string): boolean => {
|
||||
|
||||
const date = new Date(year, month, day);
|
||||
|
||||
return (
|
||||
date.getFullYear() === year &&
|
||||
date.getMonth() === month &&
|
||||
date.getDate() === day
|
||||
);
|
||||
return date.getFullYear() === year && date.getMonth() === month && date.getDate() === day;
|
||||
};
|
||||
|
||||
/** Format UF: uppercase, max 2 chars */
|
||||
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 */
|
||||
@@ -126,14 +119,14 @@ const rgFormatByUF: Record<string, [number, number, number, number]> = {
|
||||
MS: [2, 3, 3, 1],
|
||||
RO: [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 => {
|
||||
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 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 g1 = baseDigits.slice(0, a);
|
||||
@@ -149,17 +142,17 @@ export const maskRGByUF = (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 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);
|
||||
|
||||
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 */
|
||||
@@ -179,8 +172,21 @@ export const maskNumeric = (value: string): string => {
|
||||
return onlyDigits(value);
|
||||
};
|
||||
|
||||
/** Remove extra spaces and trim */
|
||||
export const normalizeText = (value: string): string => {
|
||||
return (value || "").replace(/\s+/g, " ").trim();
|
||||
/** 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 */
|
||||
export const normalizeText = (value: string): string => {
|
||||
return (value || '').replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ShoppingCart, ShoppingBag, Plus } from "lucide-svelte";
|
||||
import { ShoppingCart, Package, FileText } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
@@ -25,22 +25,40 @@
|
||||
</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">
|
||||
<a
|
||||
href={resolve('/compras/produtos')}
|
||||
class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow border border-base-200 hover:border-primary"
|
||||
>
|
||||
<div class="card-body">
|
||||
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-6">
|
||||
<ShoppingBag class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
||||
<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>
|
||||
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||
<p class="text-base-content/70 max-w-md mb-6">
|
||||
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.
|
||||
<h4 class="font-semibold">Produtos</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">
|
||||
Cadastro, listagem e edição de produtos e serviços disponíveis para compra.
|
||||
</p>
|
||||
<div class="badge badge-warning badge-lg gap-2">
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</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>
|
||||
</main>
|
||||
</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">
|
||||
import { Trophy, Award, Building2, Workflow } from "lucide-svelte";
|
||||
import { Trophy, Award, Building2, Workflow, Target } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
@@ -43,6 +43,23 @@
|
||||
</div>
|
||||
</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-body">
|
||||
<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>
|
||||
@@ -367,6 +367,15 @@
|
||||
palette: 'accent',
|
||||
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',
|
||||
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>
|
||||
8
packages/backend/convex/_generated/api.d.ts
vendored
8
packages/backend/convex/_generated/api.d.ts
vendored
@@ -8,6 +8,7 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as acoes from "../acoes.js";
|
||||
import type * as actions_email from "../actions/email.js";
|
||||
import type * as actions_linkPreview from "../actions/linkPreview.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 chamados from "../chamados.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 configuracaoJitsi from "../configuracaoJitsi.js";
|
||||
import type * as configuracaoPonto from "../configuracaoPonto.js";
|
||||
@@ -43,9 +45,11 @@ import type * as logsAcesso from "../logsAcesso.js";
|
||||
import type * as logsAtividades from "../logsAtividades.js";
|
||||
import type * as logsLogin from "../logsLogin.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 pontos from "../pontos.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 roles from "../roles.js";
|
||||
import type * as saldoFerias from "../saldoFerias.js";
|
||||
@@ -67,6 +71,7 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
acoes: typeof acoes;
|
||||
"actions/email": typeof actions_email;
|
||||
"actions/linkPreview": typeof actions_linkPreview;
|
||||
"actions/pushNotifications": typeof actions_pushNotifications;
|
||||
@@ -80,6 +85,7 @@ declare const fullApi: ApiFromModules<{
|
||||
chamadas: typeof chamadas;
|
||||
chamados: typeof chamados;
|
||||
chat: typeof chat;
|
||||
config: typeof config;
|
||||
configuracaoEmail: typeof configuracaoEmail;
|
||||
configuracaoJitsi: typeof configuracaoJitsi;
|
||||
configuracaoPonto: typeof configuracaoPonto;
|
||||
@@ -102,9 +108,11 @@ declare const fullApi: ApiFromModules<{
|
||||
logsAtividades: typeof logsAtividades;
|
||||
logsLogin: typeof logsLogin;
|
||||
monitoramento: typeof monitoramento;
|
||||
pedidos: typeof pedidos;
|
||||
permissoesAcoes: typeof permissoesAcoes;
|
||||
pontos: typeof pontos;
|
||||
preferenciasNotificacao: typeof preferenciasNotificacao;
|
||||
produtos: typeof produtos;
|
||||
pushNotifications: typeof pushNotifications;
|
||||
roles: typeof roles;
|
||||
saldoFerias: typeof saldoFerias;
|
||||
|
||||
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()
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
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',
|
||||
acao: 'excluir',
|
||||
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;
|
||||
|
||||
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
Reference in New Issue
Block a user