fix(bestellungen): automate delivery status transitions, enable received-qty input for creators, and add im_haus tracking to positionen
This commit is contained in:
@@ -8,6 +8,20 @@ import fs from 'fs';
|
|||||||
const param = (req: Request, key: string): string => req.params[key] as string;
|
const param = (req: Request, key: string): string => req.params[key] as string;
|
||||||
|
|
||||||
class BestellungController {
|
class BestellungController {
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Members
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async listMembers(_req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const members = await bestellungService.getAllMembers();
|
||||||
|
res.status(200).json({ success: true, data: members });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BestellungController.listMembers error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Catalog (shared ausruestung_artikel)
|
// Catalog (shared ausruestung_artikel)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
-- Migration 096: Add im_haus to internal request positions and FK back from external order positions
|
||||||
|
--
|
||||||
|
-- im_haus (ausruestung_anfrage_positionen): auto-set when external bestellposition is received
|
||||||
|
-- anfrage_position_id (bestellpositionen): explicit link so receipt can sync back
|
||||||
|
|
||||||
|
ALTER TABLE ausruestung_anfrage_positionen
|
||||||
|
ADD COLUMN IF NOT EXISTS im_haus BOOLEAN DEFAULT false;
|
||||||
|
|
||||||
|
ALTER TABLE bestellpositionen
|
||||||
|
ADD COLUMN IF NOT EXISTS anfrage_position_id INT
|
||||||
|
REFERENCES ausruestung_anfrage_positionen(id) ON DELETE SET NULL;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import bestellungController from '../controllers/bestellung.controller';
|
import bestellungController from '../controllers/bestellung.controller';
|
||||||
import { authenticate } from '../middleware/auth.middleware';
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
import { requirePermission } from '../middleware/rbac.middleware';
|
import { requirePermission, requireAnyPermission } from '../middleware/rbac.middleware';
|
||||||
import { uploadBestellung } from '../middleware/upload';
|
import { uploadBestellung } from '../middleware/upload';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -77,6 +77,17 @@ router.get(
|
|||||||
bestellungController.listKatalogKategorien.bind(bestellungController)
|
bestellungController.listKatalogKategorien.bind(bestellungController)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Members (all active users for member selector)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
router.get(
|
||||||
|
'/members',
|
||||||
|
authenticate,
|
||||||
|
requirePermission('bestellungen:view'),
|
||||||
|
bestellungController.listMembers.bind(bestellungController)
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Orders (Bestellungen)
|
// Orders (Bestellungen)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -159,8 +170,7 @@ router.delete(
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/items/:itemId/received',
|
'/items/:itemId/received',
|
||||||
authenticate,
|
authenticate,
|
||||||
requirePermission('bestellungen:manage_orders'),
|
requireAnyPermission('bestellungen:manage_orders', 'bestellungen:create'), bestellungController.updateReceivedQuantity.bind(bestellungController)
|
||||||
bestellungController.updateReceivedQuantity.bind(bestellungController)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -325,6 +325,7 @@ async function getRequests(filters?: { status?: string; anfrager_id?: string })
|
|||||||
a.fuer_benutzer_name,
|
a.fuer_benutzer_name,
|
||||||
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count,
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count,
|
||||||
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count,
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count,
|
||||||
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.im_haus) AS im_haus_count,
|
||||||
EXISTS(
|
EXISTS(
|
||||||
SELECT 1 FROM ausruestung_anfrage_bestellung ab
|
SELECT 1 FROM ausruestung_anfrage_bestellung ab
|
||||||
JOIN bestellungen b ON b.id = ab.bestellung_id
|
JOIN bestellungen b ON b.id = ab.bestellung_id
|
||||||
@@ -346,6 +347,7 @@ async function getMyRequests(userId: string) {
|
|||||||
`SELECT a.*,
|
`SELECT a.*,
|
||||||
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count,
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id) AS positionen_count,
|
||||||
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count,
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.geliefert) AS geliefert_count,
|
||||||
|
(SELECT COUNT(*)::int FROM ausruestung_anfrage_positionen p WHERE p.anfrage_id = a.id AND p.im_haus) AS im_haus_count,
|
||||||
EXISTS(
|
EXISTS(
|
||||||
SELECT 1 FROM ausruestung_anfrage_bestellung ab
|
SELECT 1 FROM ausruestung_anfrage_bestellung ab
|
||||||
JOIN bestellungen b ON b.id = ab.bestellung_id
|
JOIN bestellungen b ON b.id = ab.bestellung_id
|
||||||
@@ -859,9 +861,9 @@ async function createOrdersFromRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen)
|
`INSERT INTO bestellpositionen (bestellung_id, bezeichnung, menge, einheit, notizen, artikel_id, spezifikationen, anfrage_position_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)`,
|
||||||
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen)]
|
[bestellung.id, pos.bezeichnung, pos.menge, pos.einheit || 'Stk', pos.notizen || null, artikelId, JSON.stringify(spezifikationen), pos.position_id || null]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,25 @@ async function getKatalogKategorien() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Members (all active users for member selector)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function getAllMembers() {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT id, COALESCE(given_name || ' ' || family_name, name) AS name
|
||||||
|
FROM users
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY name`,
|
||||||
|
);
|
||||||
|
return result.rows;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('BestellungService.getAllMembers failed', { error });
|
||||||
|
throw new Error('Mitglieder konnten nicht geladen werden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Vendors (Lieferanten)
|
// Vendors (Lieferanten)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -574,6 +593,15 @@ async function updateReceivedQuantity(id: number, menge: number, userId: string)
|
|||||||
const item = result.rows[0];
|
const item = result.rows[0];
|
||||||
await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.bezeichnung}": ${menge} von ${item.menge} erhalten`, userId);
|
await logAction(item.bestellung_id, 'Liefermenge aktualisiert', `"${item.bezeichnung}": ${menge} von ${item.menge} erhalten`, userId);
|
||||||
|
|
||||||
|
// Sync im_haus on linked internal request position
|
||||||
|
if (item.anfrage_position_id) {
|
||||||
|
const imHaus = Number(item.erhalten_menge) >= Number(item.menge);
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE ausruestung_anfrage_positionen SET im_haus = $1 WHERE id = $2`,
|
||||||
|
[imHaus, item.anfrage_position_id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if all items for this order are fully received
|
// Check if all items for this order are fully received
|
||||||
const allItems = await pool.query(
|
const allItems = await pool.query(
|
||||||
`SELECT menge, erhalten_menge FROM bestellpositionen WHERE bestellung_id = $1`,
|
`SELECT menge, erhalten_menge FROM bestellpositionen WHERE bestellung_id = $1`,
|
||||||
@@ -760,6 +788,8 @@ async function getHistory(bestellungId: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
// Members
|
||||||
|
getAllMembers,
|
||||||
// Catalog
|
// Catalog
|
||||||
getKatalogItems,
|
getKatalogItems,
|
||||||
getKatalogItem,
|
getKatalogItem,
|
||||||
|
|||||||
@@ -109,8 +109,10 @@ function MeineAnfragenTab() {
|
|||||||
<TableCell>{r.bezeichnung || '-'}</TableCell>
|
<TableCell>{r.bezeichnung || '-'}</TableCell>
|
||||||
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{r.im_haus && r.geliefert_count != null && r.positionen_count != null && r.geliefert_count < r.positionen_count
|
{r.im_haus_count != null && r.positionen_count != null && r.im_haus_count > 0 && r.im_haus_count < r.positionen_count
|
||||||
? <Chip label="Teilweise im Haus" color="warning" size="small" />
|
? <Chip label="Teilweise im Haus" color="warning" size="small" />
|
||||||
|
: r.im_haus_count != null && r.positionen_count != null && r.im_haus_count > 0 && r.im_haus_count >= r.positionen_count
|
||||||
|
? <Chip label="Im Haus" color="success" size="small" />
|
||||||
: r.im_haus ? <Chip label="Im Haus" color="success" size="small" /> : null}
|
: r.im_haus ? <Chip label="Im Haus" color="success" size="small" /> : null}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
||||||
@@ -224,8 +226,10 @@ function AlleAnfragenTab() {
|
|||||||
<TableCell>{r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id}</TableCell>
|
<TableCell>{r.fuer_benutzer_name || r.anfrager_name || r.anfrager_id}</TableCell>
|
||||||
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
<TableCell><Chip label={AUSRUESTUNG_STATUS_LABELS[r.status]} color={AUSRUESTUNG_STATUS_COLORS[r.status]} size="small" /></TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{r.im_haus && r.geliefert_count != null && r.positionen_count != null && r.geliefert_count < r.positionen_count
|
{r.im_haus_count != null && r.positionen_count != null && r.im_haus_count > 0 && r.im_haus_count < r.positionen_count
|
||||||
? <Chip label="Teilweise im Haus" color="warning" size="small" />
|
? <Chip label="Teilweise im Haus" color="warning" size="small" />
|
||||||
|
: r.im_haus_count != null && r.positionen_count != null && r.im_haus_count > 0 && r.im_haus_count >= r.positionen_count
|
||||||
|
? <Chip label="Im Haus" color="success" size="small" />
|
||||||
: r.im_haus ? <Chip label="Im Haus" color="success" size="small" /> : null}
|
: r.im_haus ? <Chip label="Im Haus" color="success" size="small" /> : null}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
<TableCell>{r.positionen_count ?? r.items_count ?? '-'}</TableCell>
|
||||||
|
|||||||
@@ -220,8 +220,8 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
<>
|
<>
|
||||||
{detail?.im_haus && (() => {
|
{detail?.im_haus && (() => {
|
||||||
const total = detail.positionen.length;
|
const total = detail.positionen.length;
|
||||||
const delivered = detail.positionen.filter(p => p.geliefert).length;
|
const imHaus = detail.positionen.filter(p => p.im_haus).length;
|
||||||
return total > 0 && delivered < total
|
return total > 0 && imHaus < total
|
||||||
? <Chip label="Teilweise im Haus" color="warning" />
|
? <Chip label="Teilweise im Haus" color="warning" />
|
||||||
: <Chip label="Im Haus" color="success" />;
|
: <Chip label="Im Haus" color="success" />;
|
||||||
})()}
|
})()}
|
||||||
@@ -430,7 +430,7 @@ export default function AusruestungsanfrageDetail() {
|
|||||||
{p.ist_ersatz && (
|
{p.ist_ersatz && (
|
||||||
<Chip label="Ersatzbeschaffung" size="small" color="warning" variant="outlined" />
|
<Chip label="Ersatzbeschaffung" size="small" color="warning" variant="outlined" />
|
||||||
)}
|
)}
|
||||||
{p.geliefert && detail?.im_haus && (
|
{p.im_haus && (
|
||||||
<Chip label="Im Haus" size="small" color="success" />
|
<Chip label="Im Haus" size="small" color="success" />
|
||||||
)}
|
)}
|
||||||
{p.geliefert && p.zuweisung_typ === 'keine' && (
|
{p.geliefert && p.zuweisung_typ === 'keine' && (
|
||||||
|
|||||||
@@ -107,8 +107,8 @@ const STATUS_TRANSITIONS: Record<BestellungStatus, BestellungStatus[]> = {
|
|||||||
entwurf: ['wartet_auf_genehmigung'],
|
entwurf: ['wartet_auf_genehmigung'],
|
||||||
wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'],
|
wartet_auf_genehmigung: ['bereit_zur_bestellung', 'entwurf'],
|
||||||
bereit_zur_bestellung: ['bestellt'],
|
bereit_zur_bestellung: ['bestellt'],
|
||||||
bestellt: ['teillieferung', 'lieferung_pruefen'],
|
bestellt: [], // auto via received qty
|
||||||
teillieferung: ['lieferung_pruefen'],
|
teillieferung: [], // auto via received qty
|
||||||
lieferung_pruefen: ['abgeschlossen'],
|
lieferung_pruefen: ['abgeschlossen'],
|
||||||
abgeschlossen: [],
|
abgeschlossen: [],
|
||||||
};
|
};
|
||||||
@@ -547,7 +547,6 @@ export default function BestellungDetail() {
|
|||||||
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
const validTransitions = bestellung ? STATUS_TRANSITIONS[bestellung.status] : [];
|
||||||
const allCostsEntered = positionen.length === 0 || positionen.every(p => p.einzelpreis != null && Number(p.einzelpreis) > 0);
|
const allCostsEntered = positionen.length === 0 || positionen.every(p => p.einzelpreis != null && Number(p.einzelpreis) > 0);
|
||||||
const allItemsReceived = positionen.length > 0 && positionen.every(p => Number(p.erhalten_menge) >= Number(p.menge));
|
const allItemsReceived = positionen.length > 0 && positionen.every(p => Number(p.erhalten_menge) >= Number(p.menge));
|
||||||
const anyItemReceived = positionen.some(p => Number(p.erhalten_menge) > 0);
|
|
||||||
|
|
||||||
// All statuses except current, for force override
|
// All statuses except current, for force override
|
||||||
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen'];
|
const ALL_STATUSES: BestellungStatus[] = ['entwurf', 'wartet_auf_genehmigung', 'bereit_zur_bestellung', 'bestellt', 'teillieferung', 'lieferung_pruefen', 'abgeschlossen'];
|
||||||
@@ -1216,8 +1215,7 @@ export default function BestellungDetail() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
color={color as 'error' | 'info' | 'success'}
|
color={color as 'error' | 'info' | 'success'}
|
||||||
disabled={
|
disabled={
|
||||||
(isAbgeschlossen && (!allCostsEntered || !allItemsReceived)) ||
|
isAbgeschlossen && (!allCostsEntered || !allItemsReceived)
|
||||||
(s === 'teillieferung' && !anyItemReceived)
|
|
||||||
}
|
}
|
||||||
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
|
onClick={() => { setStatusForce(false); setStatusConfirmTarget(s); }}
|
||||||
>
|
>
|
||||||
@@ -1340,10 +1338,10 @@ export default function BestellungDetail() {
|
|||||||
{formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))}
|
{formatCurrency((editItemsData[p.id]?.einzelpreis ?? parseFloat(String(p.einzelpreis ?? 0))) * (editItemsData[p.id]?.menge ?? parseFloat(String(p.menge))))}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{canManageOrders ? (
|
{(canManageOrders || canCreate) ? (
|
||||||
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
|
<TextField size="small" type="number" sx={{ width: 70 }} value={p.erhalten_menge}
|
||||||
inputProps={{ min: 0, max: p.menge }}
|
inputProps={{ min: 0, max: p.menge }}
|
||||||
disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)}
|
disabled={!['bestellt', 'teillieferung'].includes(bestellung.status)}
|
||||||
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
|
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })} />
|
||||||
) : p.erhalten_menge}
|
) : p.erhalten_menge}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1408,14 +1406,14 @@ export default function BestellungDetail() {
|
|||||||
<TableCell align="right">{formatCurrency(p.einzelpreis)}</TableCell>
|
<TableCell align="right">{formatCurrency(p.einzelpreis)}</TableCell>
|
||||||
<TableCell align="right">{formatCurrency((p.einzelpreis ?? 0) * p.menge)}</TableCell>
|
<TableCell align="right">{formatCurrency((p.einzelpreis ?? 0) * p.menge)}</TableCell>
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{canManageOrders ? (
|
{(canManageOrders || canCreate) ? (
|
||||||
<TextField
|
<TextField
|
||||||
size="small"
|
size="small"
|
||||||
type="number"
|
type="number"
|
||||||
sx={{ width: 70 }}
|
sx={{ width: 70 }}
|
||||||
value={p.erhalten_menge}
|
value={p.erhalten_menge}
|
||||||
inputProps={{ min: 0, max: p.menge }}
|
inputProps={{ min: 0, max: p.menge }}
|
||||||
disabled={!['bestellt', 'teillieferung', 'lieferung_pruefen'].includes(bestellung.status)}
|
disabled={!['bestellt', 'teillieferung'].includes(bestellung.status)}
|
||||||
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
|
onChange={(e) => updateReceived.mutate({ itemId: p.id, menge: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
IconButton,
|
IconButton,
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
Divider,
|
||||||
|
MenuItem,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Add as AddIcon,
|
Add as AddIcon,
|
||||||
RemoveCircleOutline as RemoveIcon,
|
RemoveCircleOutline as RemoveIcon,
|
||||||
|
Delete as DeleteIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -20,12 +23,71 @@ import { PageHeader, FormLayout } from '../components/templates';
|
|||||||
import { useNotification } from '../contexts/NotificationContext';
|
import { useNotification } from '../contexts/NotificationContext';
|
||||||
import { bestellungApi } from '../services/bestellung';
|
import { bestellungApi } from '../services/bestellung';
|
||||||
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
import { ausruestungsanfrageApi } from '../services/ausruestungsanfrage';
|
||||||
import type { BestellungFormData, BestellpositionFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
import type { BestellungFormData, LieferantFormData, Lieferant } from '../types/bestellung.types';
|
||||||
import type { AusruestungArtikel } from '../types/ausruestungsanfrage.types';
|
import type { AusruestungArtikel, AusruestungEigenschaft } from '../types/ausruestungsanfrage.types';
|
||||||
|
|
||||||
|
// ── EigenschaftFields (same pattern as AusruestungsanfrageNeu) ──
|
||||||
|
|
||||||
|
interface EigenschaftFieldsProps {
|
||||||
|
eigenschaften: AusruestungEigenschaft[];
|
||||||
|
values: Record<number, string>;
|
||||||
|
onChange: (eigenschaftId: number, wert: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EigenschaftFields({ eigenschaften, values, onChange }: EigenschaftFieldsProps) {
|
||||||
|
if (eigenschaften.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, ml: 2, mt: 1, pl: 1.5, borderLeft: '2px solid', borderColor: 'divider' }}>
|
||||||
|
{eigenschaften.map(e => (
|
||||||
|
<Box key={e.id} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
{e.typ === 'options' && e.optionen && e.optionen.length > 0 ? (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
label={e.name}
|
||||||
|
value={values[e.id] || ''}
|
||||||
|
onChange={ev => onChange(e.id, ev.target.value)}
|
||||||
|
required={e.pflicht}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
>
|
||||||
|
<MenuItem value="">—</MenuItem>
|
||||||
|
{e.optionen.map(opt => (
|
||||||
|
<MenuItem key={opt} value={opt}>{opt}</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label={e.name}
|
||||||
|
value={values[e.id] || ''}
|
||||||
|
onChange={ev => onChange(e.id, ev.target.value)}
|
||||||
|
required={e.pflicht}
|
||||||
|
sx={{ minWidth: 160 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Types for form state ──
|
||||||
|
|
||||||
|
interface CatalogPositionForm {
|
||||||
|
artikel_id?: number;
|
||||||
|
bezeichnung: string;
|
||||||
|
menge: number;
|
||||||
|
einheit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FreePositionForm {
|
||||||
|
bezeichnung: string;
|
||||||
|
menge: number;
|
||||||
|
einheit: string;
|
||||||
|
}
|
||||||
|
|
||||||
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', mitglied_id: undefined, notizen: '', positionen: [] };
|
const emptyOrderForm: BestellungFormData = { bezeichnung: '', lieferant_id: undefined, besteller_id: '', mitglied_id: undefined, notizen: '', positionen: [] };
|
||||||
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
const emptyVendorForm: LieferantFormData = { name: '', kontakt_name: '', email: '', telefon: '', adresse: '', website: '', notizen: '' };
|
||||||
const emptyPosition: BestellpositionFormData = { bezeichnung: '', menge: 1, einheit: 'Stk' };
|
|
||||||
|
|
||||||
export default function BestellungNeu() {
|
export default function BestellungNeu() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -36,6 +98,16 @@ export default function BestellungNeu() {
|
|||||||
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
|
const [inlineVendorOpen, setInlineVendorOpen] = useState(false);
|
||||||
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
|
const [inlineVendorForm, setInlineVendorForm] = useState<LieferantFormData>({ ...emptyVendorForm });
|
||||||
|
|
||||||
|
// Position state (split into catalog + free text)
|
||||||
|
const [catalogItems, setCatalogItems] = useState<CatalogPositionForm[]>([{ bezeichnung: '', menge: 1, einheit: 'Stk' }]);
|
||||||
|
const [freeItems, setFreeItems] = useState<FreePositionForm[]>([]);
|
||||||
|
|
||||||
|
// Eigenschaften state
|
||||||
|
const [itemEigenschaften, setItemEigenschaften] = useState<Record<number, AusruestungEigenschaft[]>>({});
|
||||||
|
const itemEigenschaftenRef = useRef(itemEigenschaften);
|
||||||
|
itemEigenschaftenRef.current = itemEigenschaften;
|
||||||
|
const [itemEigenschaftValues, setItemEigenschaftValues] = useState<Record<number, Record<number, string>>>({});
|
||||||
|
|
||||||
// ── Queries ──
|
// ── Queries ──
|
||||||
const { data: vendors = [] } = useQuery({
|
const { data: vendors = [] } = useQuery({
|
||||||
queryKey: ['lieferanten'],
|
queryKey: ['lieferanten'],
|
||||||
@@ -47,6 +119,11 @@ export default function BestellungNeu() {
|
|||||||
queryFn: bestellungApi.getOrderUsers,
|
queryFn: bestellungApi.getOrderUsers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: allMembers = [] } = useQuery({
|
||||||
|
queryKey: ['bestellungen', 'all-members'],
|
||||||
|
queryFn: bestellungApi.getAllMembers,
|
||||||
|
});
|
||||||
|
|
||||||
const { data: katalogItems = [] } = useQuery({
|
const { data: katalogItems = [] } = useQuery({
|
||||||
queryKey: ['katalog'],
|
queryKey: ['katalog'],
|
||||||
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
queryFn: () => ausruestungsanfrageApi.getItems({ aktiv: true }),
|
||||||
@@ -76,9 +153,68 @@ export default function BestellungNeu() {
|
|||||||
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
|
onError: () => showError('Fehler beim Erstellen des Lieferanten'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Eigenschaft loading ──
|
||||||
|
const loadEigenschaften = useCallback(async (artikelId: number) => {
|
||||||
|
if (itemEigenschaftenRef.current[artikelId]) return;
|
||||||
|
try {
|
||||||
|
const eigs = await ausruestungsanfrageApi.getArtikelEigenschaften(artikelId);
|
||||||
|
if (eigs && eigs.length > 0) {
|
||||||
|
setItemEigenschaften(prev => ({ ...prev, [artikelId]: eigs }));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to load eigenschaften for artikel', artikelId, err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// ── Submit ──
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
if (!orderForm.bezeichnung.trim()) return;
|
if (!orderForm.bezeichnung.trim()) return;
|
||||||
createOrder.mutate(orderForm);
|
|
||||||
|
// Build catalog positions with spezifikationen from Eigenschaft values
|
||||||
|
const catalogPositionen = catalogItems
|
||||||
|
.filter(i => i.bezeichnung.trim() && i.artikel_id)
|
||||||
|
.map((item, idx) => {
|
||||||
|
const vals = itemEigenschaftValues[idx] || {};
|
||||||
|
const spezifikationen = Object.entries(vals)
|
||||||
|
.filter(([, wert]) => wert.trim())
|
||||||
|
.map(([eid, wert]) => {
|
||||||
|
const eig = item.artikel_id ? itemEigenschaften[item.artikel_id]?.find(e => e.id === Number(eid)) : null;
|
||||||
|
return `${eig?.name || eid}: ${wert}`;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
bezeichnung: item.bezeichnung,
|
||||||
|
menge: item.menge,
|
||||||
|
einheit: item.einheit,
|
||||||
|
artikel_id: item.artikel_id,
|
||||||
|
spezifikationen: spezifikationen.length > 0 ? spezifikationen : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build free text positions
|
||||||
|
const freePositionen = freeItems
|
||||||
|
.filter(i => i.bezeichnung.trim())
|
||||||
|
.map(i => ({
|
||||||
|
bezeichnung: i.bezeichnung,
|
||||||
|
menge: i.menge,
|
||||||
|
einheit: i.einheit,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check required Eigenschaften for catalog items
|
||||||
|
for (let idx = 0; idx < catalogItems.length; idx++) {
|
||||||
|
const item = catalogItems[idx];
|
||||||
|
if (!item.bezeichnung.trim() || !item.artikel_id) continue;
|
||||||
|
if (itemEigenschaften[item.artikel_id]) {
|
||||||
|
for (const e of itemEigenschaften[item.artikel_id]) {
|
||||||
|
if (e.pflicht && !(itemEigenschaftValues[idx]?.[e.id]?.trim())) {
|
||||||
|
showError(`Pflichtfeld "${e.name}" für "${item.bezeichnung}" fehlt`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPositionen = [...catalogPositionen, ...freePositionen];
|
||||||
|
createOrder.mutate({ ...orderForm, positionen: allPositionen });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -151,10 +287,10 @@ export default function BestellungNeu() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Autocomplete
|
<Autocomplete
|
||||||
options={orderUsers}
|
options={allMembers}
|
||||||
getOptionLabel={(o) => o.name}
|
getOptionLabel={(o) => o.name}
|
||||||
value={orderUsers.find((u) => u.id === orderForm.mitglied_id) || null}
|
value={allMembers.find((u) => u.id === orderForm.mitglied_id) || null}
|
||||||
onChange={(_e, v) => setOrderForm((f) => ({ ...f, mitglied_id: v?.id || '' }))}
|
onChange={(_e, v) => setOrderForm((f) => ({ ...f, mitglied_id: v?.id }))}
|
||||||
renderInput={(params) => <TextField {...params} label="Für Mitglied" />}
|
renderInput={(params) => <TextField {...params} label="Für Mitglied" />}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -166,35 +302,26 @@ export default function BestellungNeu() {
|
|||||||
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
|
onChange={(e) => setOrderForm((f) => ({ ...f, notizen: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* ── Positionen ── */}
|
{/* ── Positionen: Katalogartikel ── */}
|
||||||
<Typography variant="subtitle2" sx={{ mt: 1 }}>Positionen</Typography>
|
<Divider />
|
||||||
{(orderForm.positionen || []).map((pos, idx) => (
|
<Typography variant="subtitle2">Aus Katalog</Typography>
|
||||||
<Box key={idx} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
{catalogItems.map((item, idx) => (
|
||||||
<Autocomplete<AusruestungArtikel, false, false, true>
|
<Box key={`cat-${idx}`}>
|
||||||
freeSolo
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
size="small"
|
<Autocomplete<AusruestungArtikel>
|
||||||
options={katalogItems}
|
options={katalogItems}
|
||||||
getOptionLabel={(o) => typeof o === 'string' ? o : o.bezeichnung}
|
getOptionLabel={(o) => o.bezeichnung}
|
||||||
value={pos.bezeichnung || ''}
|
value={item.artikel_id ? katalogItems.find(c => c.id === item.artikel_id) || null : null}
|
||||||
onChange={(_, v) => {
|
onChange={(_, v) => {
|
||||||
const next = [...(orderForm.positionen || [])];
|
if (v) {
|
||||||
if (v && typeof v !== 'string') {
|
setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: v.id, bezeichnung: v.bezeichnung } : it));
|
||||||
next[idx] = { ...next[idx], bezeichnung: v.bezeichnung, artikel_id: v.id };
|
loadEigenschaften(v.id);
|
||||||
} else if (typeof v === 'string') {
|
|
||||||
next[idx] = { ...next[idx], bezeichnung: v, artikel_id: undefined };
|
|
||||||
} else {
|
} else {
|
||||||
next[idx] = { ...next[idx], bezeichnung: '', artikel_id: undefined };
|
setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, artikel_id: undefined, bezeichnung: '' } : it));
|
||||||
}
|
setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
|
||||||
setOrderForm((f) => ({ ...f, positionen: next }));
|
|
||||||
}}
|
|
||||||
onInputChange={(_, val, reason) => {
|
|
||||||
if (reason === 'input') {
|
|
||||||
const next = [...(orderForm.positionen || [])];
|
|
||||||
next[idx] = { ...next[idx], bezeichnung: val, artikel_id: undefined };
|
|
||||||
setOrderForm((f) => ({ ...f, positionen: next }));
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
renderInput={(params) => <TextField {...params} label="Bezeichnung" size="small" required InputLabelProps={{ shrink: true }} />}
|
renderInput={(params) => <TextField {...params} label="Katalogartikel" size="small" />}
|
||||||
renderOption={(props, option) => (
|
renderOption={(props, option) => (
|
||||||
<li {...props} key={option.id}>
|
<li {...props} key={option.id}>
|
||||||
<Box>
|
<Box>
|
||||||
@@ -203,7 +330,7 @@ export default function BestellungNeu() {
|
|||||||
</Box>
|
</Box>
|
||||||
</li>
|
</li>
|
||||||
)}
|
)}
|
||||||
isOptionEqualToValue={(o, v) => o.id === (typeof v === 'object' ? v.id : undefined)}
|
isOptionEqualToValue={(o, v) => o.id === v.id}
|
||||||
sx={{ flexGrow: 1 }}
|
sx={{ flexGrow: 1 }}
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
@@ -211,12 +338,8 @@ export default function BestellungNeu() {
|
|||||||
size="small"
|
size="small"
|
||||||
type="number"
|
type="number"
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
value={pos.menge}
|
value={item.menge}
|
||||||
onChange={(e) => {
|
onChange={(e) => setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value) || 1) } : it))}
|
||||||
const next = [...(orderForm.positionen || [])];
|
|
||||||
next[idx] = { ...next[idx], menge: Math.max(1, Number(e.target.value) || 1) };
|
|
||||||
setOrderForm((f) => ({ ...f, positionen: next }));
|
|
||||||
}}
|
|
||||||
sx={{ width: 90 }}
|
sx={{ width: 90 }}
|
||||||
inputProps={{ min: 1 }}
|
inputProps={{ min: 1 }}
|
||||||
/>
|
/>
|
||||||
@@ -224,32 +347,91 @@ export default function BestellungNeu() {
|
|||||||
label="Einheit"
|
label="Einheit"
|
||||||
size="small"
|
size="small"
|
||||||
InputLabelProps={{ shrink: true }}
|
InputLabelProps={{ shrink: true }}
|
||||||
value={pos.einheit || 'Stk'}
|
value={item.einheit}
|
||||||
onChange={(e) => {
|
onChange={(e) => setCatalogItems(prev => prev.map((it, i) => i === idx ? { ...it, einheit: e.target.value } : it))}
|
||||||
const next = [...(orderForm.positionen || [])];
|
|
||||||
next[idx] = { ...next[idx], einheit: e.target.value };
|
|
||||||
setOrderForm((f) => ({ ...f, positionen: next }));
|
|
||||||
}}
|
|
||||||
sx={{ width: 100 }}
|
sx={{ width: 100 }}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
size="small"
|
size="small"
|
||||||
color="error"
|
color="error"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = (orderForm.positionen || []).filter((_, i) => i !== idx);
|
setCatalogItems(prev => prev.filter((_, i) => i !== idx));
|
||||||
setOrderForm((f) => ({ ...f, positionen: next }));
|
setItemEigenschaftValues(prev => { const n = { ...prev }; delete n[idx]; return n; });
|
||||||
}}
|
}}
|
||||||
|
disabled={catalogItems.length <= 1 && freeItems.length === 0}
|
||||||
>
|
>
|
||||||
<RemoveIcon />
|
<DeleteIcon fontSize="small" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
{item.artikel_id && itemEigenschaften[item.artikel_id] && itemEigenschaften[item.artikel_id].length > 0 && (
|
||||||
|
<EigenschaftFields
|
||||||
|
eigenschaften={itemEigenschaften[item.artikel_id]}
|
||||||
|
values={itemEigenschaftValues[idx] || {}}
|
||||||
|
onChange={(eid, wert) => setItemEigenschaftValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[idx]: { ...(prev[idx] || {}), [eid]: wert },
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
startIcon={<AddIcon />}
|
startIcon={<AddIcon />}
|
||||||
onClick={() => setOrderForm((f) => ({ ...f, positionen: [...(f.positionen || []), { ...emptyPosition }] }))}
|
onClick={() => setCatalogItems(prev => [...prev, { bezeichnung: '', menge: 1, einheit: 'Stk' }])}
|
||||||
>
|
>
|
||||||
Position hinzufügen
|
Katalogartikel hinzufügen
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* ── Positionen: Freitext ── */}
|
||||||
|
<Divider />
|
||||||
|
<Typography variant="subtitle2">Freitext-Positionen</Typography>
|
||||||
|
{freeItems.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">Keine Freitext-Positionen.</Typography>
|
||||||
|
) : (
|
||||||
|
freeItems.map((item, idx) => (
|
||||||
|
<Box key={`free-${idx}`} sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Bezeichnung"
|
||||||
|
value={item.bezeichnung}
|
||||||
|
onChange={(e) => setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, bezeichnung: e.target.value } : it))}
|
||||||
|
sx={{ flexGrow: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Menge"
|
||||||
|
size="small"
|
||||||
|
type="number"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={item.menge}
|
||||||
|
onChange={(e) => setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, menge: Math.max(1, Number(e.target.value) || 1) } : it))}
|
||||||
|
sx={{ width: 90 }}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Einheit"
|
||||||
|
size="small"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
value={item.einheit}
|
||||||
|
onChange={(e) => setFreeItems(prev => prev.map((it, i) => i === idx ? { ...it, einheit: e.target.value } : it))}
|
||||||
|
sx={{ width: 100 }}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
onClick={() => setFreeItems(prev => prev.filter((_, i) => i !== idx))}
|
||||||
|
>
|
||||||
|
<RemoveIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
startIcon={<AddIcon />}
|
||||||
|
onClick={() => setFreeItems(prev => [...prev, { bezeichnung: '', menge: 1, einheit: 'Stk' }])}
|
||||||
|
>
|
||||||
|
Freitext-Position hinzufügen
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* ── Submit ── */}
|
{/* ── Submit ── */}
|
||||||
|
|||||||
@@ -122,6 +122,12 @@ export const bestellungApi = {
|
|||||||
return r.data.data;
|
return r.data.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── All members (for "Für Mitglied" selector) ──
|
||||||
|
getAllMembers: async (): Promise<Array<{ id: string; name: string }>> => {
|
||||||
|
const r = await api.get('/api/bestellungen/members');
|
||||||
|
return r.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
// ── Catalog ──
|
// ── Catalog ──
|
||||||
getKatalogItems: async (filters?: { search?: string; kategorie?: string }): Promise<AusruestungArtikel[]> => {
|
getKatalogItems: async (filters?: { search?: string; kategorie?: string }): Promise<AusruestungArtikel[]> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface AusruestungAnfrage {
|
|||||||
geliefert_count?: number;
|
geliefert_count?: number;
|
||||||
items_count?: number;
|
items_count?: number;
|
||||||
im_haus?: boolean;
|
im_haus?: boolean;
|
||||||
|
im_haus_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AusruestungAnfragePosition {
|
export interface AusruestungAnfragePosition {
|
||||||
@@ -108,6 +109,7 @@ export interface AusruestungAnfragePosition {
|
|||||||
einheit?: string;
|
einheit?: string;
|
||||||
notizen?: string;
|
notizen?: string;
|
||||||
geliefert: boolean;
|
geliefert: boolean;
|
||||||
|
im_haus: boolean;
|
||||||
erstellt_am: string;
|
erstellt_am: string;
|
||||||
eigenschaften?: AusruestungPositionEigenschaft[];
|
eigenschaften?: AusruestungPositionEigenschaft[];
|
||||||
ist_ersatz: boolean;
|
ist_ersatz: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user