Feat licitacoes contratos #30

Merged
killer-cf merged 7 commits from feat-licitacoes-contratos into master 2025-11-19 12:31:19 +00:00
13 changed files with 2633 additions and 98 deletions

View File

@@ -43,6 +43,16 @@ export const maskCEP = (value: string): string => {
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");
};
/** Format phone: (00) 0000-0000 or (00) 00000-0000 */ /** Format phone: (00) 0000-0000 or (00) 00000-0000 */
export const maskPhone = (value: string): string => { export const maskPhone = (value: string): string => {
const digits = onlyDigits(value).slice(0, 11); const digits = onlyDigits(value).slice(0, 11);

View File

@@ -1,92 +1,79 @@
<script lang="ts"> <script lang="ts">
import { FileText, ClipboardCopy, Plus, Users, File } from "lucide-svelte"; import { FileText, ClipboardCopy, Building2 } from 'lucide-svelte';
import { resolve } from "$app/paths"; import { resolve } from '$app/paths';
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from '$lib/components/ProtectedRoute.svelte';
</script> </script>
<ProtectedRoute> <ProtectedRoute>
<main class="container mx-auto px-4 py-4"> <main class="container mx-auto px-4 py-4">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<div class="text-sm breadcrumbs mb-4"> <div class="breadcrumbs mb-4 text-sm">
<ul> <ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li> <li>
<a href={resolve('/')} class="text-primary hover:underline">Dashboard</a>
</li>
<li>Licitações</li> <li>Licitações</li>
</ul> </ul>
</div> </div>
<!-- Cabeçalho --> <div class="grid gap-4 md:grid-cols-3">
<div class="mb-6"> <a
<div class="flex items-center gap-4 mb-2"> href={resolve('/licitacoes/empresas')}
<div class="p-3 bg-orange-500/20 rounded-xl"> class="card bg-base-100 border-base-200 hover:border-primary border shadow-md transition-shadow hover:shadow-lg"
<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>
<!-- Card de Aviso -->
<div class="card bg-base-100 shadow-xl">
<div class="card-body"> <div class="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="mb-2 flex items-center gap-3">
<div class="mb-6"> <div class="bg-primary/10 rounded-lg p-2">
<File class="h-24 w-24 text-base-content/20" strokeWidth={1.5} /> <Building2 class="text-primary h-6 w-6" strokeWidth={2} />
</div> </div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2> <h4 class="font-semibold">Empresas</h4>
<p class="text-base-content/70 max-w-md mb-6"> </div>
O módulo de Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios. <p class="text-base-content/70 text-sm">
Cadastro, listagem e edição de empresas e seus contatos.
</p> </p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div> </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> </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 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> </div>
<!-- Funcionalidades Previstas --> <div class="card bg-base-100 opacity-70 shadow-md">
<div class="mt-6">
<h3 class="text-xl font-bold mb-4">Funcionalidades Previstas</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body"> <div class="card-body">
<div class="flex items-center gap-3 mb-2"> <div class="mb-2 flex items-center gap-3">
<div class="p-2 bg-primary/10 rounded-lg"> <div class="bg-base-200 rounded-lg p-2">
<ClipboardCopy class="h-6 w-6 text-primary" strokeWidth={2} /> <FileText class="text-base-content/50 h-6 w-6" strokeWidth={2} />
</div> </div>
<h4 class="font-semibold">Processos Licitatórios</h4> <h4 class="text-base-content/70 font-semibold">Documentação</h4>
</div> </div>
<p class="text-sm text-base-content/70">Cadastro e acompanhamento de licitações</p> <p class="text-base-content/60 text-sm">Em breve: gestão de documentos e editais.</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<Users class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Fornecedores</h4>
</div>
<p class="text-sm text-base-content/70">Cadastro e gestão de fornecedores</p>
</div>
</div>
<div class="card bg-base-100 shadow-md hover:shadow-lg transition-shadow">
<div class="card-body">
<div class="flex items-center gap-3 mb-2">
<div class="p-2 bg-primary/10 rounded-lg">
<File class="h-6 w-6 text-primary" strokeWidth={2} />
</div>
<h4 class="font-semibold">Documentação</h4>
</div>
<p class="text-sm text-base-content/70">Gestão de documentos e editais</p>
</div> </div>
</div> </div>
</div> </div>
</div> </main>
</main>
</ProtectedRoute> </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

@@ -0,0 +1,931 @@
<script lang="ts">
import { useConvexClient, useQuery } from "convex-svelte";
import { api } from "@sgse-app/backend/convex/_generated/api";
import type { Id } from "@sgse-app/backend/convex/_generated/dataModel";
import { Building2, Phone, Mail, Plus, Users, Pencil, X } from "lucide-svelte";
import { resolve } from "$app/paths";
import { maskCNPJ, maskCEP, maskPhone, maskUF, onlyDigits } from "$lib/utils/masks";
const client = useConvexClient();
const empresasQuery = useQuery(api.empresas.list, {});
let modalAberto = $state(false);
type ContatoForm = {
_id?: Id<"contatosEmpresa">;
nome: string;
funcao: string;
email: string;
telefone: string;
descricao?: string;
_deleted?: boolean;
};
type EnderecoForm = {
cep: string;
logradouro: string;
numero: string;
complemento: string;
bairro: string;
cidade: string;
uf: string;
};
type EmpresaForm = {
id?: Id<"empresas">;
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
endereco: EnderecoForm;
contatos: ContatoForm[];
};
let empresaForm = $state<EmpresaForm>({
razao_social: "",
nome_fantasia: "",
cnpj: "",
telefone: "",
email: "",
descricao: "",
endereco: {
cep: "",
logradouro: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
uf: "",
},
contatos: [],
});
let contatoEmEdicao = $state<ContatoForm | null>(null);
let contatoIndiceEdicao = $state<number | null>(null);
let erroFormulario = $state("");
let salvando = $state(false);
let contatosModalAberto = $state(false);
let contatosDaEmpresa = $state<ContatoForm[]>([]);
let empresaContatosNome = $state("");
let carregandoCep = $state(false);
let erroCep = $state("");
let carregandoCnpj = $state(false);
let erroCnpj = $state("");
type BrasilApiCnpjResponse = {
razao_social?: string;
nome_fantasia?: string;
email?: string;
ddd_telefone_1?: string;
telefone?: string;
cep?: string;
logradouro?: string;
numero?: string;
complemento?: string;
bairro?: string;
municipio?: string;
uf?: string;
message?: string;
};
function handleEmpresaCnpjInput(event: Event) {
const target = event.target as HTMLInputElement;
empresaForm.cnpj = maskCNPJ(target.value);
target.value = empresaForm.cnpj;
}
async function handleEmpresaCnpjBlur() {
const digits = onlyDigits(empresaForm.cnpj);
if (digits.length !== 14) return;
carregandoCnpj = true;
erroCnpj = "";
try {
const response = await fetch(`https://brasilapi.com.br/api/cnpj/v1/${digits}`);
const data: BrasilApiCnpjResponse = await response.json();
if (!response.ok) {
throw new Error(data.message || "CNPJ não encontrado.");
}
if (data.razao_social && !empresaForm.razao_social) {
empresaForm.razao_social = data.razao_social;
}
if (data.nome_fantasia && !empresaForm.nome_fantasia) {
empresaForm.nome_fantasia = data.nome_fantasia;
}
const telefoneFonte = data.ddd_telefone_1 ?? data.telefone;
if (telefoneFonte && !empresaForm.telefone) {
empresaForm.telefone = maskPhone(telefoneFonte);
}
if (data.email && !empresaForm.email) {
empresaForm.email = data.email;
}
if (
data.cep ||
data.logradouro ||
data.bairro ||
data.municipio ||
data.uf
) {
empresaForm.endereco = {
...empresaForm.endereco,
cep: data.cep ? maskCEP(data.cep) : empresaForm.endereco.cep,
logradouro: data.logradouro ?? empresaForm.endereco.logradouro,
numero: data.numero ?? empresaForm.endereco.numero,
complemento: data.complemento ?? empresaForm.endereco.complemento,
bairro: data.bairro ?? empresaForm.endereco.bairro,
cidade: data.municipio ?? empresaForm.endereco.cidade,
uf: data.uf ? maskUF(data.uf) : empresaForm.endereco.uf,
};
}
} catch (error) {
erroCnpj =
error instanceof Error
? error.message
: "Não foi possível buscar os dados do CNPJ.";
} finally {
carregandoCnpj = false;
}
}
function handleEmpresaTelefoneInput(event: Event) {
const target = event.target as HTMLInputElement;
empresaForm.telefone = maskPhone(target.value);
target.value = empresaForm.telefone;
}
function handleContatoTelefoneInput(event: Event) {
const target = event.target as HTMLInputElement;
if (!contatoEmEdicao) return;
contatoEmEdicao = { ...contatoEmEdicao, telefone: maskPhone(target.value) };
target.value = contatoEmEdicao.telefone;
}
function handleCepInput(event: Event) {
const target = event.target as HTMLInputElement;
empresaForm.endereco.cep = maskCEP(target.value);
target.value = empresaForm.endereco.cep;
const digits = onlyDigits(empresaForm.endereco.cep);
if (digits.length === 8) {
void buscarCep(digits);
}
}
async function buscarCep(cepDigits: string) {
carregandoCep = true;
erroCep = "";
try {
const response = await fetch(`https://viacep.com.br/ws/${cepDigits}/json/`);
const data = await response.json();
if (data.erro) {
throw new Error("CEP não encontrado.");
}
empresaForm.endereco = {
...empresaForm.endereco,
cep: maskCEP(cepDigits),
logradouro: data.logradouro ?? empresaForm.endereco.logradouro,
bairro: data.bairro ?? empresaForm.endereco.bairro,
cidade: data.localidade ?? empresaForm.endereco.cidade,
uf: data.uf ? maskUF(data.uf) : empresaForm.endereco.uf,
};
} catch (error) {
erroCep =
error instanceof Error
? error.message
: "Não foi possível buscar o endereço pelo CEP.";
} finally {
carregandoCep = false;
}
}
function normalizeEnderecoForSave(endereco: EnderecoForm) {
const hasData =
endereco.cep ||
endereco.logradouro ||
endereco.numero ||
endereco.bairro ||
endereco.cidade ||
endereco.uf;
if (!hasData) {
return undefined;
}
return {
cep: endereco.cep,
logradouro: endereco.logradouro,
numero: endereco.numero,
complemento: endereco.complemento || undefined,
bairro: endereco.bairro,
cidade: endereco.cidade,
uf: endereco.uf,
};
}
function abrirNovaEmpresa() {
empresaForm = {
razao_social: "",
nome_fantasia: "",
cnpj: "",
telefone: "",
email: "",
descricao: "",
endereco: {
cep: "",
logradouro: "",
numero: "",
complemento: "",
bairro: "",
cidade: "",
uf: "",
},
contatos: [],
};
modalAberto = true;
}
async function editarEmpresa(id: Id<"empresas">) {
const detalhes = await client.query(api.empresas.getById, { id });
if (!detalhes) return;
empresaForm = {
id: detalhes._id,
razao_social: detalhes.razao_social,
nome_fantasia: detalhes.nome_fantasia,
cnpj: detalhes.cnpj,
telefone: detalhes.telefone,
email: detalhes.email,
descricao: detalhes.descricao ?? "",
endereco: {
cep: detalhes.endereco?.cep ?? "",
logradouro: detalhes.endereco?.logradouro ?? "",
numero: detalhes.endereco?.numero ?? "",
complemento: detalhes.endereco?.complemento ?? "",
bairro: detalhes.endereco?.bairro ?? "",
cidade: detalhes.endereco?.cidade ?? "",
uf: detalhes.endereco?.uf ?? "",
},
contatos:
detalhes.contatos?.map((c) => ({
_id: c._id,
nome: c.nome,
funcao: c.funcao,
email: c.email,
telefone: c.telefone,
descricao: c.descricao ?? "",
})) ?? [],
};
modalAberto = true;
}
async function verContatos(empresaId: Id<"empresas">, razaoSocial: string) {
const detalhes = await client.query(api.empresas.getById, { id: empresaId });
contatosDaEmpresa = detalhes?.contatos ?? [];
empresaContatosNome = razaoSocial;
contatosModalAberto = true;
}
function fecharContatosModal() {
contatosModalAberto = false;
contatosDaEmpresa = [];
empresaContatosNome = "";
}
function fecharModal() {
modalAberto = false;
contatoEmEdicao = null;
contatoIndiceEdicao = null;
erroFormulario = "";
}
function adicionarContato() {
contatoEmEdicao = {
nome: "",
funcao: "",
email: "",
telefone: "",
descricao: "",
};
contatoIndiceEdicao = null;
}
function editarContato(index: number) {
const contato = empresaForm.contatos[index];
if (!contato || contato._deleted) return;
contatoEmEdicao = { ...contato };
contatoIndiceEdicao = index;
}
function removerContato(index: number) {
const contato = empresaForm.contatos[index];
if (!contato) return;
if (contato._id) {
empresaForm.contatos[index] = { ...contato, _deleted: true };
} else {
empresaForm.contatos = empresaForm.contatos.filter((_, i) => i !== index);
}
}
function salvarContatoAtual() {
if (!contatoEmEdicao) return;
if (!contatoEmEdicao.nome || !contatoEmEdicao.email || !contatoEmEdicao.telefone) {
erroFormulario = "Preencha pelo menos nome, e-mail e telefone do contato.";
return;
}
erroFormulario = "";
if (contatoIndiceEdicao === null) {
empresaForm.contatos = [...empresaForm.contatos, contatoEmEdicao];
} else {
empresaForm.contatos[contatoIndiceEdicao] = {
...(empresaForm.contatos[contatoIndiceEdicao] ?? {}),
...contatoEmEdicao,
_deleted: false,
};
}
contatoEmEdicao = null;
contatoIndiceEdicao = null;
}
async function salvarEmpresa() {
if (
!empresaForm.razao_social ||
!empresaForm.cnpj ||
!empresaForm.telefone ||
!empresaForm.email
) {
erroFormulario = "Preencha todos os campos obrigatórios da empresa.";
return;
}
salvando = true;
erroFormulario = "";
try {
const enderecoPayload = normalizeEnderecoForSave(empresaForm.endereco);
if (empresaForm.id) {
const baseArgs = {
id: empresaForm.id,
razao_social: empresaForm.razao_social,
nome_fantasia: empresaForm.nome_fantasia,
cnpj: empresaForm.cnpj,
telefone: empresaForm.telefone,
email: empresaForm.email,
descricao: empresaForm.descricao || undefined,
contatos: empresaForm.contatos,
};
const args = enderecoPayload
? { ...baseArgs, endereco: enderecoPayload }
: baseArgs;
await client.mutation(api.empresas.update, args);
} else {
const baseArgs = {
razao_social: empresaForm.razao_social,
nome_fantasia: empresaForm.nome_fantasia,
cnpj: empresaForm.cnpj,
telefone: empresaForm.telefone,
email: empresaForm.email,
descricao: empresaForm.descricao || undefined,
contatos: empresaForm.contatos,
};
const args = enderecoPayload
? { ...baseArgs, endereco: enderecoPayload }
: baseArgs;
await client.mutation(api.empresas.create, args);
}
fecharModal();
} catch (error) {
erroFormulario = error instanceof Error ? error.message : "Erro ao salvar empresa.";
} finally {
salvando = false;
}
}
</script>
<main class="container mx-auto px-4 py-4">
<div class="text-sm breadcrumbs mb-4">
<ul>
<li><a href={resolve('/')} class="text-primary hover:underline">Dashboard</a></li>
<li><a href={resolve('/licitacoes')} class="text-primary hover:underline">Licitações</a></li>
<li>Empresas</li>
</ul>
</div>
<div class="mb-6 flex items-center justify-between gap-4">
<div class="flex items-center gap-3">
<div class="p-3 bg-primary/10 rounded-xl">
<Building2 class="h-8 w-8 text-primary" strokeWidth={2} />
</div>
<div>
<h1 class="text-3xl font-bold text-primary">Empresas</h1>
<p class="text-base-content/70">
Cadastro, listagem e contatos de empresas fornecedoras.
</p>
</div>
</div>
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
<Plus class="h-4 w-4" strokeWidth={2} />
Nova empresa
</button>
</div>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
{#if empresasQuery.isLoading}
<div class="flex justify-center py-8">
<span class="loading loading-spinner loading-lg text-primary"></span>
</div>
{:else if empresasQuery.error}
<div class="alert alert-error">
<span>Erro ao carregar empresas.</span>
</div>
{:else if empresasQuery.data && empresasQuery.data.length === 0}
<div class="text-center py-10">
<p class="text-base-content/70 mb-4">Nenhuma empresa cadastrada ainda.</p>
<button class="btn btn-primary gap-2" type="button" onclick={abrirNovaEmpresa}>
<Plus class="h-4 w-4" strokeWidth={2} />
Cadastrar primeira empresa
</button>
</div>
{:else if empresasQuery.data}
<div class="overflow-x-auto">
<table class="table table-zebra">
<thead>
<tr>
<th>CNPJ</th>
<th>Razão social / Nome fantasia</th>
<th>Telefone</th>
<th>E-mail</th>
<th>Ações</th>
</tr>
</thead>
<tbody>
{#each empresasQuery.data as empresa (empresa._id)}
<tr>
<td>{empresa.cnpj}</td>
<td>
<div class="flex flex-col">
<span class="font-semibold">{empresa.razao_social}</span>
{#if empresa.nome_fantasia}
<span class="text-xs text-base-content/70">{empresa.nome_fantasia}</span>
{/if}
</div>
</td>
<td class="flex items-center gap-2">
<Phone class="h-4 w-4 text-base-content/60" strokeWidth={2} />
<span>{empresa.telefone}</span>
</td>
<td class="flex items-center gap-2">
<Mail class="h-4 w-4 text-base-content/60" strokeWidth={2} />
<span>{empresa.email}</span>
</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button
class="btn btn-outline btn-sm gap-2"
type="button"
onclick={() => verContatos(empresa._id, empresa.razao_social)}
>
<Users class="h-4 w-4" strokeWidth={2} />
Contatos
</button>
<button
class="btn btn-ghost btn-sm gap-2"
type="button"
onclick={() => editarEmpresa(empresa._id)}
>
<Pencil class="h-4 w-4" strokeWidth={2} />
Editar
</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
</div>
{#if modalAberto}
<div class="modal modal-open">
<div class="modal-box max-w-5xl">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h2 class="text-2xl font-bold">
{empresaForm.id ? "Editar empresa" : "Nova empresa"}
</h2>
<p class="text-sm text-base-content/70">
Preencha os dados da empresa e cadastre contatos associados.
</p>
</div>
<button class="btn btn-ghost btn-sm" type="button" onclick={fecharModal}>
<X class="h-4 w-4" strokeWidth={2} />
</button>
</div>
{#if erroFormulario}
<div class="alert alert-error mb-4">
<span>{erroFormulario}</span>
</div>
{/if}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="form-control md:col-span-2">
<label class="label" for="razaoSocialEmpresa">
<span class="label-text">Razão social *</span>
</label>
<input
id="razaoSocialEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.razao_social}
placeholder="Razão social"
required
/>
</div>
<div class="form-control">
<label class="label" for="nomeFantasiaEmpresa">
<span class="label-text">Nome fantasia</span>
</label>
<input
id="nomeFantasiaEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.nome_fantasia}
placeholder="Nome fantasia"
/>
</div>
<div class="form-control">
<label class="label" for="cnpjEmpresa"><span class="label-text">CNPJ *</span></label>
<input
id="cnpjEmpresa"
class="input input-bordered w-full"
value={empresaForm.cnpj}
inputmode="numeric"
oninput={handleEmpresaCnpjInput}
onblur={handleEmpresaCnpjBlur}
placeholder="00.000.000/0000-00"
required
/>
{#if carregandoCnpj}
<span class="text-xs text-primary mt-1">Buscando dados do CNPJ...</span>
{:else if erroCnpj}
<span class="text-xs text-error mt-1">{erroCnpj}</span>
{/if}
</div>
<div class="form-control">
<label class="label" for="telefoneEmpresa"><span class="label-text">Telefone *</span></label>
<input
id="telefoneEmpresa"
class="input input-bordered w-full"
value={empresaForm.telefone}
inputmode="numeric"
oninput={handleEmpresaTelefoneInput}
placeholder="(00) 00000-0000"
required
/>
</div>
<div class="form-control">
<label class="label" for="emailEmpresa"><span class="label-text">E-mail *</span></label>
<input
id="emailEmpresa"
class="input input-bordered w-full"
type="email"
bind:value={empresaForm.email}
placeholder="contato@empresa.com"
required
/>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="descricaoEmpresa">
<span class="label-text">Descrição (opcional)</span>
</label>
<textarea
id="descricaoEmpresa"
class="textarea textarea-bordered w-full"
rows={3}
bind:value={empresaForm.descricao}
placeholder="Descrição, observações ou informações adicionais da empresa"
></textarea>
</div>
</div>
<div class="divider my-4">Endereço da empresa</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="form-control">
<label class="label" for="cepEmpresa"><span class="label-text">CEP</span></label>
<input
id="cepEmpresa"
class="input input-bordered w-full"
value={empresaForm.endereco.cep}
inputmode="numeric"
oninput={handleCepInput}
placeholder="00000-000"
/>
{#if carregandoCep}
<span class="text-xs text-primary mt-1">Buscando endereço pelo CEP...</span>
{:else if erroCep}
<span class="text-xs text-error mt-1">{erroCep}</span>
{/if}
</div>
<div class="form-control md:col-span-2">
<label class="label" for="logradouroEmpresa"><span class="label-text">Logradouro</span></label>
<input
id="logradouroEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.endereco.logradouro}
placeholder="Rua, avenida, etc."
/>
</div>
<div class="form-control">
<label class="label" for="numeroEmpresa"><span class="label-text">Número</span></label>
<input
id="numeroEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.endereco.numero}
placeholder="Número"
/>
</div>
<div class="form-control">
<label class="label" for="complementoEmpresa"><span class="label-text">Complemento</span></label>
<input
id="complementoEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.endereco.complemento}
placeholder="Sala, bloco, etc."
/>
</div>
<div class="form-control">
<label class="label" for="bairroEmpresa"><span class="label-text">Bairro</span></label>
<input
id="bairroEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.endereco.bairro}
placeholder="Bairro"
/>
</div>
<div class="form-control">
<label class="label" for="cidadeEmpresa"><span class="label-text">Cidade</span></label>
<input
id="cidadeEmpresa"
class="input input-bordered w-full"
bind:value={empresaForm.endereco.cidade}
placeholder="Cidade"
/>
</div>
<div class="form-control">
<label class="label" for="ufEmpresa"><span class="label-text">UF</span></label>
<input
id="ufEmpresa"
class="input input-bordered w-full"
maxlength="2"
bind:value={empresaForm.endereco.uf}
oninput={(event) => {
const target = event.target as HTMLInputElement;
empresaForm.endereco.uf = maskUF(target.value);
target.value = empresaForm.endereco.uf;
}}
placeholder="UF"
/>
</div>
</div>
<div class="divider my-4">Contatos da empresa</div>
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<Users class="h-5 w-5 text-primary" strokeWidth={2} />
<span class="font-semibold">Contatos cadastrados</span>
</div>
<button
class="btn btn-sm btn-outline gap-2"
type="button"
onclick={adicionarContato}
>
<Plus class="h-4 w-4" strokeWidth={2} />
Adicionar contato
</button>
</div>
{#if empresaForm.contatos.filter((c) => !c._deleted).length === 0}
<p class="text-sm text-base-content/60 mb-3">
Nenhum contato cadastrado. Clique em "Adicionar contato" para incluir.
</p>
{:else}
<div class="overflow-x-auto mb-4">
<table class="table table-sm">
<thead>
<tr>
<th>Nome</th>
<th>Função</th>
<th>E-mail</th>
<th>Telefone</th>
<th></th>
</tr>
</thead>
<tbody>
{#each empresaForm.contatos as contato, index (contato._id ?? `${contato.email}-${index}`)}
{#if !contato._deleted}
<tr>
<td>{contato.nome}</td>
<td>{contato.funcao}</td>
<td>{contato.email}</td>
<td>{contato.telefone}</td>
<td class="text-right">
<div class="flex justify-end gap-2">
<button
class="btn btn-ghost btn-xs"
type="button"
onclick={() => editarContato(index)}
>
<Pencil class="h-3 w-3" strokeWidth={2} />
</button>
<button
class="btn btn-ghost btn-xs text-error"
type="button"
onclick={() => removerContato(index)}
>
<X class="h-3 w-3" strokeWidth={2} />
</button>
</div>
</td>
</tr>
{/if}
{/each}
</tbody>
</table>
</div>
{/if}
{#if contatoEmEdicao}
<div class="mt-4 p-4 rounded-lg bg-base-200">
<h3 class="font-semibold mb-3">
{contatoIndiceEdicao === null ? "Novo contato" : "Editar contato"}
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div class="form-control">
<label class="label" for="contatoNome"><span class="label-text">Nome *</span></label>
<input
id="contatoNome"
class="input input-bordered input-sm w-full"
bind:value={contatoEmEdicao.nome}
placeholder="Nome do contato"
required
/>
</div>
<div class="form-control">
<label class="label" for="contatoFuncao"><span class="label-text">Função *</span></label>
<input
id="contatoFuncao"
class="input input-bordered input-sm w-full"
bind:value={contatoEmEdicao.funcao}
placeholder="Função / cargo"
required
/>
</div>
<div class="form-control">
<label class="label" for="contatoEmail"><span class="label-text">E-mail *</span></label>
<input
id="contatoEmail"
class="input input-bordered input-sm w-full"
type="email"
bind:value={contatoEmEdicao.email}
placeholder="email@exemplo.com"
required
/>
</div>
<div class="form-control">
<label class="label" for="contatoTelefone"><span class="label-text">Telefone *</span></label>
<input
id="contatoTelefone"
class="input input-bordered input-sm w-full"
value={contatoEmEdicao.telefone}
inputmode="numeric"
oninput={handleContatoTelefoneInput}
placeholder="(00) 00000-0000"
/>
</div>
<div class="form-control md:col-span-2">
<label class="label" for="contatoDescricao">
<span class="label-text">Descrição (opcional)</span>
</label>
<textarea
id="contatoDescricao"
class="textarea textarea-bordered textarea-sm w-full"
rows={2}
bind:value={contatoEmEdicao.descricao}
placeholder="Observações sobre o contato"
></textarea>
</div>
</div>
<div class="mt-3 flex justify-end gap-2">
<button
class="btn btn-ghost btn-sm"
type="button"
onclick={() => {
contatoEmEdicao = null;
contatoIndiceEdicao = null;
}}
>
Cancelar
</button>
<button
class="btn btn-primary btn-sm"
type="button"
onclick={salvarContatoAtual}
>
Salvar contato
</button>
</div>
</div>
{/if}
<div class="modal-action">
<button class="btn btn-ghost" type="button" onclick={fecharModal} disabled={salvando}>
Cancelar
</button>
<button
class="btn btn-primary"
type="button"
onclick={salvarEmpresa}
disabled={salvando}
>
{#if salvando}
<span class="loading loading-spinner loading-sm"></span>
Salvando...
{:else}
Salvar
{/if}
</button>
</div>
</div>
</div>
{/if}
{#if contatosModalAberto}
<div class="modal modal-open">
<div class="modal-box max-w-3xl">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h2 class="text-2xl font-bold">Contatos da empresa</h2>
<p class="text-sm text-base-content/70">
{empresaContatosNome}
</p>
</div>
<button class="btn btn-ghost btn-sm" type="button" onclick={fecharContatosModal}>
<X class="h-4 w-4" strokeWidth={2} />
</button>
</div>
{#if !contatosDaEmpresa.length}
<p class="text-sm text-base-content/60">
Nenhum contato cadastrado para esta empresa.
</p>
{:else}
<div class="overflow-x-auto">
<table class="table table-sm">
<thead>
<tr>
<th>Nome</th>
<th>Função</th>
<th>E-mail</th>
<th>Telefone</th>
<th>Descrição</th>
</tr>
</thead>
<tbody>
{#each contatosDaEmpresa as contato (contato._id ?? `${contato.email}`)}
<tr>
<td>{contato.nome}</td>
<td>{contato.funcao}</td>
<td>{contato.email}</td>
<td>{contato.telefone}</td>
<td>{contato.descricao}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<div class="modal-action">
<button class="btn" type="button" onclick={fecharContatosModal}>Fechar</button>
</div>
</div>
</div>
{/if}
</main>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Trophy, Award, Plus } from "lucide-svelte"; import { Trophy, Award, Building2 } from "lucide-svelte";
import { resolve } from "$app/paths"; import { resolve } from "$app/paths";
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte"; import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
</script> </script>
@@ -25,23 +25,37 @@
</div> </div>
</div> </div>
<div class="card bg-base-100 shadow-xl"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<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="card-body">
<div class="flex flex-col items-center justify-center py-12 text-center"> <div class="flex items-center gap-3 mb-2">
<div class="mb-6"> <div class="p-2 bg-primary/10 rounded-lg">
<Award class="h-24 w-24 text-base-content/20" strokeWidth={1.5} /> <Building2 class="h-6 w-6 text-primary" strokeWidth={2} />
</div> </div>
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2> <h4 class="font-semibold">Empresas</h4>
<p class="text-base-content/70 max-w-md mb-6"> </div>
O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos. <p class="text-sm text-base-content/70">
Cadastro, listagem e edição de empresas e seus contatos.
</p> </p>
<div class="badge badge-warning badge-lg gap-2">
<Plus class="h-4 w-4" strokeWidth={2} />
Em Desenvolvimento
</div> </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">
<Award class="h-6 w-6 text-base-content/50" strokeWidth={2} />
</div>
<h4 class="font-semibold text-base-content/70">Programas Esportivos</h4>
</div>
<p class="text-sm text-base-content/60">
Em breve: funcionalidades para gestão de programas e projetos esportivos.
</p>
</div> </div>
</div> </div>
</div> </div>
</main> </main>
</ProtectedRoute> </ProtectedRoute>

View File

@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "sgse-app", "name": "sgse-app",

View File

@@ -21,13 +21,13 @@ import type * as auth_utils from "../auth/utils.js";
import type * as chamados from "../chamados.js"; import type * as chamados from "../chamados.js";
import type * as chat from "../chat.js"; import type * as chat from "../chat.js";
import type * as configuracaoEmail from "../configuracaoEmail.js"; import type * as configuracaoEmail from "../configuracaoEmail.js";
import type * as configuracaoPonto from "../configuracaoPonto.js"; import type * as contratos from "../contratos.js";
import type * as configuracaoRelogio from "../configuracaoRelogio.js";
import type * as crons from "../crons.js"; import type * as crons from "../crons.js";
import type * as cursos from "../cursos.js"; import type * as cursos from "../cursos.js";
import type * as dashboard from "../dashboard.js"; import type * as dashboard from "../dashboard.js";
import type * as documentos from "../documentos.js"; import type * as documentos from "../documentos.js";
import type * as email from "../email.js"; import type * as email from "../email.js";
import type * as empresas from "../empresas.js";
import type * as ferias from "../ferias.js"; import type * as ferias from "../ferias.js";
import type * as funcionarios from "../funcionarios.js"; import type * as funcionarios from "../funcionarios.js";
import type * as healthCheck from "../healthCheck.js"; import type * as healthCheck from "../healthCheck.js";
@@ -73,6 +73,7 @@ declare const fullApi: ApiFromModules<{
chamados: typeof chamados; chamados: typeof chamados;
chat: typeof chat; chat: typeof chat;
configuracaoEmail: typeof configuracaoEmail; configuracaoEmail: typeof configuracaoEmail;
contratos: typeof contratos;
configuracaoPonto: typeof configuracaoPonto; configuracaoPonto: typeof configuracaoPonto;
configuracaoRelogio: typeof configuracaoRelogio; configuracaoRelogio: typeof configuracaoRelogio;
crons: typeof crons; crons: typeof crons;
@@ -80,6 +81,7 @@ declare const fullApi: ApiFromModules<{
dashboard: typeof dashboard; dashboard: typeof dashboard;
documentos: typeof documentos; documentos: typeof documentos;
email: typeof email; email: typeof email;
empresas: typeof empresas;
ferias: typeof ferias; ferias: typeof ferias;
funcionarios: typeof funcionarios; funcionarios: typeof funcionarios;
healthCheck: typeof healthCheck; healthCheck: typeof healthCheck;

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

@@ -0,0 +1,292 @@
import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import { internal } from "./_generated/api";
import { getCurrentUserFunction } from "./auth";
import type { Id } from "./_generated/dataModel";
export const list = query({
args: {},
handler: async (ctx) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "listar",
});
const empresas = await ctx.db.query("empresas").collect();
return empresas;
},
});
export const getById = query({
args: { id: v.id("empresas") },
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "ver",
});
const empresa = await ctx.db.get(args.id);
if (!empresa) {
return null;
}
const contatos = await ctx.db
.query("contatosEmpresa")
.withIndex("by_empresa", (q) => q.eq("empresaId", args.id))
.collect();
const endereco = empresa.enderecoId
? await ctx.db.get(empresa.enderecoId as Id<"enderecos">)
: null;
return { ...empresa, endereco, contatos };
},
});
const contatoInput = v.object({
_id: v.optional(v.id("contatosEmpresa")),
empresaId: v.optional(v.id("empresas")),
nome: v.string(),
funcao: v.string(),
email: v.string(),
telefone: v.string(),
adicionadoPor: v.optional(v.id("usuarios")),
descricao: v.optional(v.string()),
_deleted: v.optional(v.boolean()),
});
const enderecoInput = v.object({
cep: v.string(),
logradouro: v.string(),
numero: v.string(),
complemento: v.optional(v.string()),
bairro: v.string(),
cidade: v.string(),
uf: v.string(),
});
export const create = mutation({
args: {
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
endereco: v.optional(enderecoInput),
contatos: v.optional(v.array(contatoInput)),
},
returns: v.id("empresas"),
handler: async (ctx, args) => {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
throw new Error("Usuário não autenticado.");
}
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "criar",
});
const cnpjExistente = await ctx.db
.query("empresas")
.withIndex("by_cnpj", (q) => q.eq("cnpj", args.cnpj))
.unique();
if (cnpjExistente) {
throw new Error("Já existe uma empresa cadastrada com este CNPJ.");
}
let enderecoId: Id<"enderecos"> | undefined;
if (args.endereco) {
enderecoId = await ctx.db.insert("enderecos", {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
criadoPor: usuarioAtual._id,
atualizadoPor: usuarioAtual._id,
});
}
const empresaDoc: {
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
enderecoId?: Id<"enderecos">;
criadoPor: Id<"usuarios">;
} = {
razao_social: args.razao_social,
cnpj: args.cnpj,
telefone: args.telefone,
email: args.email,
criadoPor: usuarioAtual._id,
};
if (args.nome_fantasia !== undefined) {
empresaDoc.nome_fantasia = args.nome_fantasia;
}
if (args.descricao !== undefined) {
empresaDoc.descricao = args.descricao;
}
if (enderecoId) {
empresaDoc.enderecoId = enderecoId;
}
const empresaId = await ctx.db.insert("empresas", empresaDoc);
if (args.contatos && args.contatos.length > 0) {
for (const contato of args.contatos) {
await ctx.db.insert("contatosEmpresa", {
empresaId,
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
adicionadoPor: usuarioAtual._id,
descricao: contato.descricao,
});
}
}
return empresaId;
},
});
export const update = mutation({
args: {
id: v.id("empresas"),
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
endereco: v.optional(enderecoInput),
contatos: v.optional(v.array(contatoInput)),
},
returns: v.null(),
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: "empresas",
acao: "editar",
});
const cnpjExistente = await ctx.db
.query("empresas")
.withIndex("by_cnpj", (q) => q.eq("cnpj", args.cnpj))
.unique();
if (cnpjExistente && cnpjExistente._id !== args.id) {
throw new Error("Já existe uma empresa cadastrada com este CNPJ.");
}
const empresa = await ctx.db.get(args.id);
if (!empresa) {
throw new Error("Empresa não encontrada.");
}
if (args.endereco) {
if (empresa.enderecoId) {
const usuarioAtual = await getCurrentUserFunction(ctx);
await ctx.db.patch(empresa.enderecoId as Id<"enderecos">, {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
atualizadoPor: usuarioAtual?._id,
});
} else {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
throw new Error("Usuário não autenticado.");
}
const novoEnderecoId: Id<"enderecos"> = await ctx.db.insert("enderecos", {
cep: args.endereco.cep,
logradouro: args.endereco.logradouro,
numero: args.endereco.numero,
complemento: args.endereco.complemento,
bairro: args.endereco.bairro,
cidade: args.endereco.cidade,
uf: args.endereco.uf,
criadoPor: usuarioAtual._id,
atualizadoPor: usuarioAtual._id,
});
await ctx.db.patch(args.id, {
enderecoId: novoEnderecoId,
});
}
}
const patchDoc: {
razao_social: string;
nome_fantasia?: string;
cnpj: string;
telefone: string;
email: string;
descricao?: string;
} = {
razao_social: args.razao_social,
cnpj: args.cnpj,
telefone: args.telefone,
email: args.email,
};
if (args.nome_fantasia !== undefined) {
patchDoc.nome_fantasia = args.nome_fantasia;
}
if (args.descricao !== undefined) {
patchDoc.descricao = args.descricao;
}
await ctx.db.patch(args.id, patchDoc);
if (!args.contatos) {
return null;
}
for (const contato of args.contatos) {
if (contato._id && contato._deleted) {
await ctx.db.delete(contato._id);
} else if (contato._id) {
await ctx.db.patch(contato._id, {
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
descricao: contato.descricao,
});
} else if (!contato._deleted) {
const usuarioAtual = await getCurrentUserFunction(ctx);
if (!usuarioAtual) {
throw new Error("Usuário não autenticado.");
}
await ctx.db.insert("contatosEmpresa", {
empresaId: args.id,
nome: contato.nome,
funcao: contato.funcao,
email: contato.email,
telefone: contato.telefone,
adicionadoPor: usuarioAtual._id,
descricao: contato.descricao,
});
}
}
return null;
},
});

View File

@@ -224,6 +224,36 @@ const PERMISSOES_BASE = {
acao: 'ver', acao: 'ver',
descricao: 'Acessar telas do módulo de licitações' 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 // Compras
{ {
nome: 'compras.ver', nome: 'compras.ver',

View File

@@ -120,11 +120,78 @@ export const reportStatus = v.union(
v.literal("falhou") 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({ 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({ todos: defineTable({
text: v.string(), text: v.string(),
completed: v.boolean(), completed: v.boolean(),
}), }),
enderecos: defineTable({
cep: v.string(),
logradouro: v.string(),
numero: v.string(),
complemento: v.optional(v.string()),
bairro: v.string(),
cidade: v.string(),
uf: v.string(),
criadoPor: v.optional(v.id("usuarios")),
atualizadoPor: v.optional(v.id("usuarios")),
}).index("by_cep", ["cep"]),
empresas: defineTable({
razao_social: v.string(),
nome_fantasia: v.optional(v.string()),
cnpj: v.string(),
telefone: v.string(),
email: v.string(),
descricao: v.optional(v.string()),
enderecoId: v.optional(v.id("enderecos")),
criadoPor: v.optional(v.id("usuarios")),
})
.index("by_razao_social", ["razao_social"])
.index("by_cnpj", ["cnpj"]),
contatosEmpresa: defineTable({
empresaId: v.id("empresas"),
nome: v.string(),
funcao: v.string(),
email: v.string(),
telefone: v.string(),
adicionadoPor: v.optional(v.id("usuarios")),
descricao: v.optional(v.string()),
})
.index("by_empresa", ["empresaId"])
.index("by_email", ["email"]),
funcionarios: defineTable({ funcionarios: defineTable({
// Campos obrigatórios existentes // Campos obrigatórios existentes
nome: v.string(), nome: v.string(),