Feat licitacoes contratos #30
@@ -43,6 +43,16 @@ export const maskCEP = (value: string): string => {
|
||||
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 */
|
||||
export const maskPhone = (value: string): string => {
|
||||
const digits = onlyDigits(value).slice(0, 11);
|
||||
|
||||
@@ -1,92 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { FileText, ClipboardCopy, Plus, Users, File } 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">
|
||||
<div class="breadcrumbs mb-4 text-sm">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- Card de Aviso -->
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<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="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-6">
|
||||
<File class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
||||
<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>
|
||||
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||
<p class="text-base-content/70 max-w-md mb-6">
|
||||
O módulo de Licitações está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de processos licitatórios.
|
||||
<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 class="badge badge-warning badge-lg gap-2">
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</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 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>
|
||||
|
||||
<!-- Funcionalidades Previstas -->
|
||||
<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 bg-base-100 opacity-70 shadow-md">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<ClipboardCopy class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
<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="font-semibold">Processos Licitatórios</h4>
|
||||
</div>
|
||||
<p class="text-sm text-base-content/70">Cadastro e acompanhamento de licitações</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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
931
apps/web/src/routes/(dashboard)/licitacoes/empresas/+page.svelte
Normal file
931
apps/web/src/routes/(dashboard)/licitacoes/empresas/+page.svelte
Normal 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Trophy, Award, Plus } from "lucide-svelte";
|
||||
import { Trophy, Award, Building2 } from "lucide-svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
import ProtectedRoute from "$lib/components/ProtectedRoute.svelte";
|
||||
</script>
|
||||
@@ -25,23 +25,37 @@
|
||||
</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="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div class="mb-6">
|
||||
<Award class="h-24 w-24 text-base-content/20" strokeWidth={1.5} />
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 bg-primary/10 rounded-lg">
|
||||
<Building2 class="h-6 w-6 text-primary" strokeWidth={2} />
|
||||
</div>
|
||||
<h2 class="text-2xl font-bold mb-2">Módulo em Desenvolvimento</h2>
|
||||
<p class="text-base-content/70 max-w-md mb-6">
|
||||
O módulo de Programas Esportivos está sendo desenvolvido e em breve estará disponível com funcionalidades completas para gestão de programas e projetos esportivos.
|
||||
<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 class="badge badge-warning badge-lg gap-2">
|
||||
<Plus class="h-4 w-4" strokeWidth={2} />
|
||||
Em Desenvolvimento
|
||||
</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>
|
||||
</main>
|
||||
</ProtectedRoute>
|
||||
|
||||
|
||||
1
bun.lock
1
bun.lock
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sgse-app",
|
||||
|
||||
6
packages/backend/convex/_generated/api.d.ts
vendored
6
packages/backend/convex/_generated/api.d.ts
vendored
@@ -21,13 +21,13 @@ 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 configuracaoPonto from "../configuracaoPonto.js";
|
||||
import type * as configuracaoRelogio from "../configuracaoRelogio.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";
|
||||
import type * as documentos from "../documentos.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 funcionarios from "../funcionarios.js";
|
||||
import type * as healthCheck from "../healthCheck.js";
|
||||
@@ -73,6 +73,7 @@ declare const fullApi: ApiFromModules<{
|
||||
chamados: typeof chamados;
|
||||
chat: typeof chat;
|
||||
configuracaoEmail: typeof configuracaoEmail;
|
||||
contratos: typeof contratos;
|
||||
configuracaoPonto: typeof configuracaoPonto;
|
||||
configuracaoRelogio: typeof configuracaoRelogio;
|
||||
crons: typeof crons;
|
||||
@@ -80,6 +81,7 @@ declare const fullApi: ApiFromModules<{
|
||||
dashboard: typeof dashboard;
|
||||
documentos: typeof documentos;
|
||||
email: typeof email;
|
||||
empresas: typeof empresas;
|
||||
ferias: typeof ferias;
|
||||
funcionarios: typeof funcionarios;
|
||||
healthCheck: typeof healthCheck;
|
||||
|
||||
200
packages/backend/convex/contratos.ts
Normal file
200
packages/backend/convex/contratos.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
292
packages/backend/convex/empresas.ts
Normal file
292
packages/backend/convex/empresas.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -120,11 +120,78 @@ 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(),
|
||||
}),
|
||||
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({
|
||||
// Campos obrigatórios existentes
|
||||
nome: v.string(),
|
||||
|
||||
Reference in New Issue
Block a user