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:
parent
797f435a49
commit
d27dbaa843
30 changed files with 3882 additions and 1 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue