Feat licitacoes contratos #30

Merged
killer-cf merged 7 commits from feat-licitacoes-contratos into master 2025-11-19 12:31:19 +00:00
33 changed files with 2761 additions and 6041 deletions
Showing only changes of commit b8506b6d45 - Show all commits

View File

@@ -1,78 +1,79 @@
<script lang="ts">
import { FileText, ClipboardCopy, Building2 } from "lucide-svelte";
import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
import { FileText, ClipboardCopy, Building2 } from 'lucide-svelte';
import { resolve } from '$app/paths';
import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
</script>
<ProtectedRoute>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li>Licitações</li>
</ul>
</div>
<main class="container mx-auto px-4 py-4">
<!-- Breadcrumb -->
<div class="breadcrumbs mb-4 text-sm">
<ul>
<li>
<a href={resolve('/')} class="text-primary hover:underline">Dashboard</a>
</li>
<li>Licitações</li>
</ul>
</div>
<!-- Cabeçalho -->
<div class="mb-6">
<div class="flex items-center gap-4 mb-2">
<div class="p-3 bg-orange-500/20 rounded-xl">
<FileText class="h-8 w-8 text-orange-600" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Licitações</h1>
<p class="text-base-content/70">Gestão de processos licitatórios</p>
</div>
</div>
</div>
<div class="grid gap-4 md:grid-cols-3">
<a
href={resolve('/licitacoes/empresas')}
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<Building2 class="text-primary h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Empresas</h4>
</div>
<p class="text-base-content/70 text-sm">
Cadastro, listagem e edição de empresas e seus contatos.
</p>
</div>
</a>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
href={resolve('/licitacoes/empresas')}
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 items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<Building2 class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Empresas</h4>
</div>
<p class="text-sm text-base-content/70">
Cadastro, listagem e edição de empresas e seus contatos.
</p>
</div>
</a>
<a
href={resolve('/licitacoes/contratos')}
class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
>
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-primary/10 rounded-lg p-2">
<FileText class="text-primary h-6 w-6" strokeWidth={2} />
</div>
<h4 class="font-semibold">Contratos</h4>
</div>
<p class="text-base-content/70 text-sm">Gestão de contratos, vigências e situações.</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">
<div class="p-2 bg-base-200 rounded-lg">
<ClipboardCopy class="h-6 w-6 text-base-content/50" strokeWidth={2} />
</div>
<h4 class="font-semibold text-base-content/70">Processos Licitatórios</h4>
</div>
<p class="text-sm text-base-content/60">
Em breve: cadastro e acompanhamento de licitações.
</p>
</div>
</div>
<div class="card bg-base-100 opacity-70 shadow-md">
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-base-200 rounded-lg p-2">
<ClipboardCopy class="text-base-content/50 h-6 w-6" strokeWidth={2} />
</div>
<h4 class="text-base-content/70 font-semibold">Processos Licitatórios</h4>
</div>
<p class="text-base-content/60 text-sm">
Em breve: cadastro e acompanhamento de licitações.
</p>
</div>
</div>
<div class="card bg-base-100 shadow-md opacity-70">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-base-200 rounded-lg">
<FileText class="h-6 w-6 text-base-content/50" strokeWidth={2} />
</div>
<h4 class="font-semibold text-base-content/70">Documentação</h4>
</div>
<p class="text-sm text-base-content/60">
Em breve: gestão de documentos e editais.
</p>
</div>
</div>
</div>
</main>
<div class="card bg-base-100 opacity-70 shadow-md">
<div class="card-body">
<div class="mb-2 flex items-center gap-3">
<div class="bg-base-200 rounded-lg p-2">
<FileText class="text-base-content/50 h-6 w-6" strokeWidth={2} />
</div>
<h4 class="text-base-content/70 font-semibold">Documentação</h4>
</div>
<p class="text-base-content/60 text-sm">Em breve: gestão de documentos e editais.</p>
</div>
</div>
</div>
</main>
</ProtectedRoute>

View File

@@ -0,0 +1,244 @@
<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';
import { Plus, AlertTriangle } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { format } from 'date-fns';
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
// Client para mutations
const client = useConvexClient();
// Autenticação
$effect(() => {
useConvexWithAuth();
});
// Filtros
let responsavelId = $state<Id<'funcionarios'> | undefined>(undefined);
let dataInicio = $state<string | undefined>(undefined);
let dataFim = $state<string | undefined>(undefined);
// Queries
const contratosQuery = useQuery(api.contratos.listar, () => ({
responsavelId: responsavelId,
dataInicio,
dataFim
}));
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
// Derivados para facilitar acesso aos dados
const contratos = $derived.by(() => {
if (contratosQuery === undefined || contratosQuery === null) return [];
if (Array.isArray(contratosQuery)) return contratosQuery;
if ('data' in contratosQuery) return contratosQuery.data || [];
return [];
});
const funcionarios = $derived.by(() => {
if (funcionariosQuery === undefined || funcionariosQuery === null) return [];
if (Array.isArray(funcionariosQuery)) return funcionariosQuery;
if ('data' in funcionariosQuery) return funcionariosQuery.data || [];
return [];
});
const isLoading = $derived(contratosQuery === undefined);
const error = $derived(contratosQuery instanceof Error ? contratosQuery : null);
// Helpers
function formatarData(data: string) {
if (!data) return '-';
return format(new Date(data), 'dd/MM/yyyy');
}
function formatarMoeda(valor: string) {
if (!valor) return '-';
return new Intl.NumberFormat('pt-BR', {
style: 'currency',
currency: 'BRL'
}).format(parseFloat(valor));
}
async function handleDelete(id: Id<'contratos'>) {
if (confirm('Tem certeza que deseja excluir este contrato?')) {
await client.mutation(api.contratos.excluir, { id });
}
}
// Verificar vencimento (lógica visual simples baseada na data de fim)
function isProximoVencimento(dataFim: string, diasAviso: number) {
if (!dataFim) return false;
const fim = new Date(dataFim).getTime();
const hoje = Date.now();
const aviso = fim - diasAviso * 24 * 60 * 60 * 1000;
return hoje >= aviso && hoje <= fim;
}
</script>
<div class="flex flex-col gap-6 p-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold tracking-tight">Contratos</h1>
<p class="text-muted-foreground">Gerencie os contratos, vigências e situações.</p>
</div>
<button class="btn btn-primary" onclick={() => goto(resolve('/licitacoes/contratos/novo'))}>
<Plus class="mr-2 h-4 w-4" />
Novo Contrato
</button>
</div>
<!-- Filtros -->
<div class="bg-base-100 grid gap-4 rounded-lg border p-4 shadow-sm md:grid-cols-4">
<div class="form-control w-full">
<label class="label" for="filtroResponsavel">
<span class="label-text">Responsável</span>
</label>
<select
id="filtroResponsavel"
class="select select-bordered w-full"
bind:value={responsavelId}
>
<option value={undefined}>Todos</option>
{#each funcionarios as func (func._id)}
<option value={func._id}>{func.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="filtroDataInicio">
<span class="label-text">Vigência Início (A partir de)</span>
</label>
<input
id="filtroDataInicio"
type="date"
class="input input-bordered w-full"
bind:value={dataInicio}
/>
</div>
<div class="form-control w-full">
<label class="label" for="filtroDataFim">
<span class="label-text">Vigência Fim (Até)</span>
</label>
<input
id="filtroDataFim"
type="date"
class="input input-bordered w-full"
bind:value={dataFim}
/>
</div>
<div class="flex items-end">
<button
class="btn btn-outline w-full"
onclick={() => {
responsavelId = undefined;
dataInicio = undefined;
dataFim = undefined;
}}
>
Limpar Filtros
</button>
</div>
</div>
<!-- Tabela -->
<div class="bg-base-100 overflow-x-auto rounded-md border">
<table class="table w-full">
<thead>
<tr>
<th>Nº Contrato</th>
<th>Objeto</th>
<th>Contratada</th>
<th>Vigência</th>
<th>Valor</th>
<th>Situação</th>
<th>Responsável</th>
<th class="text-right">Ações</th>
</tr>
</thead>
<tbody>
{#if isLoading}
<tr>
<td colspan="8" class="h-24 text-center">
<span class="loading loading-spinner loading-lg"></span>
</td>
</tr>
{:else if error}
<tr>
<td colspan="8" class="text-error h-24 text-center">
Erro ao carregar contratos: {error.message}
</td>
</tr>
{:else if contratos.length === 0}
<tr>
<td colspan="8" class="text-base-content/70 h-24 text-center">
Nenhum contrato encontrado.
</td>
</tr>
{:else}
{#each contratos as contrato (contrato._id)}
<tr>
<td class="font-medium">
<div class="flex items-center gap-2">
{contrato.numeroContrato}/{contrato.anoContrato}
{#if isProximoVencimento(contrato.dataFimVigencia, contrato.diasAvisoVencimento)}
<div class="tooltip" data-tip="Próximo do vencimento">
<AlertTriangle class="text-warning h-4 w-4" />
</div>
{/if}
</div>
</td>
<td class="max-w-[200px] truncate" title={contrato.objeto}>
{contrato.objeto}
</td>
<td>
{contrato.contratada?.razao_social || 'Empresa não encontrada'}
</td>
<td>
<div class="text-xs">
{formatarData(contrato.dataInicioVigencia)} até
<br />
{formatarData(contrato.dataFimVigencia)}
</div>
</td>
<td>{formatarMoeda(contrato.valorTotal)}</td>
<td>
<div
class="badge gap-2
{contrato.situacao === 'em_execucao'
? 'badge-success'
: contrato.situacao === 'rescendido'
? 'badge-error'
: contrato.situacao === 'aguardando_assinatura'
? 'badge-warning'
: 'badge-ghost'}"
>
{contrato.situacao.replace('_', ' ').toUpperCase()}
</div>
</td>
<td>
{contrato.responsavel?.nome || '-'}
</td>
<td class="text-right">
<button
class="btn btn-ghost btn-xs"
onclick={() => goto(resolve(`/licitacoes/contratos/${contrato._id}`))}
>
Editar
</button>
<button
class="btn btn-ghost btn-xs text-error"
onclick={() => handleDelete(contrato._id)}
>
Excluir
</button>
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
</div>
</div>

View File

@@ -0,0 +1,394 @@
<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';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/stores';
import { ArrowLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
// Client para mutations
const client = useConvexClient();
// Autenticação
$effect(() => {
useConvexWithAuth();
});
const contratoId = $page.params.id as Id<'contratos'>;
// Queries
const contratoQuery = useQuery(api.contratos.obter, { id: contratoId });
const empresasQuery = useQuery(api.empresas.list, {});
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
// Derivados
const contrato = $derived(contratoQuery.data);
const empresas = $derived(empresasQuery.data || []);
const funcionarios = $derived(funcionariosQuery.data || []);
const isLoading = $derived(contratoQuery.isLoading);
const error = $derived(contratoQuery.error);
type SituacaoContrato = 'em_execucao' | 'rescendido' | 'aguardando_assinatura' | 'finalizado';
type ContratoForm = {
contratadaId: string | null;
objeto: string;
numeroNotaEmpenho: string;
responsavelId: string | null;
departamento: string;
situacao: SituacaoContrato;
numeroProcessoLicitatorio: string;
modalidade: string;
numeroContrato: string;
anoContrato: number;
dataInicioVigencia: string;
dataFimVigencia: string;
nomeFiscal: string;
valorTotal: string;
dataAditivoPrazo: string;
diasAvisoVencimento: number;
};
// Estado do formulário
let loading = $state(false);
let formData = $state<ContratoForm>({
contratadaId: null,
objeto: '',
numeroNotaEmpenho: '',
responsavelId: null,
departamento: '',
situacao: 'aguardando_assinatura',
numeroProcessoLicitatorio: '',
modalidade: '',
numeroContrato: '',
anoContrato: new Date().getFullYear(),
dataInicioVigencia: '',
dataFimVigencia: '',
nomeFiscal: '',
valorTotal: '',
dataAditivoPrazo: '',
diasAvisoVencimento: 30
});
// Carregar dados quando a query retornar
$effect(() => {
if (contrato) {
formData = {
contratadaId: contrato.contratadaId,
objeto: contrato.objeto,
numeroNotaEmpenho: contrato.numeroNotaEmpenho,
responsavelId: contrato.responsavelId,
departamento: contrato.departamento,
situacao: contrato.situacao,
numeroProcessoLicitatorio: contrato.numeroProcessoLicitatorio,
modalidade: contrato.modalidade,
numeroContrato: contrato.numeroContrato,
anoContrato: contrato.anoContrato,
dataInicioVigencia: contrato.dataInicioVigencia,
dataFimVigencia: contrato.dataFimVigencia,
nomeFiscal: contrato.nomeFiscal,
valorTotal: contrato.valorTotal,
dataAditivoPrazo: contrato.dataAditivoPrazo || '',
diasAvisoVencimento: contrato.diasAvisoVencimento
};
}
});
async function handleSubmit() {
if (!formData.contratadaId || !formData.responsavelId) {
toast.error('Selecione a empresa e o responsável.');
return;
}
try {
loading = true;
await client.mutation(api.contratos.editar, {
id: contratoId,
contratadaId: formData.contratadaId as Id<'empresas'>,
objeto: formData.objeto,
numeroNotaEmpenho: formData.numeroNotaEmpenho,
responsavelId: formData.responsavelId as Id<'funcionarios'>,
departamento: formData.departamento,
situacao: formData.situacao,
numeroProcessoLicitatorio: formData.numeroProcessoLicitatorio,
modalidade: formData.modalidade,
numeroContrato: formData.numeroContrato,
anoContrato: Number(formData.anoContrato),
dataInicioVigencia: formData.dataInicioVigencia,
dataFimVigencia: formData.dataFimVigencia,
nomeFiscal: formData.nomeFiscal,
valorTotal: formData.valorTotal,
dataAditivoPrazo: formData.dataAditivoPrazo || undefined,
diasAvisoVencimento: Number(formData.diasAvisoVencimento)
});
toast.success('Contrato atualizado com sucesso!');
goto(resolve('/licitacoes/contratos'));
} catch (error) {
console.error(error);
if (error instanceof Error) {
toast.error('Erro ao atualizar contrato: ' + error.message);
} else {
toast.error('Erro ao atualizar contrato: ' + String(error));
}
} finally {
loading = false;
}
}
</script>
<div class="mx-auto flex max-w-4xl flex-col gap-6 p-6">
<div class="flex items-center gap-4">
<button
class="btn btn-ghost btn-square"
onclick={() => goto(resolve('/licitacoes/contratos'))}
>
<ArrowLeft class="h-4 w-4" />
</button>
<div>
<h1 class="text-3xl font-bold tracking-tight">Editar Contrato</h1>
<p class="text-muted-foreground">Atualize os dados do contrato.</p>
</div>
</div>
{#if isLoading}
<div class="flex justify-center p-8">
<span class="loading loading-spinner loading-lg text-base-content/50"></span>
</div>
{:else if error}
<div class="alert alert-error">
<span>Erro ao carregar contrato: {error.message}</span>
</div>
{:else if contrato}
<div class="bg-base-100 grid gap-6 rounded-lg border p-6 shadow-sm">
<!-- Dados Básicos -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for="numeroContrato">
<span class="label-text">Número do Contrato</span>
</label>
<input
id="numeroContrato"
class="input input-bordered w-full"
bind:value={formData.numeroContrato}
placeholder="Ex: 001"
required
/>
</div>
<div class="form-control w-full">
<label class="label" for="anoContrato">
<span class="label-text">Ano do Contrato</span>
</label>
<input
id="anoContrato"
type="number"
class="input input-bordered w-full"
bind:value={formData.anoContrato}
required
/>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label" for="objeto">
<span class="label-text">Objeto</span>
</label>
<textarea
id="objeto"
class="textarea textarea-bordered w-full"
bind:value={formData.objeto}
placeholder="Descrição do objeto do contrato"
required
></textarea>
</div>
</div>
<!-- Partes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for="contratadaId">
<span class="label-text">Empresa Contratada</span>
</label>
<select
id="contratadaId"
class="select select-bordered w-full"
bind:value={formData.contratadaId}
>
<option value={null} disabled>Selecione a empresa...</option>
{#each empresas as emp (emp._id)}
<option value={emp._id}>{emp.razao_social}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="responsavelId">
<span class="label-text">Responsável (Fiscal)</span>
</label>
<select
id="responsavelId"
class="select select-bordered w-full"
bind:value={formData.responsavelId}
>
<option value={null} disabled>Selecione o responsável...</option>
{#each funcionarios as func (func._id)}
<option value={func._id}>{func.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="nomeFiscal">
<span class="label-text">Nome do Fiscal (Texto)</span>
</label>
<input
id="nomeFiscal"
class="input input-bordered w-full"
bind:value={formData.nomeFiscal}
placeholder="Nome completo do fiscal"
/>
</div>
<div class="form-control w-full">
<label class="label" for="departamento">
<span class="label-text">Departamento</span>
</label>
<input
id="departamento"
class="input input-bordered w-full"
bind:value={formData.departamento}
placeholder="Ex: TI, Administrativo"
/>
</div>
</div>
<!-- Detalhes Administrativos -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full">
<label class="label" for="numeroProcessoLicitatorio">
<span class="label-text">Processo Licitatório</span>
</label>
<input
id="numeroProcessoLicitatorio"
class="input input-bordered w-full"
bind:value={formData.numeroProcessoLicitatorio}
placeholder="Nº do processo"
/>
</div>
<div class="form-control w-full">
<label class="label" for="modalidade">
<span class="label-text">Modalidade</span>
</label>
<input
id="modalidade"
class="input input-bordered w-full"
bind:value={formData.modalidade}
placeholder="Ex: Pregão Eletrônico"
/>
</div>
<div class="form-control w-full">
<label class="label" for="numeroNotaEmpenho">
<span class="label-text">Nota de Empenho</span>
</label>
<input
id="numeroNotaEmpenho"
class="input input-bordered w-full"
bind:value={formData.numeroNotaEmpenho}
placeholder="Nº da nota"
/>
</div>
</div>
<!-- Valores e Prazos -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full">
<label class="label" for="valorTotal">
<span class="label-text">Valor Total (R$)</span>
</label>
<input
id="valorTotal"
class="input input-bordered w-full"
bind:value={formData.valorTotal}
placeholder="0,00"
/>
</div>
<div class="form-control w-full">
<label class="label" for="dataInicioVigencia">
<span class="label-text">Início Vigência</span>
</label>
<input
id="dataInicioVigencia"
type="date"
class="input input-bordered w-full"
bind:value={formData.dataInicioVigencia}
required
/>
</div>
<div class="form-control w-full">
<label class="label" for="dataFimVigencia">
<span class="label-text">Fim Vigência</span>
</label>
<input
id="dataFimVigencia"
type="date"
class="input input-bordered w-full"
bind:value={formData.dataFimVigencia}
required
/>
</div>
</div>
<!-- Situação e Alertas -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for="situacao">
<span class="label-text">Situação</span>
</label>
<select
id="situacao"
class="select select-bordered w-full"
bind:value={formData.situacao}
>
<option value="aguardando_assinatura">Aguardando Assinatura</option>
<option value="em_execucao">Em Execução</option>
<option value="rescendido">Rescindido</option>
<option value="finalizado">Finalizado</option>
</select>
</div>
<div class="form-control w-full">
<label class="label" for="diasAvisoVencimento">
<span class="label-text">Aviso de Vencimento (dias antes)</span>
</label>
<input
id="diasAvisoVencimento"
type="number"
class="input input-bordered w-full"
bind:value={formData.diasAvisoVencimento}
min="1"
/>
</div>
<div class="form-control w-full">
<label class="label" for="dataAditivoPrazo">
<span class="label-text">Data Aditivo Prazo (Opcional)</span>
</label>
<input
id="dataAditivoPrazo"
type="date"
class="input input-bordered w-full"
bind:value={formData.dataAditivoPrazo}
/>
</div>
</div>
<div class="flex justify-end gap-4 pt-4">
<button
class="btn btn-outline"
onclick={() => goto(resolve('/licitacoes/contratos'))}>Cancelar</button
>
<button class="btn btn-primary" onclick={handleSubmit} disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
Salvar Alterações
</button>
</div>
</div>
{/if}
</div>

View File

@@ -0,0 +1,363 @@
<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';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { ArrowLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { useConvexWithAuth } from '$lib/hooks/useConvexWithAuth';
// Client para mutations
const client = useConvexClient();
// Autenticação
$effect(() => {
useConvexWithAuth();
});
// Queries para selects
const empresasQuery = useQuery(api.empresas.list, {});
const funcionariosQuery = useQuery(api.funcionarios.getAll, {});
// Derivados
const empresas = $derived.by(() => {
if (empresasQuery === undefined || empresasQuery === null) return [];
if (Array.isArray(empresasQuery)) return empresasQuery;
if ('data' in empresasQuery) return empresasQuery.data || [];
return [];
});
const funcionarios = $derived.by(() => {
if (funcionariosQuery === undefined || funcionariosQuery === null) return [];
if (Array.isArray(funcionariosQuery)) return funcionariosQuery;
if ('data' in funcionariosQuery) return funcionariosQuery.data || [];
return [];
});
type SituacaoContrato = 'em_execucao' | 'rescendido' | 'aguardando_assinatura' | 'finalizado';
type ContratoForm = {
contratadaId: string | null;
objeto: string;
numeroNotaEmpenho: string;
responsavelId: string | null;
departamento: string;
situacao: SituacaoContrato;
numeroProcessoLicitatorio: string;
modalidade: string;
numeroContrato: string;
anoContrato: number;
dataInicioVigencia: string;
dataFimVigencia: string;
nomeFiscal: string;
valorTotal: string;
dataAditivoPrazo: string;
diasAvisoVencimento: number;
};
// Estado do formulário
let loading = $state(false);
let formData = $state<ContratoForm>({
contratadaId: null,
objeto: '',
numeroNotaEmpenho: '',
responsavelId: null,
departamento: '',
situacao: 'aguardando_assinatura',
numeroProcessoLicitatorio: '',
modalidade: '',
numeroContrato: '',
anoContrato: new Date().getFullYear(),
dataInicioVigencia: '',
dataFimVigencia: '',
nomeFiscal: '',
valorTotal: '',
dataAditivoPrazo: '',
diasAvisoVencimento: 30
});
async function handleSubmit() {
if (!formData.contratadaId || !formData.responsavelId) {
toast.error('Selecione a empresa e o responsável.');
return;
}
try {
loading = true;
await client.mutation(api.contratos.criar, {
contratadaId: formData.contratadaId as Id<'empresas'>,
objeto: formData.objeto,
numeroNotaEmpenho: formData.numeroNotaEmpenho,
responsavelId: formData.responsavelId as Id<'funcionarios'>,
departamento: formData.departamento,
situacao: formData.situacao,
numeroProcessoLicitatorio: formData.numeroProcessoLicitatorio,
modalidade: formData.modalidade,
numeroContrato: formData.numeroContrato,
anoContrato: Number(formData.anoContrato),
dataInicioVigencia: formData.dataInicioVigencia,
dataFimVigencia: formData.dataFimVigencia,
nomeFiscal: formData.nomeFiscal,
valorTotal: formData.valorTotal,
dataAditivoPrazo: formData.dataAditivoPrazo || undefined,
diasAvisoVencimento: Number(formData.diasAvisoVencimento)
});
toast.success('Contrato criado com sucesso!');
goto(resolve('/licitacoes/contratos'));
} catch (error) {
console.error(error);
if (error instanceof Error) {
toast.error('Erro ao criar contrato: ' + error.message);
} else {
toast.error('Erro ao criar contrato: ' + String(error));
}
} finally {
loading = false;
}
}
</script>
<div class="mx-auto flex max-w-4xl flex-col gap-6 p-6">
<div class="flex items-center gap-4">
<button
class="btn btn-ghost btn-square"
onclick={() => goto(resolve('/licitacoes/contratos'))}
>
<ArrowLeft class="h-4 w-4" />
</button>
<div>
<h1 class="text-3xl font-bold tracking-tight">Novo Contrato</h1>
<p class="text-muted-foreground">Preencha os dados do novo contrato.</p>
</div>
</div>
<div class="bg-base-100 grid gap-6 rounded-lg border p-6 shadow-sm">
<!-- Dados Básicos -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for="numeroContrato">
<span class="label-text">Número do Contrato</span>
</label>
<input
id="numeroContrato"
class="input input-bordered w-full"
bind:value={formData.numeroContrato}
placeholder="Ex: 001"
required
/>
</div>
<div class="form-control w-full">
<label class="label" for="anoContrato">
<span class="label-text">Ano do Contrato</span>
</label>
<input
id="anoContrato"
type="number"
class="input input-bordered w-full"
bind:value={formData.anoContrato}
required
/>
</div>
<div class="form-control w-full md:col-span-2">
<label class="label" for="objeto">
<span class="label-text">Objeto</span>
</label>
<textarea
id="objeto"
class="textarea textarea-bordered w-full"
bind:value={formData.objeto}
placeholder="Descrição do objeto do contrato"
required
></textarea>
</div>
</div>
<!-- Partes -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for="contratadaId">
<span class="label-text">Empresa Contratada</span>
</label>
<select
id="contratadaId"
class="select select-bordered w-full"
bind:value={formData.contratadaId}
>
<option value={null} disabled selected>Selecione a empresa...</option>
{#each empresas as emp (emp._id)}
<option value={emp._id}>{emp.razao_social}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="responsavelId">
<span class="label-text">Responsável (Fiscal)</span>
</label>
<select
id="responsavelId"
class="select select-bordered w-full"
bind:value={formData.responsavelId}
>
<option value={null} disabled selected>Selecione o responsável...</option>
{#each funcionarios as func (func._id)}
<option value={func._id}>{func.nome}</option>
{/each}
</select>
</div>
<div class="form-control w-full">
<label class="label" for="nomeFiscal">
<span class="label-text">Nome do Fiscal (Texto)</span>
</label>
<input
id="nomeFiscal"
class="input input-bordered w-full"
bind:value={formData.nomeFiscal}
placeholder="Nome completo do fiscal"
/>
</div>
<div class="form-control w-full">
<label class="label" for="departamento">
<span class="label-text">Departamento</span>
</label>
<input
id="departamento"
class="input input-bordered w-full"
bind:value={formData.departamento}
placeholder="Ex: TI, Administrativo"
/>
</div>
</div>
<!-- Detalhes Administrativos -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full">
<label class="label" for="numeroProcessoLicitatorio">
<span class="label-text">Processo Licitatório</span>
</label>
<input
id="numeroProcessoLicitatorio"
class="input input-bordered w-full"
bind:value={formData.numeroProcessoLicitatorio}
placeholder="Nº do processo"
/>
</div>
<div class="form-control w-full">
<label class="label" for="modalidade">
<span class="label-text">Modalidade</span>
</label>
<input
id="modalidade"
class="input input-bordered w-full"
bind:value={formData.modalidade}
placeholder="Ex: Pregão Eletrônico"
/>
</div>
<div class="form-control w-full">
<label class="label" for="numeroNotaEmpenho">
<span class="label-text">Nota de Empenho</span>
</label>
<input
id="numeroNotaEmpenho"
class="input input-bordered w-full"
bind:value={formData.numeroNotaEmpenho}
placeholder="Nº da nota"
/>
</div>
</div>
<!-- Valores e Prazos -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
<div class="form-control w-full">
<label class="label" for="valorTotal">
<span class="label-text">Valor Total (R$)</span>
</label>
<input
id="valorTotal"
class="input input-bordered w-full"
bind:value={formData.valorTotal}
placeholder="0,00"
/>
</div>
<div class="form-control w-full">
<label class="label" for="dataInicioVigencia">
<span class="label-text">Início Vigência</span>
</label>
<input
id="dataInicioVigencia"
type="date"
class="input input-bordered w-full"
bind:value={formData.dataInicioVigencia}
required
/>
</div>
<div class="form-control w-full">
<label class="label" for="dataFimVigencia">
<span class="label-text">Fim Vigência</span>
</label>
<input
id="dataFimVigencia"
type="date"
class="input input-bordered w-full"
bind:value={formData.dataFimVigencia}
required
/>
</div>
</div>
<!-- Situação e Alertas -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div class="form-control w-full">
<label class="label" for="situacao">
<span class="label-text">Situação</span>
</label>
<select
id="situacao"
class="select select-bordered w-full"
bind:value={formData.situacao}
>
<option value="aguardando_assinatura">Aguardando Assinatura</option>
<option value="em_execucao">Em Execução</option>
<option value="rescendido">Rescindido</option>
<option value="finalizado">Finalizado</option>
</select>
</div>
<div class="form-control w-full">
<label class="label" for="diasAvisoVencimento">
<span class="label-text">Aviso de Vencimento (dias antes)</span>
</label>
<input
id="diasAvisoVencimento"
type="number"
class="input input-bordered w-full"
bind:value={formData.diasAvisoVencimento}
min="1"
/>
</div>
<div class="form-control w-full">
<label class="label" for="dataAditivoPrazo">
<span class="label-text">Data Aditivo Prazo (Opcional)</span>
</label>
<input
id="dataAditivoPrazo"
type="date"
class="input input-bordered w-full"
bind:value={formData.dataAditivoPrazo}
/>
</div>
</div>
<div class="flex justify-end gap-4 pt-4">
<button
class="btn btn-outline"
onclick={() => goto(resolve('/licitacoes/contratos'))}>Cancelar</button
>
<button class="btn btn-primary" onclick={handleSubmit} disabled={loading}>
{#if loading}
<span class="loading loading-spinner loading-sm mr-2"></span>
{/if}
Criar Contrato
</button>
</div>
</div>
</div>

View File

@@ -21,6 +21,7 @@ import type * as auth_utils from "../auth/utils.js";
import type * as chamados from "../chamados.js";
import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as contratos from "../contratos.js";
import type * as crons from "../crons.js";
import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js";
@@ -71,6 +72,7 @@ declare const fullApi: ApiFromModules<{
chamados: typeof chamados;
chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail;
contratos: typeof contratos;
crons: typeof crons;
cursos: typeof cursos;
dashboard: typeof dashboard;

View File

@@ -0,0 +1,200 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
import { situacaoContrato } from "./schema";
import { getCurrentUserFunction } from "./auth";
import { internal } from "./_generated/api";
export const listar = query({
args: {
responsavelId: v.optional(v.id("funcionarios")),
dataInicio: v.optional(v.string()),
dataFim: v.optional(v.string()),
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "contratos",
acao: "listar",
});
let q = ctx.db.query("contratos");
if (args.responsavelId) {
q = q.withIndex("by_responsavel", (q) =>
q.eq("responsavelId", args.responsavelId!)
) as typeof q;
}
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)
// Se o volume for muito grande, ideal seria criar índices específicos ou usar search.
let resultado = contratos;
if (args.dataInicio) {
resultado = resultado.filter(
(c) => c.dataInicioVigencia >= args.dataInicio!
);
}
if (args.dataFim) {
resultado = resultado.filter((c) => c.dataFimVigencia <= args.dataFim!);
}
// Enriquecer com dados relacionados
const contratosEnriquecidos = await Promise.all(
resultado.map(async (c) => {
const contratada = await ctx.db.get(c.contratadaId);
const responsavel = await ctx.db.get(c.responsavelId);
return {
...c,
contratada,
responsavel,
};
})
);
return contratosEnriquecidos;
},
});
export const obter = query({
args: { id: v.id("contratos") },
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "contratos",
acao: "ver",
});
const contrato = await ctx.db.get(args.id);
if (!contrato) return null;
const contratada = await ctx.db.get(contrato.contratadaId);
const responsavel = await ctx.db.get(contrato.responsavelId);
return {
...contrato,
contratada,
responsavel,
};
},
});
export const criar = mutation({
args: {
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(),
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "contratos",
acao: "criar",
});
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) throw new Error("Não autenticado");
const id = await ctx.db.insert("contratos", {
...args,
criadoPor: usuario._id,
criadoEm: Date.now(),
});
return id;
},
});
export const editar = mutation({
args: {
id: v.id("contratos"),
contratadaId: v.optional(v.id("empresas")),
objeto: v.optional(v.string()),
numeroNotaEmpenho: v.optional(v.string()),
responsavelId: v.optional(v.id("funcionarios")),
departamento: v.optional(v.string()),
situacao: v.optional(situacaoContrato),
numeroProcessoLicitatorio: v.optional(v.string()),
modalidade: v.optional(v.string()),
numeroContrato: v.optional(v.string()),
anoContrato: v.optional(v.number()),
dataInicioVigencia: v.optional(v.string()),
dataFimVigencia: v.optional(v.string()),
nomeFiscal: v.optional(v.string()),
valorTotal: v.optional(v.string()),
dataAditivoPrazo: v.optional(v.string()),
diasAvisoVencimento: v.optional(v.number()),
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "contratos",
acao: "editar",
});
const { id, ...campos } = args;
await ctx.db.patch(id, {
...campos,
atualizadoEm: Date.now(),
});
},
});
export const excluir = mutation({
args: { id: v.id("contratos") },
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "contratos",
acao: "excluir",
});
await ctx.db.delete(args.id);
},
});
export const verificarVencimentos = query({
args: {},
handler: async (ctx) => {
// 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
const hoje = new Date();
const hojeStr = hoje.toISOString().split("T")[0];
// Buscar contratos ativos (em execução ou aguardando assinatura)
const contratos = await ctx.db
.query("contratos")
.filter((q) =>
q.or(
q.eq(q.field("situacao"), "em_execucao"),
q.eq(q.field("situacao"), "aguardando_assinatura")
)
)
.collect();
const proximosVencimento = contratos.filter((c) => {
if (!c.dataFimVigencia) return false;
const dataFim = new Date(c.dataFimVigencia);
const dataAviso = new Date(dataFim);
dataAviso.setDate(dataAviso.getDate() - c.diasAvisoVencimento);
const dataAvisoStr = dataAviso.toISOString().split("T")[0];
// Se hoje for maior ou igual a data de aviso e menor que a data fim
return hojeStr >= dataAvisoStr && hojeStr <= c.dataFimVigencia;
});
return proximosVencimento;
},
});

View File

@@ -224,6 +224,36 @@ const PERMISSOES_BASE = {
acao: 'ver',
descricao: 'Acessar telas do módulo de licitações'
},
{
nome: 'contratos.listar',
recurso: 'contratos',
acao: 'listar',
descricao: 'Listar contratos'
},
{
nome: 'contratos.criar',
recurso: 'contratos',
acao: 'criar',
descricao: 'Criar novos contratos'
},
{
nome: 'contratos.editar',
recurso: 'contratos',
acao: 'editar',
descricao: 'Editar contratos'
},
{
nome: 'contratos.excluir',
recurso: 'contratos',
acao: 'excluir',
descricao: 'Excluir contratos'
},
{
nome: 'contratos.ver',
recurso: 'contratos',
acao: 'ver',
descricao: 'Visualizar detalhes de contratos'
},
// Compras
{
nome: 'compras.ver',

View File

@@ -120,7 +120,40 @@ export const reportStatus = v.union(
v.literal("falhou")
);
export const situacaoContrato = v.union(
v.literal("em_execucao"),
v.literal("rescendido"),
v.literal("aguardando_assinatura"),
v.literal("finalizado")
);
export default defineSchema({
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"]),
todos: defineTable({
text: v.string(),
completed: v.boolean(),