Per-Anbieter default VO with auto-select in the schaltung wizard

- New nullable anbieter.default_vo_id column (migration adds it to
  existing DBs); deleting a VO that some provider points at clears
  the reference automatically.
- GET /api/anbieter now includes default_vo_id + default_vo_nummer
  + default_vo_bezeichnung so the wizard can preselect without an
  extra round-trip.
- New PUT /api/anbieter/:id/default-vo endpoint. Validates the VO
  belongs to the given provider; accepts vo_id=null to clear.
- VO admin table grew a "Standard" column and an "Als Standard" /
  "Standard entfernen" button on each row.
- When the user picks a provider in step 1 of the wizard, the VO
  dropdown now preselects the default VO (falling back to the only
  VO if there is exactly one), and immediately loads the
  matching tariffs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
kerimatilganandClaude Opus 4.7 committed 2026-05-15 16:46:03 +02:00
1 parent 97ff779bcf
commit 041df61b24
5 files changed
+82 -16

No files matched your search

+4 -1
View File
@@ -55,12 +55,15 @@ function initDatabase() {
const db = getDatabase();
db.serialize(() => {
// Anbieter (Providers)
// Anbieter (Providers). default_vo_id picks which of this provider's
// VOs is preselected in the schaltung wizard.
db.run(`CREATE TABLE IF NOT EXISTS anbieter (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
default_vo_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
addColumnIfMissing(db, 'anbieter', 'default_vo_id INTEGER');
// Mitarbeiter-Prozentsätze pro Anbieter
db.run(`CREATE TABLE IF NOT EXISTS mitarbeiter_prozentsaetze (
+2
View File
@@ -1273,6 +1273,7 @@
<div class="admin-content-section">
<h4>Bestehende VOs</h4>
<p style="color:#666; font-size:13px;">Die mit ★ markierte VO wird bei der Schaltungserfassung automatisch vorausgewählt.</p>
<div class="table-container">
<table class="admin-table">
<thead>
@@ -1281,6 +1282,7 @@
<th>Nummer</th>
<th>Bezeichnung</th>
<th>Aktiv</th>
<th>Standard</th>
<th>Aktionen</th>
</tr>
</thead>
+51 -14
View File
@@ -68,7 +68,7 @@ async function renderWizardAnbieterButtons() {
btn.className = 'btn-success';
btn.style.cssText = 'padding:14px 22px; font-size:15px; min-width:140px;';
btn.textContent = a.name;
btn.addEventListener('click', () => wizardSelectAnbieter(a.id, a.name));
btn.addEventListener('click', () => wizardSelectAnbieter(a.id, a.name, a.default_vo_id));
container.appendChild(btn);
});
} catch (e) {
@@ -76,15 +76,17 @@ async function renderWizardAnbieterButtons() {
}
}
async function wizardSelectAnbieter(anbieterId, anbieterName) {
async function wizardSelectAnbieter(anbieterId, anbieterName, defaultVoId) {
document.getElementById('anbieter').value = anbieterId;
document.getElementById('wizardAnbieterLabel').textContent = '· ' + anbieterName;
document.getElementById('wizardStep1').style.display = 'none';
document.getElementById('wizardStep2').style.display = 'block';
// Load VOs for this provider
const voSelect = document.getElementById('wizardVo');
voSelect.innerHTML = '<option value="">VO wählen...</option>';
document.getElementById('wizardTarif').innerHTML = '<option value="">Erst VO wählen...</option>';
document.getElementById('tarifoptionenSection').style.display = 'none';
try {
const vos = await apiRequest(`/api/vos?anbieter_id=${encodeURIComponent(anbieterId)}`);
vos.forEach(v => {
@@ -93,12 +95,20 @@ async function wizardSelectAnbieter(anbieterId, anbieterName) {
});
if (vos.length === 0) {
showMessage('Für diesen Anbieter wurden noch keine VOs angelegt. Bitte in der Administration → VOs eine VO anlegen.', 'error');
return;
}
// Preselect the provider's default VO if set, otherwise the first VO if it is the only one.
let preselect = null;
if (defaultVoId && vos.some(v => v.id === defaultVoId)) {
preselect = defaultVoId;
} else if (vos.length === 1) {
preselect = vos[0].id;
}
if (preselect) {
voSelect.value = String(preselect);
await loadTarifeForWizard();
}
} catch (e) { /* error shown */ }
// Reset Tarif + Tarifoptionen
document.getElementById('wizardTarif').innerHTML = '<option value="">Erst VO wählen...</option>';
document.getElementById('tarifoptionenSection').style.display = 'none';
}
function wizardBackToStep1() {
@@ -4067,22 +4077,34 @@ async function loadAnbieterForVoForm() {
async function loadVosAdmin() {
const tbody = document.getElementById('vosList');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:#666;">Lade...</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#666;">Lade...</td></tr>';
try {
const vos = await apiRequest('/api/vos');
// Load anbieter to know each one's default_vo_id.
const [vos, anbieter] = await Promise.all([
apiRequest('/api/vos'),
apiRequest('/api/anbieter'),
]);
const defaultByAnbieter = {};
anbieter.forEach(a => { defaultByAnbieter[a.id] = a.default_vo_id; });
tbody.innerHTML = '';
if (!vos.length) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:#666;">Keine VOs vorhanden</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#666;">Keine VOs vorhanden</td></tr>';
return;
}
vos.forEach(v => {
const isDefault = defaultByAnbieter[v.anbieter_id] === v.id;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${escapeHtml(v.anbieter_name)}</td>
<td>${escapeHtml(v.nummer)}</td>
<td>${escapeHtml(v.bezeichnung || '')}</td>
<td>${v.aktiv ? '<span style="color:#27ae60;">Ja</span>' : '<span style="color:#c0392b;">Nein</span>'}</td>
<td>${isDefault ? '<span title="Standard-VO" style="color:#f39c12; font-size:18px;">★</span>' : ''}</td>
<td>
${isDefault
? `<button data-vo-action="unset-default" data-anbieter-id="${v.anbieter_id}" style="margin-right:5px;">Standard entfernen</button>`
: `<button data-vo-action="set-default" data-anbieter-id="${v.anbieter_id}" data-id="${v.id}" style="margin-right:5px;">Als Standard</button>`}
<button data-vo-action="edit" data-id="${v.id}"
data-anbieter-name="${escapeHtml(v.anbieter_name)}"
data-nummer="${escapeHtml(v.nummer)}"
@@ -4094,21 +4116,36 @@ async function loadVosAdmin() {
tr.querySelectorAll('button[data-vo-action]').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.dataset.voAction;
const id = parseInt(btn.dataset.id, 10);
if (action === 'edit') {
editVo(id, btn.dataset.nummer, btn.dataset.bezeichnung, btn.dataset.aktiv === '1', btn.dataset.anbieterName);
editVo(parseInt(btn.dataset.id, 10), btn.dataset.nummer, btn.dataset.bezeichnung, btn.dataset.aktiv === '1', btn.dataset.anbieterName);
} else if (action === 'delete') {
deleteVo(id, btn.dataset.label);
deleteVo(parseInt(btn.dataset.id, 10), btn.dataset.label);
} else if (action === 'set-default') {
setAnbieterDefaultVo(parseInt(btn.dataset.anbieterId, 10), parseInt(btn.dataset.id, 10));
} else if (action === 'unset-default') {
setAnbieterDefaultVo(parseInt(btn.dataset.anbieterId, 10), null);
}
});
});
tbody.appendChild(tr);
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center; color:#c0392b;">Fehler beim Laden</td></tr>';
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; color:#c0392b;">Fehler beim Laden</td></tr>';
}
}
async function setAnbieterDefaultVo(anbieterId, voId) {
try {
await apiRequest(`/api/anbieter/${anbieterId}/default-vo`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vo_id: voId }),
});
showMessage(voId ? 'Standard-VO gesetzt' : 'Standard-VO entfernt');
loadVosAdmin();
} catch (e) { /* error already shown */ }
}
async function addVo() {
const anbieter_id = document.getElementById('voAnbieter').value;
const nummer = document.getElementById('voNummer').value.trim();
+23 -1
View File
@@ -5,10 +5,32 @@ const asyncHandler = require('../middleware/asyncHandler');
const router = express.Router();
router.get('/', asyncHandler(async (req, res) => {
const rows = await db.all('SELECT * FROM anbieter ORDER BY name');
const rows = await db.all(
`SELECT a.*, v.nummer AS default_vo_nummer, v.bezeichnung AS default_vo_bezeichnung
FROM anbieter a
LEFT JOIN vos v ON a.default_vo_id = v.id
ORDER BY a.name`
);
res.json(rows);
}));
// Set or clear the default VO for a provider. Pass vo_id=null to clear.
router.put('/:id/default-vo', asyncHandler(async (req, res) => {
const { id } = req.params;
const { vo_id } = req.body || {};
const anbieter = await db.get('SELECT id FROM anbieter WHERE id = ?', [id]);
if (!anbieter) return res.status(404).json({ error: 'Anbieter nicht gefunden' });
if (vo_id) {
const vo = await db.get('SELECT anbieter_id FROM vos WHERE id = ?', [vo_id]);
if (!vo) return res.status(400).json({ error: 'VO nicht gefunden' });
if (vo.anbieter_id !== Number(id)) {
return res.status(400).json({ error: 'VO gehört nicht zu diesem Anbieter' });
}
}
await db.run('UPDATE anbieter SET default_vo_id = ? WHERE id = ?', [vo_id || null, id]);
res.json({ message: 'Standard-VO aktualisiert', default_vo_id: vo_id || null });
}));
router.post('/', asyncHandler(async (req, res) => {
const { name } = req.body || {};
if (!name || !name.trim()) {
+2
View File
@@ -84,6 +84,8 @@ router.delete('/:id', asyncHandler(async (req, res) => {
if (used.n_schaltungen > 0) parts.push(`${used.n_schaltungen} Schaltung(en)`);
return res.status(400).json({ error: `VO kann nicht gelöscht werden: ${parts.join(', ')} verwenden diese VO` });
}
// Clear any anbieter pointing at this VO as their default before removing it.
await db.run('UPDATE anbieter SET default_vo_id = NULL WHERE default_vo_id = ?', [id]);
const result = await db.run('DELETE FROM vos WHERE id = ?', [id]);
if (result.changes === 0) return res.status(404).json({ error: 'VO nicht gefunden' });
res.json({ message: 'VO erfolgreich gelöscht' });