1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:10:22 +01:00

feat(cli): Stammdaten-TUI mit Master Data API-Anbindung

- Neue Screens: Kategorien, Lieferanten, Artikel, Kunden (jeweils
  Liste, Detail, Anlegen + Detailaktionen wie Bewertung, Zertifikate,
  Verkaufseinheiten, Lieferadressen, Präferenzen)
- API-Client: Resources für alle 4 Stammdaten-Aggregate implementiert
  (categories, suppliers, articles, customers) mit Mapping von
  verschachtelten Domain-VOs auf flache DTOs
- Lieferant, Artikel, Kategorie: echte HTTP-Calls gegen Backend
  (/api/suppliers, /api/articles, /api/categories, /api/customers)
- 204-No-Content-Endpoints (removeSalesUnit, removeSupplier,
  removeCertificate, removeDeliveryAddress, removeFrameContract)
  lösen Re-Fetch des Aggregats aus
- MasterdataMenu, Navigation-Erweiterung, App.tsx-Routing
This commit is contained in:
Sebastian Frick 2026-02-18 13:35:20 +01:00
parent 797f435a49
commit d27dbaa843
30 changed files with 3882 additions and 1 deletions

View file

@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../../state/navigation-context.js';
import { useSuppliers } from '../../../hooks/useSuppliers.js';
import { FormInput } from '../../shared/FormInput.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
type Field = 'certificateType' | 'issuer' | 'validFrom' | 'validUntil';
const FIELDS: Field[] = ['certificateType', 'issuer', 'validFrom', 'validUntil'];
const FIELD_LABELS: Record<Field, string> = {
certificateType: 'Zertifikat-Typ *',
issuer: 'Aussteller *',
validFrom: 'Gültig ab * (YYYY-MM-DD)',
validUntil: 'Gültig bis * (YYYY-MM-DD)',
};
function isValidDate(s: string): boolean {
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !isNaN(Date.parse(s));
}
export function AddCertificateScreen() {
const { params, navigate, back } = useNavigation();
const supplierId = params['supplierId'] ?? '';
const { addCertificate, loading, error, clearError } = useSuppliers();
const [values, setValues] = useState<Record<Field, string>>({
certificateType: '',
issuer: '',
validFrom: '',
validUntil: '',
});
const [activeField, setActiveField] = useState<Field>('certificateType');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const setField = (field: Field) => (value: string) => {
setValues((v) => ({ ...v, [field]: value }));
};
useInput((_input, key) => {
if (loading) return;
if (key.tab || key.downArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
});
}
if (key.upArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
});
}
if (key.escape) back();
});
const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!values.certificateType.trim()) errors.certificateType = 'Typ ist erforderlich.';
if (!values.issuer.trim()) errors.issuer = 'Aussteller ist erforderlich.';
if (!isValidDate(values.validFrom)) errors.validFrom = 'Ungültiges Datum (YYYY-MM-DD).';
if (!isValidDate(values.validUntil)) errors.validUntil = 'Ungültiges Datum (YYYY-MM-DD).';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const updated = await addCertificate(supplierId, {
certificateType: values.certificateType.trim().toUpperCase(),
issuer: values.issuer.trim(),
validFrom: values.validFrom,
validUntil: values.validUntil,
});
if (updated) navigate('supplier-detail', { supplierId });
};
const handleFieldSubmit = (field: Field) => (_value: string) => {
const idx = FIELDS.indexOf(field);
if (idx < FIELDS.length - 1) {
setActiveField(FIELDS[idx + 1] ?? field);
} else {
void handleSubmit();
}
};
if (loading) return <LoadingSpinner label="Zertifikat wird hinzugefügt..." />;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Zertifikat hinzufügen</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[field]}
onChange={setField(field)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
placeholder={field.includes('valid') ? '2025-01-01' : ''}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
))}
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>
Tab/ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,115 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../../state/navigation-context.js';
import { useSuppliers } from '../../../hooks/useSuppliers.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
import { SuccessDisplay } from '../../shared/SuccessDisplay.js';
type ScoreField = 'quality' | 'delivery' | 'price';
const FIELDS: ScoreField[] = ['quality', 'delivery', 'price'];
const FIELD_LABELS: Record<ScoreField, string> = {
quality: 'Qualität',
delivery: 'Lieferung',
price: 'Preis',
};
function ScoreSelector({ label, value, active }: { label: string; value: number; active: boolean }) {
return (
<Box gap={2}>
<Text color={active ? 'cyan' : 'gray'} bold={active}>{label.padEnd(12)}</Text>
<Box gap={1}>
{[1, 2, 3, 4, 5].map((n) => (
<Text key={n} color={n <= value ? 'yellow' : 'gray'}>
{n <= value ? '★' : '☆'}
</Text>
))}
<Text color={active ? 'cyan' : 'gray'}> {value}/5</Text>
</Box>
</Box>
);
}
export function RateSupplierScreen() {
const { params, navigate, back } = useNavigation();
const supplierId = params['supplierId'] ?? '';
const { rateSupplier, loading, error, clearError } = useSuppliers();
const [scores, setScores] = useState<Record<ScoreField, number>>({ quality: 3, delivery: 3, price: 3 });
const [activeField, setActiveField] = useState<ScoreField>('quality');
const [successMessage, setSuccessMessage] = useState<string | null>(null);
useInput((_input, key) => {
if (loading) return;
if (key.upArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[Math.max(0, idx - 1)] ?? f;
});
}
if (key.downArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[Math.min(FIELDS.length - 1, idx + 1)] ?? f;
});
}
if (key.leftArrow) {
setScores((s) => ({ ...s, [activeField]: Math.max(1, s[activeField] - 1) }));
}
if (key.rightArrow) {
setScores((s) => ({ ...s, [activeField]: Math.min(5, s[activeField] + 1) }));
}
if (key.return && activeField === 'price') {
void handleSubmit();
}
if (key.return && activeField !== 'price') {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[idx + 1] ?? f;
});
}
if (key.escape) back();
});
const handleSubmit = async () => {
const updated = await rateSupplier(supplierId, {
qualityScore: scores.quality,
deliveryScore: scores.delivery,
priceScore: scores.price,
});
if (updated) {
setSuccessMessage('Bewertung gespeichert.');
setTimeout(() => navigate('supplier-detail', { supplierId }), 1000);
}
};
if (loading) return <LoadingSpinner label="Bewertung wird gespeichert..." />;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Lieferant bewerten</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} gap={1}>
{FIELDS.map((field) => (
<ScoreSelector
key={field}
label={FIELD_LABELS[field]}
value={scores[field]}
active={activeField === field}
/>
))}
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>
Kriterium · Bewertung ändern · Enter nächstes/Speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,123 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../../state/navigation-context.js';
import { useSuppliers } from '../../../hooks/useSuppliers.js';
import { FormInput } from '../../shared/FormInput.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
type Field = 'name' | 'phone' | 'email' | 'contactPerson' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'paymentDueDays';
const FIELDS: Field[] = ['name', 'phone', 'email', 'contactPerson', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'paymentDueDays'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
phone: 'Telefon *',
email: 'E-Mail',
contactPerson: 'Ansprechpartner',
street: 'Straße',
houseNumber: 'Hausnummer',
postalCode: 'PLZ',
city: 'Stadt',
country: 'Land',
paymentDueDays: 'Zahlungsziel (Tage)',
};
export function SupplierCreateScreen() {
const { navigate, back } = useNavigation();
const { createSupplier, loading, error, clearError } = useSuppliers();
const [values, setValues] = useState<Record<Field, string>>({
name: '', phone: '', email: '', contactPerson: '',
street: '', houseNumber: '', postalCode: '', city: '', country: 'Deutschland',
paymentDueDays: '',
});
const [activeField, setActiveField] = useState<Field>('name');
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
const setField = (field: Field) => (value: string) => {
setValues((v) => ({ ...v, [field]: value }));
};
useInput((_input, key) => {
if (loading) return;
if (key.tab || key.downArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
});
}
if (key.upArrow) {
setActiveField((f) => {
const idx = FIELDS.indexOf(f);
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
});
}
if (key.escape) back();
});
const handleSubmit = async () => {
const errors: Partial<Record<Field, string>> = {};
if (!values.name.trim()) errors.name = 'Name ist erforderlich.';
if (!values.phone.trim()) errors.phone = 'Telefon ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const result = await createSupplier({
name: values.name.trim(),
phone: values.phone.trim(),
...(values.email.trim() ? { email: values.email.trim() } : {}),
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
...(values.street.trim() ? { street: values.street.trim() } : {}),
...(values.houseNumber.trim() ? { houseNumber: values.houseNumber.trim() } : {}),
...(values.postalCode.trim() ? { postalCode: values.postalCode.trim() } : {}),
...(values.city.trim() ? { city: values.city.trim() } : {}),
...(values.country.trim() ? { country: values.country.trim() } : {}),
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
});
if (result) navigate('supplier-list');
};
const handleFieldSubmit = (field: Field) => (_value: string) => {
const idx = FIELDS.indexOf(field);
if (idx < FIELDS.length - 1) {
setActiveField(FIELDS[idx + 1] ?? field);
} else {
void handleSubmit();
}
};
if (loading) {
return (
<Box flexDirection="column" alignItems="center" paddingY={2}>
<LoadingSpinner label="Lieferant wird angelegt..." />
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Neuer Lieferant</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => (
<FormInput
key={field}
label={FIELD_LABELS[field]}
value={values[field]}
onChange={setField(field)}
onSubmit={handleFieldSubmit(field)}
focus={activeField === field}
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
/>
))}
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>
Tab/ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,257 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { SupplierDTO, QualityCertificateDTO } from '@effigenix/api-client';
import { useNavigation } from '../../../state/navigation-context.js';
import { useSuppliers } from '../../../hooks/useSuppliers.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
import { SuccessDisplay } from '../../shared/SuccessDisplay.js';
import { ConfirmDialog } from '../../shared/ConfirmDialog.js';
import { client } from '../../../utils/api-client.js';
type MenuAction =
| 'toggle-status'
| 'rate'
| 'add-certificate'
| 'remove-certificate'
| 'back';
type Mode = 'menu' | 'confirm-status' | 'confirm-remove-cert';
const MENU_ITEMS: { id: MenuAction; label: (s: SupplierDTO) => string }[] = [
{ id: 'toggle-status', label: (s) => s.status === 'ACTIVE' ? '[Deaktivieren]' : '[Aktivieren]' },
{ id: 'rate', label: () => '[Bewerten]' },
{ id: 'add-certificate', label: () => '[Zertifikat hinzufügen]' },
{ id: 'remove-certificate', label: (s) => s.certificates.length > 0 ? '[Zertifikat entfernen]' : '[Zertifikat entfernen (keine vorhanden)]' },
{ id: 'back', label: () => '[Zurück]' },
];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
function formatDate(d: string): string {
return new Date(d).toLocaleDateString('de-DE');
}
function avgRating(r: SupplierDTO['rating']): string {
if (!r) return '';
return ((r.qualityScore + r.deliveryScore + r.priceScore) / 3).toFixed(1);
}
export function SupplierDetailScreen() {
const { params, navigate, back } = useNavigation();
const supplierId = params['supplierId'] ?? '';
const { activateSupplier, deactivateSupplier, removeCertificate } = useSuppliers();
const [supplier, setSupplier] = useState<SupplierDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedAction, setSelectedAction] = useState(0);
const [mode, setMode] = useState<Mode>('menu');
const [actionLoading, setActionLoading] = useState(false);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [selectedCertIndex, setSelectedCertIndex] = useState(0);
const loadSupplier = useCallback(() => {
setLoading(true);
setError(null);
client.suppliers.getById(supplierId)
.then((s) => { setSupplier(s); setLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
}, [supplierId]);
useEffect(() => { if (supplierId) loadSupplier(); }, [loadSupplier, supplierId]);
useInput((_input, key) => {
if (loading || actionLoading) return;
if (mode === 'confirm-remove-cert' && supplier) {
if (key.upArrow) setSelectedCertIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedCertIndex((i) => Math.min(supplier.certificates.length - 1, i + 1));
if (key.return) {
const c = supplier.certificates[selectedCertIndex];
if (c) void handleRemoveCert(c);
}
if (key.escape) setMode('menu');
return;
}
if (mode !== 'menu') return;
if (key.upArrow) setSelectedAction((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedAction((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
if (key.return) void handleAction();
if (key.backspace || key.escape) back();
});
const handleAction = async () => {
if (!supplier) return;
const item = MENU_ITEMS[selectedAction];
if (!item) return;
switch (item.id) {
case 'toggle-status':
setMode('confirm-status');
break;
case 'rate':
navigate('supplier-rate', { supplierId: supplier.id });
break;
case 'add-certificate':
navigate('supplier-add-certificate', { supplierId: supplier.id });
break;
case 'remove-certificate':
if (supplier.certificates.length > 0) {
setSelectedCertIndex(0);
setMode('confirm-remove-cert');
}
break;
case 'back':
back();
break;
}
};
const handleToggleStatus = useCallback(async () => {
if (!supplier) return;
setMode('menu');
setActionLoading(true);
const fn = supplier.status === 'ACTIVE' ? deactivateSupplier : activateSupplier;
const updated = await fn(supplier.id);
setActionLoading(false);
if (updated) {
setSupplier(updated);
setSuccessMessage(supplier.status === 'ACTIVE' ? 'Lieferant deaktiviert.' : 'Lieferant aktiviert.');
}
}, [supplier, activateSupplier, deactivateSupplier]);
const handleRemoveCert = useCallback(async (cert: QualityCertificateDTO) => {
if (!supplier) return;
setMode('menu');
setActionLoading(true);
const updated = await removeCertificate(supplier.id, {
certificateType: cert.certificateType,
issuer: cert.issuer,
validFrom: cert.validFrom,
});
setActionLoading(false);
if (updated) {
setSupplier(updated);
setSuccessMessage(`Zertifikat "${cert.certificateType}" entfernt.`);
}
}, [supplier, removeCertificate]);
if (loading) return <LoadingSpinner label="Lade Lieferant..." />;
if (error && !supplier) return <ErrorDisplay message={error} onDismiss={back} />;
if (!supplier) return <Text color="red">Lieferant nicht gefunden.</Text>;
const statusColor = supplier.status === 'ACTIVE' ? 'green' : 'red';
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Lieferant: {supplier.name}</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
{/* Info-Box */}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
<Box gap={2}>
<Text color="gray">Status:</Text>
<Text color={statusColor} bold>{supplier.status}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Telefon:</Text>
<Text>{supplier.contactInfo.phone}</Text>
</Box>
{supplier.contactInfo.email && (
<Box gap={2}>
<Text color="gray">E-Mail:</Text>
<Text>{supplier.contactInfo.email}</Text>
</Box>
)}
{supplier.contactInfo.contactPerson && (
<Box gap={2}>
<Text color="gray">Ansprechpartner:</Text>
<Text>{supplier.contactInfo.contactPerson}</Text>
</Box>
)}
{supplier.address && (
<Box gap={2}>
<Text color="gray">Adresse:</Text>
<Text>{`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}`}</Text>
</Box>
)}
{supplier.paymentTerms && (
<Box gap={2}>
<Text color="gray">Zahlungsziel:</Text>
<Text>{supplier.paymentTerms.paymentDueDays} Tage</Text>
</Box>
)}
{supplier.rating && (
<Box gap={2}>
<Text color="gray">Bewertung:</Text>
<Text>
{'★ ' + avgRating(supplier.rating)}
{` (Q:${supplier.rating.qualityScore} L:${supplier.rating.deliveryScore} P:${supplier.rating.priceScore})`}
</Text>
</Box>
)}
{supplier.certificates.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Zertifikate:</Text>
{supplier.certificates.map((c) => (
<Box key={`${c.certificateType}-${c.validFrom}`} paddingLeft={2} gap={1}>
<Text color="yellow"></Text>
<Text>{c.certificateType}</Text>
<Text color="gray">({c.issuer}, bis {formatDate(c.validUntil)})</Text>
</Box>
))}
</Box>
)}
</Box>
{/* Confirm Status Dialog */}
{mode === 'confirm-status' && (
<ConfirmDialog
message={supplier.status === 'ACTIVE' ? 'Lieferant deaktivieren?' : 'Lieferant aktivieren?'}
onConfirm={() => void handleToggleStatus()}
onCancel={() => setMode('menu')}
/>
)}
{/* Confirm Remove Certificate */}
{mode === 'confirm-remove-cert' && supplier.certificates.length > 0 && (
<Box flexDirection="column" gap={1}>
<Text color="yellow">Zertifikat auswählen:</Text>
{supplier.certificates.map((c, i) => (
<Box key={`${c.certificateType}-${c.validFrom}`}>
<Text color={i === selectedCertIndex ? 'cyan' : 'white'}>
{i === selectedCertIndex ? '▶ ' : ' '}{c.certificateType} {c.issuer}
</Text>
</Box>
))}
<Text color="gray" dimColor> auswählen · Enter Entfernen · Escape Abbrechen</Text>
</Box>
)}
{/* Action Menu */}
{mode === 'menu' && (
<Box flexDirection="column">
<Text color="gray" bold>Aktionen:</Text>
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
{!actionLoading && MENU_ITEMS.map((item, index) => (
<Box key={item.id}>
<Text color={index === selectedAction ? 'cyan' : 'white'}>
{index === selectedAction ? '▶ ' : ' '}{item.label(supplier)}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor> navigieren · Enter ausführen · Backspace Zurück</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,93 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../../state/navigation-context.js';
import { useSuppliers } from '../../../hooks/useSuppliers.js';
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
import type { SupplierStatus } from '@effigenix/api-client';
type Filter = 'ALL' | SupplierStatus;
function avgRating(rating: { qualityScore: number; deliveryScore: number; priceScore: number } | null): string {
if (!rating) return '';
const avg = (rating.qualityScore + rating.deliveryScore + rating.priceScore) / 3;
return avg.toFixed(1);
}
export function SupplierListScreen() {
const { navigate, back } = useNavigation();
const { suppliers, loading, error, fetchSuppliers, clearError } = useSuppliers();
const [selectedIndex, setSelectedIndex] = useState(0);
const [filter, setFilter] = useState<Filter>('ALL');
useEffect(() => {
void fetchSuppliers();
}, [fetchSuppliers]);
const filtered = filter === 'ALL' ? suppliers : suppliers.filter((s) => s.status === filter);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
if (key.return && filtered.length > 0) {
const sup = filtered[selectedIndex];
if (sup) navigate('supplier-detail', { supplierId: sup.id });
}
if (input === 'n') navigate('supplier-create');
if (input === 'a') { setFilter('ALL'); setSelectedIndex(0); }
if (input === 'A') { setFilter('ACTIVE'); setSelectedIndex(0); }
if (input === 'I') { setFilter('INACTIVE'); setSelectedIndex(0); }
if (key.backspace || key.escape) back();
});
const filterLabel: Record<Filter, string> = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Lieferanten</Text>
<Text color="gray" dimColor>Filter: <Text color="yellow">{filterLabel[filter]}</Text> ({filtered.length})</Text>
</Box>
{loading && <LoadingSpinner label="Lade Lieferanten..." />}
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
{!loading && !error && (
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
<Box paddingX={1}>
<Text color="gray" bold>{' Status Name'.padEnd(32)}</Text>
<Text color="gray" bold>{'Bewertung Zertifikate'}</Text>
</Box>
{filtered.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Lieferanten gefunden.</Text>
</Box>
)}
{filtered.map((sup, index) => {
const isSelected = index === selectedIndex;
const statusColor = sup.status === 'ACTIVE' ? 'green' : 'red';
const textColor = isSelected ? 'cyan' : 'white';
return (
<Box key={sup.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={statusColor}>{(sup.status === 'ACTIVE' ? '● ' : '○ ')}</Text>
<Text color={textColor}>{sup.name.substring(0, 26).padEnd(27)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{`${avgRating(sup.rating)} `}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{`${sup.certificates.length} Zert.`}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · Backspace Zurück
</Text>
</Box>
</Box>
);
}