feat(contratos): sistema completo de contratos odontológicos

- Tabelas contratos e contrato_itens com migrações automáticas
- Endpoints CRUD com itens em transação
- ContratosView: listagem, filtro por status, cards, stats
- ContratoModal: wizard 3 abas (Dados/Itens/Cláusulas), catálogo de
  procedimentos, DnD para reordenar, financeiro completo, print A4
- Sidebar: novo item CONTRATOS
- App.tsx: nova rota /contratos com permissões dentista/funcionario
- PatientsView: botão CONTRATOS no menu financeiro

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
VPS 4 Builder
2026-05-14 20:04:57 +02:00
parent d6d2bc1e78
commit d064aa9058
12 changed files with 2178 additions and 9 deletions
+116
View File
@@ -1256,6 +1256,91 @@ app.delete('/api/financeiro/:id', async (req, res) => {
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- CONTRATOS ---
app.get('/api/contratos', async (req, res) => {
try {
const { paciente_id, status } = req.query;
let where = 'WHERE 1=1';
const vals = [];
if (paciente_id) { vals.push(paciente_id); where += ` AND c.paciente_id = $${vals.length}`; }
if (status) { vals.push(status); where += ` AND c.status = $${vals.length}`; }
const { rows: contratos } = await pool.query(
`SELECT c.*, json_agg(ci.* ORDER BY ci.ordem) FILTER (WHERE ci.id IS NOT NULL) AS items
FROM contratos c
LEFT JOIN contrato_itens ci ON ci.contrato_id = c.id
${where} GROUP BY c.id ORDER BY c.created_at DESC`, vals);
res.json(contratos.map(c => ({
...c,
valorTotal: parseFloat(c.valor_total || 0),
entrada: parseFloat(c.entrada || 0),
parcelas: parseInt(c.parcelas || 1),
pacienteId: c.paciente_id,
pacienteNome: c.paciente_nome,
dentistaId: c.dentista_id,
dentistaNome: c.dentista_nome,
especialidadeId: c.especialidade_id,
especialidadeNome: c.especialidade_nome,
dataInicio: c.data_inicio,
dataFim: c.data_fim,
formaPagamento: c.forma_pagamento,
createdAt: c.created_at,
items: (c.items || []).map(i => ({
...i,
contratoId: i.contrato_id,
procedimentoNome: i.procedimento_nome,
valorUnitario: parseFloat(i.valor_unitario || 0),
valorTotal: parseFloat(i.valor_total || 0),
}))
})));
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.post('/api/contratos', async (req, res) => {
try {
const { items, ...contrato } = req.body;
if (!contrato.id) contrato.id = Math.random().toString(36).substring(2, 12).toUpperCase();
await withTransaction(async (client) => {
const ins = buildInsert('contratos', contrato);
await client.query(ins.text, ins.values);
if (items?.length) {
for (const item of items) {
if (!item.id) item.id = Math.random().toString(36).substring(2, 12);
const iIns = buildInsert('contrato_itens', { ...item, contrato_id: contrato.id });
await client.query(iIns.text, iIns.values);
}
}
});
res.json({ id: contrato.id });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.put('/api/contratos/:id', async (req, res) => {
try {
const { items, ...contrato } = req.body;
await withTransaction(async (client) => {
const upd = buildUpdate('contratos', contrato, 'id', req.params.id);
await client.query(upd.text, upd.values);
await client.query('DELETE FROM contrato_itens WHERE contrato_id = $1', [req.params.id]);
if (items?.length) {
for (const item of items) {
if (!item.id) item.id = Math.random().toString(36).substring(2, 12);
const iIns = buildInsert('contrato_itens', { ...item, contrato_id: req.params.id });
await client.query(iIns.text, iIns.values);
}
}
});
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
app.delete('/api/contratos/:id', async (req, res) => {
try {
await pool.query('DELETE FROM contrato_itens WHERE contrato_id = $1', [req.params.id]);
await pool.query('DELETE FROM contratos WHERE id = $1', [req.params.id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ error: err.message }); }
});
// --- LEADS ---
app.get('/api/leads', async (req, res) => {
try {
@@ -1819,6 +1904,37 @@ async function runMigrations() {
observacao TEXT,
ordem INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS contratos (
id TEXT PRIMARY KEY,
clinica_id TEXT,
paciente_id TEXT,
paciente_nome TEXT NOT NULL,
dentista_id TEXT,
dentista_nome TEXT,
especialidade_id TEXT,
especialidade_nome TEXT,
titulo TEXT NOT NULL,
status TEXT DEFAULT 'Rascunho',
data_inicio TEXT,
data_fim TEXT,
valor_total NUMERIC DEFAULT 0,
entrada NUMERIC DEFAULT 0,
parcelas INTEGER DEFAULT 1,
forma_pagamento TEXT DEFAULT 'Pix',
clausulas TEXT,
observacoes TEXT,
created_at TIMESTAMP DEFAULT NOW()
)`,
`CREATE TABLE IF NOT EXISTS contrato_itens (
id TEXT PRIMARY KEY,
contrato_id TEXT,
procedimento_nome TEXT NOT NULL,
quantidade INTEGER DEFAULT 1,
valor_unitario NUMERIC DEFAULT 0,
valor_total NUMERIC DEFAULT 0,
descricao TEXT,
ordem INTEGER DEFAULT 0
)`,
];
for (const sql of migrations) {
try { await pool.query(sql); } catch (e) { console.error('[MIGRATION]', e.message); }