feat: personal equipment tracking, order assignment, purge fix, widget consolidation

- Migration 084: new persoenliche_ausruestung table with catalog link, user
  assignment, soft delete; adds zuweisung_typ/ausruestung_id/persoenlich_id
  columns to ausruestung_anfrage_positionen; seeds feature group + 5 permissions

- Fix user data purge: table was shop_anfragen, renamed to ausruestung_anfragen
  in mig 046 — caused full transaction rollback. Also keep mitglieder_profile
  row but NULL FDISK-synced fields (dienstgrad, geburtsdatum, etc.) instead of
  deleting the profile

- Personal equipment CRUD: backend service/controller/routes at
  /api/persoenliche-ausruestung; frontend page with DataTable, user filter,
  catalog Autocomplete, FAB create dialog; widget in Status group; sidebar
  entry (Checkroom icon); card in MitgliedDetail Tab 0

- Ausruestungsanfrage item assignment: when a request reaches erledigt,
  auto-opens ItemAssignmentDialog listing all delivered positions; each item
  can be assigned as general equipment (vehicle/storage), personal item (user,
  prefilled with requester), or not tracked; POST /requests/:id/assign backend

- StatCard refactored to use WidgetCard as outer shell for consistent header
  styling across all dashboard widget templates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Hochmeister
2026-04-13 19:19:35 +02:00
parent b477e5dbe0
commit 1215e9ea70
23 changed files with 1700 additions and 63 deletions

View File

