Erste Fassung, live unter https://pulse.perinco.de. Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV. Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM. Fachlich: - Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5 - Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder aufgebrauchtem Budget, Buchung nur auf den passenden Topf - Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte - Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend) - SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
162 lines
5.9 KiB
JavaScript
162 lines
5.9 KiB
JavaScript
import express from 'express';
|
|
import { pool } from '../db.js';
|
|
import { getSettings } from '../lib/settings.js';
|
|
import { loadProjects } from '../lib/projects.js';
|
|
import { loadEntries } from '../lib/entries.js';
|
|
import { addDays, today } from '../lib/dates.js';
|
|
|
|
const router = express.Router();
|
|
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
|
|
|
|
function normalize(body, creating) {
|
|
const errors = [];
|
|
const out = {};
|
|
if (creating || body.name !== undefined) {
|
|
out.name = str(body.name, 190);
|
|
if (!out.name) errors.push('Kundenname fehlt.');
|
|
}
|
|
if (creating || body.short_name !== undefined) {
|
|
out.short_name = str(body.short_name, 16)?.toUpperCase().replace(/[^A-Z0-9ÄÖÜ_-]/g, '') || null;
|
|
if (!out.short_name) errors.push('Kürzel fehlt (z. B. KUKA) - es steht im Projektcode.');
|
|
}
|
|
for (const [key, max] of [
|
|
['contact_person', 160],
|
|
['email', 190],
|
|
['phone', 60],
|
|
['street', 190],
|
|
['zip', 20],
|
|
['city', 120],
|
|
['country', 80],
|
|
['notes', 5000],
|
|
]) {
|
|
if (body[key] !== undefined) out[key] = str(body[key], max);
|
|
}
|
|
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
|
|
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
|
|
return { out, errors };
|
|
}
|
|
|
|
router.get('/', async (req, res, next) => {
|
|
try {
|
|
const [rows] = await pool.query(
|
|
`SELECT c.*, (SELECT COUNT(*) FROM pulse_projects p WHERE p.customer_id = c.id) AS project_count
|
|
FROM pulse_customers c ORDER BY c.name`
|
|
);
|
|
res.json(rows.map((r) => ({ ...r, is_active: Boolean(r.is_active), project_count: Number(r.project_count) })));
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Uebersicht: welche Kunden haben welche Projekte mit welchen Mitarbeitern,
|
|
* wie viel Umsatz ist erbracht und wie viel noch offen - plus die letzten 12
|
|
* Monate Umsatz fuer die Verlaufslinie.
|
|
*/
|
|
router.get('/overview', async (req, res, next) => {
|
|
try {
|
|
const settings = await getSettings();
|
|
const [customers] = await pool.query('SELECT * FROM pulse_customers ORDER BY name');
|
|
const projects = await loadProjects(settings);
|
|
const from = `${addDays(today(), -365).slice(0, 7)}-01`;
|
|
const entries = await loadEntries(settings, { from });
|
|
|
|
const months = [];
|
|
const cursor = new Date(`${from}T00:00:00Z`);
|
|
for (let i = 0; i < 13; i++) {
|
|
months.push(cursor.toISOString().slice(0, 7));
|
|
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
|
|
}
|
|
|
|
const result = customers.map((customer) => {
|
|
const own = projects.filter((p) => p.customer_id === customer.id);
|
|
const memberMap = new Map();
|
|
for (const project of own) for (const m of project.members) memberMap.set(m.id, m);
|
|
const monthly = months.map((month) =>
|
|
entries
|
|
.filter((e) => e.customer_id === customer.id && e.entry_date.startsWith(month))
|
|
.reduce((sum, e) => sum + e.amount + e.fixed_amount, 0)
|
|
);
|
|
return {
|
|
...customer,
|
|
is_active: Boolean(customer.is_active),
|
|
projects: own.map((p) => ({
|
|
id: p.id,
|
|
code: p.code,
|
|
name: p.name,
|
|
display_name: p.display_name,
|
|
type: p.type,
|
|
is_active: p.is_active,
|
|
used_minutes: p.used_minutes,
|
|
budget_hours: p.budget_hours,
|
|
earned: p.earned,
|
|
open: p.open,
|
|
budget_value: p.budget_value,
|
|
members: p.members,
|
|
end_date: p.end_date,
|
|
})),
|
|
members: [...memberMap.values()],
|
|
used_minutes: own.reduce((s, p) => s + p.used_minutes, 0),
|
|
earned: Math.round(own.reduce((s, p) => s + p.earned, 0) * 100) / 100,
|
|
open: Math.round(own.reduce((s, p) => s + p.open, 0) * 100) / 100,
|
|
monthly: monthly.map((v) => Math.round(v * 100) / 100),
|
|
};
|
|
});
|
|
res.json({ months, customers: result });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.post('/', async (req, res, next) => {
|
|
const { out, errors } = normalize(req.body || {}, true);
|
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
|
try {
|
|
const keys = Object.keys(out);
|
|
const [result] = await pool.query(
|
|
`INSERT INTO pulse_customers (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
|
|
keys.map((k) => out[k])
|
|
);
|
|
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [result.insertId]);
|
|
res.status(201).json(row);
|
|
} catch (err) {
|
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.put('/:id', async (req, res, next) => {
|
|
const { out, errors } = normalize(req.body || {}, false);
|
|
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
|
try {
|
|
const keys = Object.keys(out);
|
|
if (keys.length) {
|
|
const [result] = await pool.query(`UPDATE pulse_customers SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
|
|
...keys.map((k) => out[k]),
|
|
Number(req.params.id),
|
|
]);
|
|
if (!result.affectedRows) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
|
}
|
|
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
|
|
res.json(row);
|
|
} catch (err) {
|
|
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
router.delete('/:id', async (req, res, next) => {
|
|
try {
|
|
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_projects WHERE customer_id = ?', [Number(req.params.id)]);
|
|
if (Number(n) > 0) {
|
|
return res.status(409).json({ error: `Dem Kunden sind ${n} Projekte zugeordnet. Bitte deaktivieren statt löschen.` });
|
|
}
|
|
await pool.query('DELETE FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export default router;
|