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:
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user