@@ -889,6 +889,145 @@ async function getWidgetOverview() {
return result.rows[0];
}
// ---------------------------------------------------------------------------
// Assignment of delivered items
// ---------------------------------------------------------------------------
interface AssignmentInput {
positionId: number;
typ: 'ausruestung' | 'persoenlich' | 'keine';
fahrzeugId?: string;
standort?: string;
userId?: string;
benutzerName?: string;
groesse?: string;
kategorie?: string;
}
async function assignDeliveredItems(
anfrageId: number,
requestingUserId: string,
assignments: AssignmentInput[],
) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// Load anfrage to get anfrager_id (default user for personal assignments)
const anfrageResult = await client.query(
'SELECT anfrager_id FROM ausruestung_anfragen WHERE id = $1',
[anfrageId],
);
if (anfrageResult.rows.length === 0) {
throw new Error('Anfrage nicht gefunden');
}
const anfragerId = anfrageResult.rows[0].anfrager_id;
let assigned = 0;
for (const a of assignments) {
// Load position details
const posResult = await client.query(
'SELECT bezeichnung, artikel_id FROM ausruestung_anfrage_positionen WHERE id = $1 AND anfrage_id = $2',
[a.positionId, anfrageId],
);
if (posResult.rows.length === 0) continue;
const pos = posResult.rows[0];
if (a.typ === 'ausruestung') {
// Look up kategorie_id from artikel if available
let kategorieId: string | null = null;
if (pos.artikel_id) {
const artikelResult = await client.query(
'SELECT kategorie_id FROM ausruestung_artikel WHERE id = $1',
[pos.artikel_id],
);
if (artikelResult.rows[0]?.kategorie_id) {
// artikel has kategorie_id (int FK to ausruestung_kategorien_katalog), but
// ausruestung.kategorie_id is UUID FK to ausruestung_kategorien — look up by name
const katNameResult = await client.query(
'SELECT name FROM ausruestung_kategorien_katalog WHERE id = $1',
[artikelResult.rows[0].kategorie_id],
);
if (katNameResult.rows[0]?.name) {
const katResult = await client.query(
'SELECT id FROM ausruestung_kategorien WHERE name = $1 LIMIT 1',
[katNameResult.rows[0].name],
);
kategorieId = katResult.rows[0]?.id ?? null;
}
}
}
// Fallback: pick first category
if (!kategorieId) {
const fallback = await client.query('SELECT id FROM ausruestung_kategorien LIMIT 1');
kategorieId = fallback.rows[0]?.id ?? null;
}
const insertResult = await client.query(
`INSERT INTO ausruestung (
id, bezeichnung, kategorie_id, fahrzeug_id, standort, status, ist_wichtig
) VALUES (gen_random_uuid(), $1, $2, $3, $4, 'einsatzbereit', false)
RETURNING id`,
[pos.bezeichnung, kategorieId, a.fahrzeugId ?? null, a.standort ?? 'Lager'],
);
const newId = insertResult.rows[0].id;
await client.query(
`UPDATE ausruestung_anfrage_positionen
SET zuweisung_typ = 'ausruestung', zuweisung_ausruestung_id = $1
WHERE id = $2`,
[newId, a.positionId],
);
} else if (a.typ === 'persoenlich') {
const insertResult = await client.query(
`INSERT INTO persoenliche_ausruestung (
bezeichnung, kategorie, groesse, user_id, benutzer_name,
anfrage_id, anfrage_position_id, artikel_id, erstellt_von
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`,
[
pos.bezeichnung,
a.kategorie ?? null,
a.groesse ?? null,
a.userId ?? anfragerId,
a.benutzerName ?? null,
anfrageId,
a.positionId,
pos.artikel_id ?? null,
requestingUserId,
],
);
const newId = insertResult.rows[0].id;
await client.query(
`UPDATE ausruestung_anfrage_positionen
SET zuweisung_typ = 'persoenlich', zuweisung_persoenlich_id = $1
WHERE id = $2`,
[newId, a.positionId],
);
} else {
// typ === 'keine'
await client.query(
`UPDATE ausruestung_anfrage_positionen SET zuweisung_typ = 'keine' WHERE id = $1`,
[a.positionId],
);
}
assigned++;
}
await client.query('COMMIT');
return { assigned };
} catch (error) {
await client.query('ROLLBACK');
logger.error('ausruestungsanfrageService.assignDeliveredItems failed', { error, anfrageId });
throw error;
} finally {
client.release();
}
}
export default {
getAllUsers,
getKategorien,
@@ -919,4 +1058,5 @@ export default {
createOrdersFromRequest,
getOverview,
getWidgetOverview,
assignDeliveredItems,
};

View File

@@ -0,0 +1,200 @@
import pool from '../config/database';
import logger from '../utils/logger';
interface PersonalEquipmentFilters {
userId?: string;
kategorie?: string;
zustand?: string;
}
interface CreatePersonalEquipmentData {
bezeichnung: string;
kategorie?: string;
artikel_id?: number;
user_id?: string;
benutzer_name?: string;
groesse?: string;
seriennummer?: string;
inventarnummer?: string;
anschaffung_datum?: string;
zustand?: string;
notizen?: string;
}
interface UpdatePersonalEquipmentData {
bezeichnung?: string;
kategorie?: string;
artikel_id?: number | null;
user_id?: string | null;
benutzer_name?: string | null;
groesse?: string | null;
seriennummer?: string | null;
inventarnummer?: string | null;
anschaffung_datum?: string | null;
zustand?: string;
notizen?: string | null;
}
const BASE_SELECT = `
SELECT pa.*,
COALESCE(u.given_name || ' ' || u.family_name, u.name) AS user_display_name,
aa.bezeichnung AS artikel_bezeichnung
FROM persoenliche_ausruestung pa
LEFT JOIN users u ON u.id = pa.user_id
LEFT JOIN ausruestung_artikel aa ON aa.id = pa.artikel_id
WHERE pa.geloescht_am IS NULL
`;
async function getAll(filters: PersonalEquipmentFilters = {}) {
try {
const conditions: string[] = [];
const params: unknown[] = [];
if (filters.userId) {
params.push(filters.userId);
conditions.push(`pa.user_id = $${params.length}`);
}
if (filters.kategorie) {
params.push(filters.kategorie);
conditions.push(`pa.kategorie = $${params.length}`);
}
if (filters.zustand) {
params.push(filters.zustand);
conditions.push(`pa.zustand = $${params.length}`);
}
const where = conditions.length > 0 ? ` AND ${conditions.join(' AND ')}` : '';
const result = await pool.query(
`${BASE_SELECT}${where} ORDER BY pa.bezeichnung`,
params,
);
return result.rows;
} catch (error) {
logger.error('personalEquipmentService.getAll failed', { error });
throw new Error('Failed to fetch personal equipment');
}
}
async function getByUserId(userId: string) {
try {
const result = await pool.query(
`${BASE_SELECT} AND pa.user_id = $1 ORDER BY pa.bezeichnung`,
[userId],
);
return result.rows;
} catch (error) {
logger.error('personalEquipmentService.getByUserId failed', { error, userId });
throw new Error('Failed to fetch personal equipment for user');
}
}
async function getById(id: string) {
try {
const result = await pool.query(
`${BASE_SELECT} AND pa.id = $1`,
[id],
);
return result.rows[0] || null;
} catch (error) {
logger.error('personalEquipmentService.getById failed', { error, id });
throw new Error('Failed to fetch personal equipment item');
}
}
async function create(data: CreatePersonalEquipmentData, requestingUserId: string) {
try {
const result = await pool.query(
`INSERT INTO persoenliche_ausruestung (
bezeichnung, kategorie, artikel_id, user_id, benutzer_name,
groesse, seriennummer, inventarnummer, anschaffung_datum,
zustand, notizen, erstellt_von
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING *`,
[
data.bezeichnung,
data.kategorie ?? null,
data.artikel_id ?? null,
data.user_id ?? null,
data.benutzer_name ?? null,
data.groesse ?? null,
data.seriennummer ?? null,
data.inventarnummer ?? null,
data.anschaffung_datum ?? null,
data.zustand ?? 'gut',
data.notizen ?? null,
requestingUserId,
],
);
logger.info('Personal equipment created', { id: result.rows[0].id, by: requestingUserId });
return result.rows[0];
} catch (error) {
logger.error('personalEquipmentService.create failed', { error });
throw new Error('Failed to create personal equipment');
}
}
async function update(id: string, data: UpdatePersonalEquipmentData) {
try {
const fields: string[] = [];
const values: unknown[] = [];
let p = 1;
const addField = (col: string, value: unknown) => {
fields.push(`${col} = $${p++}`);
values.push(value);
};
if (data.bezeichnung !== undefined) addField('bezeichnung', data.bezeichnung);
if (data.kategorie !== undefined) addField('kategorie', data.kategorie);
if (data.artikel_id !== undefined) addField('artikel_id', data.artikel_id);
if (data.user_id !== undefined) addField('user_id', data.user_id);
if (data.benutzer_name !== undefined) addField('benutzer_name', data.benutzer_name);
if (data.groesse !== undefined) addField('groesse', data.groesse);
if (data.seriennummer !== undefined) addField('seriennummer', data.seriennummer);
if (data.inventarnummer !== undefined) addField('inventarnummer', data.inventarnummer);
if (data.anschaffung_datum !== undefined) addField('anschaffung_datum', data.anschaffung_datum);
if (data.zustand !== undefined) addField('zustand', data.zustand);
if (data.notizen !== undefined) addField('notizen', data.notizen);
if (fields.length === 0) {
throw new Error('No fields to update');
}
values.push(id);
const result = await pool.query(
`UPDATE persoenliche_ausruestung SET ${fields.join(', ')} WHERE id = $${p} AND geloescht_am IS NULL RETURNING *`,
values,
);
if (result.rows.length === 0) return null;
logger.info('Personal equipment updated', { id });
return result.rows[0];
} catch (error) {
logger.error('personalEquipmentService.update failed', { error, id });
throw error;
}
}
async function softDelete(id: string) {
try {
const result = await pool.query(
`UPDATE persoenliche_ausruestung SET geloescht_am = NOW() WHERE id = $1 AND geloescht_am IS NULL RETURNING id`,
[id],
);
if (result.rows.length === 0) return false;
logger.info('Personal equipment soft-deleted', { id });
return true;
} catch (error) {
logger.error('personalEquipmentService.softDelete failed', { error, id });
throw new Error('Failed to delete personal equipment');
}
}
export default {
getAll,
getByUserId,
getById,
create,
update,
delete: softDelete,
};

View File

@@ -350,7 +350,13 @@ class UserService {
// User-owned data tables (DELETE rows)
await purge('notifications');
await purge('mitglieder_profile');
// Keep mitglieder_profile row but NULL FDISK-synced fields
await client.query(
`UPDATE mitglieder_profile SET dienstgrad = NULL, dienstgrad_seit = NULL, eintrittsdatum = NULL, austrittsdatum = NULL, geburtsdatum = NULL, fuehrerscheinklassen = '{}' WHERE user_id = $1`,
[userId]
);
results['mitglieder_profile_fdisk_cleared'] = 1;
await purge('dienstgrad_verlauf');
await purge('atemschutz_traeger');
await purge('ausbildungen');
@@ -362,7 +368,14 @@ class UserService {
await purge('veranstaltung_teilnahmen');
await purge('veranstaltung_ical_tokens');
await purge('fahrzeug_ical_tokens');
await purge('shop_anfragen', 'anfrager_id');
// Delete child positions before parent anfragen (FK constraint)
const posResult = await client.query(
'DELETE FROM ausruestung_anfrage_positionen WHERE anfrage_id IN (SELECT id FROM ausruestung_anfragen WHERE anfrager_id = $1)',
[userId]
);
results['ausruestung_anfrage_positionen'] = posResult.rowCount ?? 0;
await purge('ausruestung_anfragen', 'anfrager_id');
await purge('persoenliche_ausruestung');
// Clear user preferences (widget layout, etc.)
await client.query(