diff --git a/frontend/apps/cli/src/App.tsx b/frontend/apps/cli/src/App.tsx
index f536447..b5fbe40 100644
--- a/frontend/apps/cli/src/App.tsx
+++ b/frontend/apps/cli/src/App.tsx
@@ -12,6 +12,25 @@ import { UserDetailScreen } from './components/users/UserDetailScreen.js';
import { ChangePasswordScreen } from './components/users/ChangePasswordScreen.js';
import { RoleListScreen } from './components/roles/RoleListScreen.js';
import { RoleDetailScreen } from './components/roles/RoleDetailScreen.js';
+// Stammdaten
+import { MasterdataMenu } from './components/masterdata/MasterdataMenu.js';
+import { CategoryListScreen } from './components/masterdata/categories/CategoryListScreen.js';
+import { CategoryDetailScreen } from './components/masterdata/categories/CategoryDetailScreen.js';
+import { CategoryCreateScreen } from './components/masterdata/categories/CategoryCreateScreen.js';
+import { SupplierListScreen } from './components/masterdata/suppliers/SupplierListScreen.js';
+import { SupplierDetailScreen } from './components/masterdata/suppliers/SupplierDetailScreen.js';
+import { SupplierCreateScreen } from './components/masterdata/suppliers/SupplierCreateScreen.js';
+import { RateSupplierScreen } from './components/masterdata/suppliers/RateSupplierScreen.js';
+import { AddCertificateScreen } from './components/masterdata/suppliers/AddCertificateScreen.js';
+import { ArticleListScreen } from './components/masterdata/articles/ArticleListScreen.js';
+import { ArticleDetailScreen } from './components/masterdata/articles/ArticleDetailScreen.js';
+import { ArticleCreateScreen } from './components/masterdata/articles/ArticleCreateScreen.js';
+import { AddSalesUnitScreen } from './components/masterdata/articles/AddSalesUnitScreen.js';
+import { CustomerListScreen } from './components/masterdata/customers/CustomerListScreen.js';
+import { CustomerDetailScreen } from './components/masterdata/customers/CustomerDetailScreen.js';
+import { CustomerCreateScreen } from './components/masterdata/customers/CustomerCreateScreen.js';
+import { AddDeliveryAddressScreen } from './components/masterdata/customers/AddDeliveryAddressScreen.js';
+import { SetPreferencesScreen } from './components/masterdata/customers/SetPreferencesScreen.js';
function ScreenRouter() {
const { isAuthenticated, loading } = useAuth();
@@ -49,6 +68,25 @@ function ScreenRouter() {
{current === 'change-password' && }
{current === 'role-list' && }
{current === 'role-detail' && }
+ {/* Stammdaten */}
+ {current === 'masterdata-menu' && }
+ {current === 'category-list' && }
+ {current === 'category-detail' && }
+ {current === 'category-create' && }
+ {current === 'supplier-list' && }
+ {current === 'supplier-detail' && }
+ {current === 'supplier-create' && }
+ {current === 'supplier-rate' && }
+ {current === 'supplier-add-certificate' && }
+ {current === 'article-list' && }
+ {current === 'article-detail' && }
+ {current === 'article-create' && }
+ {current === 'article-add-sales-unit' && }
+ {current === 'customer-list' && }
+ {current === 'customer-detail' && }
+ {current === 'customer-create' && }
+ {current === 'customer-add-delivery-address' && }
+ {current === 'customer-set-preferences' && }
);
}
diff --git a/frontend/apps/cli/src/components/MainMenu.tsx b/frontend/apps/cli/src/components/MainMenu.tsx
index 10a2358..4208247 100644
--- a/frontend/apps/cli/src/components/MainMenu.tsx
+++ b/frontend/apps/cli/src/components/MainMenu.tsx
@@ -16,6 +16,7 @@ export function MainMenu() {
const [selectedIndex, setSelectedIndex] = useState(0);
const items: MenuItem[] = [
+ { label: 'Stammdaten', screen: 'masterdata-menu' },
{ label: 'Benutzer verwalten', screen: 'user-list' },
{ label: 'Rollen anzeigen', screen: 'role-list' },
{ label: 'Abmelden', action: () => void logout() },
diff --git a/frontend/apps/cli/src/components/masterdata/MasterdataMenu.tsx b/frontend/apps/cli/src/components/masterdata/MasterdataMenu.tsx
new file mode 100644
index 0000000..9967b50
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/MasterdataMenu.tsx
@@ -0,0 +1,67 @@
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../state/navigation-context.js';
+import type { Screen } from '../../state/navigation-context.js';
+
+interface MenuItem {
+ label: string;
+ screen: Screen;
+ description: string;
+}
+
+const MENU_ITEMS: MenuItem[] = [
+ { label: 'Produktkategorien', screen: 'category-list', description: 'Kategorien verwalten' },
+ { label: 'Lieferanten', screen: 'supplier-list', description: 'Lieferanten, Zertifikate, Bewertungen' },
+ { label: 'Artikel', screen: 'article-list', description: 'Artikel und Verkaufseinheiten' },
+ { label: 'Kunden', screen: 'customer-list', description: 'Kunden, Lieferadressen, Präferenzen' },
+];
+
+export function MasterdataMenu() {
+ const { navigate, back } = useNavigation();
+ const [selectedIndex, setSelectedIndex] = useState(0);
+
+ useInput((_input, key) => {
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
+ if (key.downArrow) setSelectedIndex((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
+ if (key.return) {
+ const item = MENU_ITEMS[selectedIndex];
+ if (item) navigate(item.screen);
+ }
+ if (key.backspace || key.escape) back();
+ });
+
+ return (
+
+
+ Stammdaten
+
+
+
+ {MENU_ITEMS.map((item, index) => (
+
+
+ {index === selectedIndex ? '▶ ' : ' '}
+ {item.label}
+
+ {index === selectedIndex && (
+
+ {item.description}
+
+ )}
+
+ ))}
+
+
+ ↑↓ navigieren · Enter auswählen · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx b/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx
new file mode 100644
index 0000000..2d4084f
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/articles/AddSalesUnitScreen.tsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { Unit, PriceModel } from '@effigenix/api-client';
+import { UNIT_LABELS, PRICE_MODEL_LABELS } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useArticles } from '../../../hooks/useArticles.js';
+import { FormInput } from '../../shared/FormInput.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+
+const UNITS: Unit[] = ['PIECE_FIXED', 'KG', 'HUNDRED_GRAM', 'PIECE_VARIABLE'];
+const UNIT_PRICE_MODEL: Record = {
+ PIECE_FIXED: 'FIXED',
+ KG: 'WEIGHT_BASED',
+ HUNDRED_GRAM: 'WEIGHT_BASED',
+ PIECE_VARIABLE: 'WEIGHT_BASED',
+};
+
+export function AddSalesUnitScreen() {
+ const { params, navigate, back } = useNavigation();
+ const articleId = params['articleId'] ?? '';
+ const { addSalesUnit, loading, error, clearError } = useArticles();
+
+ const [unitIndex, setUnitIndex] = useState(0);
+ const [price, setPrice] = useState('');
+ const [activeField, setActiveField] = useState<'unit' | 'price'>('unit');
+ const [priceError, setPriceError] = useState(null);
+
+ const selectedUnit = UNITS[unitIndex] ?? 'PIECE_FIXED';
+ const autoModel = UNIT_PRICE_MODEL[selectedUnit];
+
+ useInput((_input, key) => {
+ if (loading) return;
+
+ if (activeField === 'unit') {
+ if (key.leftArrow) setUnitIndex((i) => Math.max(0, i - 1));
+ if (key.rightArrow) setUnitIndex((i) => Math.min(UNITS.length - 1, i + 1));
+ if (key.tab || key.downArrow || key.return) setActiveField('price');
+ }
+
+ if (key.upArrow && activeField === 'price') setActiveField('unit');
+ if (key.escape) back();
+ });
+
+ const handleSubmit = async () => {
+ const priceNum = parseFloat(price.replace(',', '.'));
+ if (isNaN(priceNum) || priceNum <= 0) {
+ setPriceError('Gültiger Preis erforderlich.');
+ return;
+ }
+ setPriceError(null);
+ const updated = await addSalesUnit(articleId, { unit: selectedUnit, priceModel: autoModel, price: priceNum });
+ if (updated) navigate('article-detail', { articleId });
+ };
+
+ if (loading) return ;
+
+ return (
+
+ Verkaufseinheit hinzufügen
+ {error && }
+
+
+
+ Einheit *
+
+ {'< '}
+ {UNIT_LABELS[selectedUnit]}
+ {' >'}
+ → {PRICE_MODEL_LABELS[autoModel]}
+
+ {activeField === 'unit' && ←→ Einheit · Tab/Enter weiter}
+
+
+ void handleSubmit()}
+ focus={activeField === 'price'}
+ placeholder="z.B. 2.49"
+ {...(priceError ? { error: priceError } : {})}
+ />
+
+
+
+ Tab/↑↓ Feld · ←→ Einheit · Enter speichern · Escape Abbrechen
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx
new file mode 100644
index 0000000..3291af5
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/articles/ArticleCreateScreen.tsx
@@ -0,0 +1,172 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { Unit, PriceModel } from '@effigenix/api-client';
+import { UNIT_LABELS, PRICE_MODEL_LABELS } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useArticles } from '../../../hooks/useArticles.js';
+import { useCategories } from '../../../hooks/useCategories.js';
+import { FormInput } from '../../shared/FormInput.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+
+type Field = 'name' | 'articleNumber' | 'categoryId' | 'unit' | 'priceModel' | 'price';
+const FIELDS: Field[] = ['name', 'articleNumber', 'categoryId', 'unit', 'priceModel', 'price'];
+
+const UNITS: Unit[] = ['PIECE_FIXED', 'KG', 'HUNDRED_GRAM', 'PIECE_VARIABLE'];
+
+
+const UNIT_PRICE_MODEL: Record = {
+ PIECE_FIXED: 'FIXED',
+ KG: 'WEIGHT_BASED',
+ HUNDRED_GRAM: 'WEIGHT_BASED',
+ PIECE_VARIABLE: 'WEIGHT_BASED',
+};
+
+export function ArticleCreateScreen() {
+ const { navigate, back } = useNavigation();
+ const { createArticle, loading, error, clearError } = useArticles();
+ const { categories, fetchCategories } = useCategories();
+
+ const [name, setName] = useState('');
+ const [articleNumber, setArticleNumber] = useState('');
+ const [categoryIndex, setCategoryIndex] = useState(0);
+ const [unitIndex, setUnitIndex] = useState(0);
+ const [price, setPrice] = useState('');
+ const [activeField, setActiveField] = useState('name');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+
+ useEffect(() => { void fetchCategories(); }, [fetchCategories]);
+
+ const selectedUnit = UNITS[unitIndex] ?? 'PIECE_FIXED';
+ const autoModel = UNIT_PRICE_MODEL[selectedUnit];
+
+ useInput((_input, key) => {
+ if (loading) return;
+
+ if (activeField === 'categoryId') {
+ if (key.leftArrow) setCategoryIndex((i) => Math.max(0, i - 1));
+ if (key.rightArrow) setCategoryIndex((i) => Math.min(categories.length - 1, i + 1));
+ }
+ if (activeField === 'unit') {
+ if (key.leftArrow) {
+ const newIdx = Math.max(0, unitIndex - 1);
+ setUnitIndex(newIdx);
+ }
+ if (key.rightArrow) {
+ const newIdx = Math.min(UNITS.length - 1, unitIndex + 1);
+ setUnitIndex(newIdx);
+ }
+ }
+
+ if (key.tab || key.downArrow) {
+ setActiveField((f) => {
+ const idx = FIELDS.indexOf(f);
+ return FIELDS[(idx + 1) % FIELDS.length] ?? f;
+ });
+ }
+ if (key.upArrow && activeField !== 'unit' && activeField !== 'categoryId') {
+ 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> = {};
+ if (!name.trim()) errors.name = 'Name ist erforderlich.';
+ if (!articleNumber.trim()) errors.articleNumber = 'Artikelnummer ist erforderlich.';
+ if (categories.length === 0) errors.categoryId = 'Keine Kategorien verfügbar.';
+ const priceNum = parseFloat(price.replace(',', '.'));
+ if (isNaN(priceNum) || priceNum <= 0) errors.price = 'Gültiger Preis erforderlich.';
+ setFieldErrors(errors);
+ if (Object.keys(errors).length > 0) return;
+
+ const cat = categories[categoryIndex];
+ if (!cat) return;
+
+ const result = await createArticle({
+ name: name.trim(),
+ articleNumber: articleNumber.trim(),
+ categoryId: cat.id,
+ unit: selectedUnit,
+ priceModel: autoModel,
+ price: priceNum,
+ });
+ if (result) navigate('article-list');
+ };
+
+ if (loading) return ;
+
+ const selectedCategory = categories[categoryIndex];
+
+ return (
+
+ Neuer Artikel
+ {error && }
+
+
+ setActiveField('articleNumber')}
+ focus={activeField === 'name'}
+ {...(fieldErrors.name ? { error: fieldErrors.name } : {})}
+ />
+ setActiveField('categoryId')}
+ focus={activeField === 'articleNumber'}
+ placeholder="z.B. OG-001"
+ {...(fieldErrors.articleNumber ? { error: fieldErrors.articleNumber } : {})}
+ />
+
+ {/* Category Selector */}
+
+ Kategorie *
+
+ {'< '}
+
+ {selectedCategory?.name ?? 'Keine Kategorien'}
+
+ {' >'}
+
+ {fieldErrors.categoryId && {fieldErrors.categoryId}}
+ {activeField === 'categoryId' && ←→ Kategorie wechseln · Tab weiter}
+
+
+ {/* Unit Selector */}
+
+ Einheit *
+
+ {'< '}
+ {UNIT_LABELS[selectedUnit]}
+ {' >'}
+ → {PRICE_MODEL_LABELS[autoModel]}
+
+ {activeField === 'unit' && ←→ Einheit wechseln · Tab weiter}
+
+
+ void handleSubmit()}
+ focus={activeField === 'price'}
+ placeholder="z.B. 2.49"
+ {...(fieldErrors.price ? { error: fieldErrors.price } : {})}
+ />
+
+
+
+
+ Tab/↑↓ Feld · ←→ Auswahl · Enter auf Preis speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/articles/ArticleDetailScreen.tsx b/frontend/apps/cli/src/components/masterdata/articles/ArticleDetailScreen.tsx
new file mode 100644
index 0000000..87876a1
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/articles/ArticleDetailScreen.tsx
@@ -0,0 +1,212 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { ArticleDTO, SalesUnitDTO } from '@effigenix/api-client';
+import { UNIT_LABELS, PRICE_MODEL_LABELS } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useArticles } from '../../../hooks/useArticles.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' | 'add-sales-unit' | 'remove-sales-unit' | 'back';
+type Mode = 'menu' | 'confirm-status' | 'select-sales-unit';
+
+const MENU_ITEMS: { id: MenuAction; label: (a: ArticleDTO) => string }[] = [
+ { id: 'toggle-status', label: (a) => a.status === 'ACTIVE' ? '[Deaktivieren]' : '[Aktivieren]' },
+ { id: 'add-sales-unit', label: () => '[Verkaufseinheit hinzufügen]' },
+ { id: 'remove-sales-unit', label: (a) => a.salesUnits.length > 1 ? '[Verkaufseinheit entfernen]' : '[Entfernen (min. 1 erforderlich)]' },
+ { id: 'back', label: () => '[Zurück]' },
+];
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function ArticleDetailScreen() {
+ const { params, navigate, back } = useNavigation();
+ const articleId = params['articleId'] ?? '';
+ const { activateArticle, deactivateArticle, removeSalesUnit } = useArticles();
+
+ const [article, setArticle] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedAction, setSelectedAction] = useState(0);
+ const [mode, setMode] = useState('menu');
+ const [actionLoading, setActionLoading] = useState(false);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [selectedSuIndex, setSelectedSuIndex] = useState(0);
+
+ const loadArticle = useCallback(() => {
+ setLoading(true);
+ setError(null);
+ client.articles.getById(articleId)
+ .then((a) => { setArticle(a); setLoading(false); })
+ .catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
+ }, [articleId]);
+
+ useEffect(() => { if (articleId) loadArticle(); }, [loadArticle, articleId]);
+
+ useInput((_input, key) => {
+ if (loading || actionLoading) return;
+
+ if (mode === 'select-sales-unit' && article) {
+ if (key.upArrow) setSelectedSuIndex((i) => Math.max(0, i - 1));
+ if (key.downArrow) setSelectedSuIndex((i) => Math.min(article.salesUnits.length - 1, i + 1));
+ if (key.return) {
+ const su = article.salesUnits[selectedSuIndex];
+ if (su && article.salesUnits.length > 1) void handleRemoveSalesUnit(su);
+ }
+ 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 (!article) return;
+ const item = MENU_ITEMS[selectedAction];
+ if (!item) return;
+
+ switch (item.id) {
+ case 'toggle-status':
+ setMode('confirm-status');
+ break;
+ case 'add-sales-unit':
+ navigate('article-add-sales-unit', { articleId: article.id });
+ break;
+ case 'remove-sales-unit':
+ if (article.salesUnits.length > 1) {
+ setSelectedSuIndex(0);
+ setMode('select-sales-unit');
+ }
+ break;
+ case 'back':
+ back();
+ break;
+ }
+ };
+
+ const handleToggleStatus = useCallback(async () => {
+ if (!article) return;
+ setMode('menu');
+ setActionLoading(true);
+ const fn = article.status === 'ACTIVE' ? deactivateArticle : activateArticle;
+ const updated = await fn(article.id);
+ setActionLoading(false);
+ if (updated) {
+ setArticle(updated);
+ setSuccessMessage(article.status === 'ACTIVE' ? 'Artikel deaktiviert.' : 'Artikel aktiviert.');
+ }
+ }, [article, activateArticle, deactivateArticle]);
+
+ const handleRemoveSalesUnit = useCallback(async (su: SalesUnitDTO) => {
+ if (!article) return;
+ setMode('menu');
+ setActionLoading(true);
+ const updated = await removeSalesUnit(article.id, su.id);
+ setActionLoading(false);
+ if (updated) {
+ setArticle(updated);
+ setSuccessMessage(`Verkaufseinheit "${UNIT_LABELS[su.unit]}" entfernt.`);
+ }
+ }, [article, removeSalesUnit]);
+
+ if (loading) return ;
+ if (error && !article) return ;
+ if (!article) return Artikel nicht gefunden.;
+
+ const statusColor = article.status === 'ACTIVE' ? 'green' : 'red';
+
+ return (
+
+ Artikel: {article.articleNumber} – {article.name}
+
+ {error && setError(null)} />}
+ {successMessage && setSuccessMessage(null)} />}
+
+ {/* Info-Box */}
+
+
+ Status:
+ {article.status}
+
+
+ Artikelnummer:
+ {article.articleNumber}
+
+
+ Kategorie-ID:
+ {article.categoryId}
+
+
+ Verkaufseinheiten ({article.salesUnits.length}):
+ {article.salesUnits.map((su) => (
+
+ •
+ {UNIT_LABELS[su.unit]}
+ ({PRICE_MODEL_LABELS[su.priceModel]})
+ {su.price.toFixed(2)} €
+
+ ))}
+
+ {article.supplierIds.length > 0 && (
+
+ Lieferanten:
+ {article.supplierIds.length} zugewiesen
+
+ )}
+
+
+ {/* Confirm Status */}
+ {mode === 'confirm-status' && (
+ void handleToggleStatus()}
+ onCancel={() => setMode('menu')}
+ />
+ )}
+
+ {/* Select Sales Unit to Remove */}
+ {mode === 'select-sales-unit' && (
+
+ Verkaufseinheit entfernen:
+ {article.salesUnits.map((su, i) => (
+
+
+ {i === selectedSuIndex ? '▶ ' : ' '}
+ {UNIT_LABELS[su.unit]} – {su.price.toFixed(2)} €
+
+
+ ))}
+ ↑↓ auswählen · Enter Entfernen · Escape Abbrechen
+
+ )}
+
+ {/* Action Menu */}
+ {mode === 'menu' && (
+
+ Aktionen:
+ {actionLoading && }
+ {!actionLoading && MENU_ITEMS.map((item, index) => (
+
+
+ {index === selectedAction ? '▶ ' : ' '}{item.label(article)}
+
+
+ ))}
+
+ )}
+
+
+ ↑↓ navigieren · Enter ausführen · Backspace Zurück
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/articles/ArticleListScreen.tsx b/frontend/apps/cli/src/components/masterdata/articles/ArticleListScreen.tsx
new file mode 100644
index 0000000..1efc16c
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/articles/ArticleListScreen.tsx
@@ -0,0 +1,85 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useArticles } from '../../../hooks/useArticles.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+import type { ArticleStatus } from '@effigenix/api-client';
+
+type Filter = 'ALL' | ArticleStatus;
+
+export function ArticleListScreen() {
+ const { navigate, back } = useNavigation();
+ const { articles, loading, error, fetchArticles, clearError } = useArticles();
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [filter, setFilter] = useState('ALL');
+
+ useEffect(() => {
+ void fetchArticles();
+ }, [fetchArticles]);
+
+ const filtered = filter === 'ALL' ? articles : articles.filter((a) => a.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 art = filtered[selectedIndex];
+ if (art) navigate('article-detail', { articleId: art.id });
+ }
+ if (input === 'n') navigate('article-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 = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
+
+ return (
+
+
+ Artikel
+ Filter: {filterLabel[filter]} ({filtered.length})
+
+
+ {loading && }
+ {error && !loading && }
+
+ {!loading && !error && (
+
+
+ {' St Nummer Name'.padEnd(38)}
+ Einh.
+
+ {filtered.length === 0 && (
+
+ Keine Artikel gefunden.
+
+ )}
+ {filtered.map((art, index) => {
+ const isSelected = index === selectedIndex;
+ const statusColor = art.status === 'ACTIVE' ? 'green' : 'red';
+ const textColor = isSelected ? 'cyan' : 'white';
+ return (
+
+ {isSelected ? '▶ ' : ' '}
+ {art.status === 'ACTIVE' ? '● ' : '○ '}
+ {art.articleNumber.padEnd(10)}
+ {art.name.substring(0, 22).padEnd(23)}
+ {art.salesUnits.length} VE
+
+ );
+ })}
+
+ )}
+
+
+
+ ↑↓ nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx
new file mode 100644
index 0000000..69fbb3b
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/categories/CategoryCreateScreen.tsx
@@ -0,0 +1,98 @@
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCategories } from '../../../hooks/useCategories.js';
+import { FormInput } from '../../shared/FormInput.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+
+type Field = 'name' | 'description';
+const FIELDS: Field[] = ['name', 'description'];
+
+export function CategoryCreateScreen() {
+ const { navigate, back } = useNavigation();
+ const { createCategory, loading, error, clearError } = useCategories();
+
+ const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
+ const [activeField, setActiveField] = useState('name');
+ const [nameError, setNameError] = useState(null);
+
+ 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 () => {
+ setNameError(null);
+ if (!name.trim()) {
+ setNameError('Name ist erforderlich.');
+ return;
+ }
+ const cat = await createCategory(name.trim(), description.trim() || undefined);
+ if (cat) navigate('category-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 (
+
+
+
+ );
+ }
+
+ return (
+
+ Neue Produktkategorie
+
+ {error && }
+
+
+
+
+
+
+
+
+ Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/categories/CategoryDetailScreen.tsx b/frontend/apps/cli/src/components/masterdata/categories/CategoryDetailScreen.tsx
new file mode 100644
index 0000000..0492ee0
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/categories/CategoryDetailScreen.tsx
@@ -0,0 +1,161 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { ProductCategoryDTO } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCategories } from '../../../hooks/useCategories.js';
+import { FormInput } from '../../shared/FormInput.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+import { SuccessDisplay } from '../../shared/SuccessDisplay.js';
+import { client } from '../../../utils/api-client.js';
+
+type Mode = 'view' | 'edit';
+type EditField = 'name' | 'description';
+const EDIT_FIELDS: EditField[] = ['name', 'description'];
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function CategoryDetailScreen() {
+ const { params, back } = useNavigation();
+ const categoryId = params['categoryId'] ?? '';
+ const { updateCategory } = useCategories();
+
+ const [category, setCategory] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [mode, setMode] = useState('view');
+ const [activeField, setActiveField] = useState('name');
+ const [editName, setEditName] = useState('');
+ const [editDescription, setEditDescription] = useState('');
+ const [saving, setSaving] = useState(false);
+ const [successMessage, setSuccessMessage] = useState(null);
+
+ useEffect(() => {
+ setLoading(true);
+ client.categories.getById(categoryId)
+ .then((cat) => { setCategory(cat); setLoading(false); })
+ .catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
+ }, [categoryId]);
+
+ useInput((_input, key) => {
+ if (loading || saving) return;
+
+ if (mode === 'view') {
+ if (key.return || _input === 'e') {
+ if (category) {
+ setEditName(category.name);
+ setEditDescription(category.description ?? '');
+ setActiveField('name');
+ setMode('edit');
+ }
+ }
+ if (key.backspace || key.escape) back();
+ }
+
+ if (mode === 'edit') {
+ if (key.tab || key.downArrow) {
+ setActiveField((f) => {
+ const idx = EDIT_FIELDS.indexOf(f);
+ return EDIT_FIELDS[(idx + 1) % EDIT_FIELDS.length] ?? f;
+ });
+ }
+ if (key.upArrow) {
+ setActiveField((f) => {
+ const idx = EDIT_FIELDS.indexOf(f);
+ return EDIT_FIELDS[(idx - 1 + EDIT_FIELDS.length) % EDIT_FIELDS.length] ?? f;
+ });
+ }
+ if (key.escape) setMode('view');
+ }
+ });
+
+ const handleSave = async () => {
+ if (!category || !editName.trim()) return;
+ setSaving(true);
+ setError(null);
+ const updated = await updateCategory(
+ category.id,
+ editName.trim(),
+ editDescription.trim() || null,
+ );
+ setSaving(false);
+ if (updated) {
+ setCategory(updated);
+ setMode('view');
+ setSuccessMessage('Kategorie gespeichert.');
+ }
+ };
+
+ if (loading) return ;
+ if (error && !category) return ;
+ if (!category) return Kategorie nicht gefunden.;
+
+ return (
+
+ Kategorie: {category.name}
+
+ {error && setError(null)} />}
+ {successMessage && setSuccessMessage(null)} />}
+
+ {mode === 'view' && (
+ <>
+
+
+ ID:
+ {category.id}
+
+
+ Name:
+ {category.name}
+
+
+ Beschreibung:
+ {category.description ?? '–'}
+
+
+
+
+
+ [e]/Enter Bearbeiten · Backspace Zurück
+
+
+ >
+ )}
+
+ {mode === 'edit' && (
+ <>
+ {saving && }
+ {!saving && (
+
+ {
+ if (activeField === 'description') void handleSave();
+ else setActiveField('description');
+ }}
+ focus={activeField === 'name'}
+ />
+ void handleSave()}
+ focus={activeField === 'description'}
+ placeholder="(optional)"
+ />
+
+ )}
+
+
+ Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+ >
+ )}
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/categories/CategoryListScreen.tsx b/frontend/apps/cli/src/components/masterdata/categories/CategoryListScreen.tsx
new file mode 100644
index 0000000..2e2aaef
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/categories/CategoryListScreen.tsx
@@ -0,0 +1,101 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCategories } from '../../../hooks/useCategories.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+import { ConfirmDialog } from '../../shared/ConfirmDialog.js';
+import { SuccessDisplay } from '../../shared/SuccessDisplay.js';
+
+export function CategoryListScreen() {
+ const { navigate, back } = useNavigation();
+ const { categories, loading, error, fetchCategories, deleteCategory, clearError } = useCategories();
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [confirmDeleteId, setConfirmDeleteId] = useState(null);
+ const [successMessage, setSuccessMessage] = useState(null);
+
+ useEffect(() => {
+ void fetchCategories();
+ }, [fetchCategories]);
+
+ useInput((input, key) => {
+ if (loading || confirmDeleteId) return;
+
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
+ if (key.downArrow) setSelectedIndex((i) => Math.min(categories.length - 1, i + 1));
+
+ if (key.return && categories.length > 0) {
+ const cat = categories[selectedIndex];
+ if (cat) navigate('category-detail', { categoryId: cat.id });
+ }
+ if (input === 'n') navigate('category-create');
+ if (input === 'd' && categories.length > 0) {
+ const cat = categories[selectedIndex];
+ if (cat) setConfirmDeleteId(cat.id);
+ }
+ if (key.backspace || key.escape) back();
+ });
+
+ const handleDelete = async () => {
+ if (!confirmDeleteId) return;
+ const cat = categories.find((c) => c.id === confirmDeleteId);
+ const ok = await deleteCategory(confirmDeleteId);
+ setConfirmDeleteId(null);
+ if (ok) {
+ setSuccessMessage(`Kategorie "${cat?.name ?? ''}" gelöscht.`);
+ setSelectedIndex((i) => Math.max(0, i - 1));
+ }
+ };
+
+ return (
+
+
+ Produktkategorien
+ – {categories.length} Einträge
+
+
+ {loading && }
+ {error && !loading && }
+ {successMessage && setSuccessMessage(null)} />}
+
+ {confirmDeleteId && (
+ c.id === confirmDeleteId)?.name}" löschen?`}
+ onConfirm={() => void handleDelete()}
+ onCancel={() => setConfirmDeleteId(null)}
+ />
+ )}
+
+ {!loading && !error && (
+
+
+ {' Name'.padEnd(30)}
+ Beschreibung
+
+ {categories.length === 0 && (
+
+ Keine Kategorien vorhanden.
+
+ )}
+ {categories.map((cat, index) => (
+
+
+ {index === selectedIndex ? '▶ ' : ' '}
+ {cat.name.padEnd(28)}
+
+
+ {cat.description ?? '–'}
+
+
+ ))}
+
+ )}
+
+
+
+ ↑↓ navigieren · Enter Details · [n] Neu · [d] Löschen · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx
new file mode 100644
index 0000000..cb2ebbc
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/customers/AddDeliveryAddressScreen.tsx
@@ -0,0 +1,117 @@
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCustomers } from '../../../hooks/useCustomers.js';
+import { FormInput } from '../../shared/FormInput.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+
+type Field = 'label' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'contactPerson' | 'deliveryNotes';
+const FIELDS: Field[] = ['label', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'contactPerson', 'deliveryNotes'];
+
+const FIELD_LABELS: Record = {
+ label: 'Bezeichnung * (z.B. Hauptküche)',
+ street: 'Straße *',
+ houseNumber: 'Hausnummer *',
+ postalCode: 'PLZ *',
+ city: 'Stadt *',
+ country: 'Land *',
+ contactPerson: 'Ansprechpartner',
+ deliveryNotes: 'Lieferhinweis',
+};
+
+export function AddDeliveryAddressScreen() {
+ const { params, navigate, back } = useNavigation();
+ const customerId = params['customerId'] ?? '';
+ const { addDeliveryAddress, loading, error, clearError } = useCustomers();
+
+ const [values, setValues] = useState>({
+ label: '', street: '', houseNumber: '', postalCode: '',
+ city: '', country: 'Deutschland', contactPerson: '', deliveryNotes: '',
+ });
+ const [activeField, setActiveField] = useState('label');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+
+ 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> = {};
+ if (!values.label.trim()) errors.label = 'Bezeichnung ist erforderlich.';
+ if (!values.street.trim()) errors.street = 'Straße ist erforderlich.';
+ if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
+ if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
+ if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
+ if (!values.country.trim()) errors.country = 'Land ist erforderlich.';
+ setFieldErrors(errors);
+ if (Object.keys(errors).length > 0) return;
+
+ const updated = await addDeliveryAddress(customerId, {
+ label: values.label.trim(),
+ street: values.street.trim(),
+ houseNumber: values.houseNumber.trim(),
+ postalCode: values.postalCode.trim(),
+ city: values.city.trim(),
+ country: values.country.trim(),
+ ...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
+ ...(values.deliveryNotes.trim() ? { deliveryNotes: values.deliveryNotes.trim() } : {}),
+ });
+ if (updated) navigate('customer-detail', { customerId });
+ };
+
+ 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 ;
+
+ return (
+
+ Lieferadresse hinzufügen
+ {error && }
+
+
+ {FIELDS.map((field) => (
+
+ ))}
+
+
+
+
+ Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx
new file mode 100644
index 0000000..53dbbb2
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/customers/CustomerCreateScreen.tsx
@@ -0,0 +1,145 @@
+import React, { useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { CustomerType } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCustomers } from '../../../hooks/useCustomers.js';
+import { FormInput } from '../../shared/FormInput.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+
+type Field = 'name' | 'phone' | 'email' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'paymentDueDays';
+const FIELDS: Field[] = ['name', 'phone', 'email', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'paymentDueDays'];
+
+const FIELD_LABELS: Record = {
+ name: 'Name *',
+ phone: 'Telefon *',
+ email: 'E-Mail',
+ street: 'Straße *',
+ houseNumber: 'Hausnummer *',
+ postalCode: 'PLZ *',
+ city: 'Stadt *',
+ country: 'Land *',
+ paymentDueDays: 'Zahlungsziel (Tage)',
+};
+
+const TYPES: CustomerType[] = ['B2B', 'B2C'];
+
+export function CustomerCreateScreen() {
+ const { navigate, back } = useNavigation();
+ const { createCustomer, loading, error, clearError } = useCustomers();
+
+ const [values, setValues] = useState>({
+ name: '', phone: '', email: '', street: '', houseNumber: '',
+ postalCode: '', city: '', country: 'Deutschland', paymentDueDays: '',
+ });
+ const [typeIndex, setTypeIndex] = useState(0);
+ const [activeField, setActiveField] = useState('name');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+
+ const setField = (field: Field) => (value: string) => {
+ setValues((v) => ({ ...v, [field]: value }));
+ };
+
+ useInput((_input, key) => {
+ if (loading) return;
+
+ if (activeField === 'type') {
+ if (key.leftArrow) setTypeIndex((i) => Math.max(0, i - 1));
+ if (key.rightArrow) setTypeIndex((i) => Math.min(TYPES.length - 1, i + 1));
+ if (key.tab || key.downArrow || key.return) setActiveField('name');
+ } else {
+ if (key.tab || key.downArrow) {
+ setActiveField((f) => {
+ if (f === 'type') return 'name';
+ const idx = FIELDS.indexOf(f as Field);
+ return FIELDS[(idx + 1) % FIELDS.length] ?? f;
+ });
+ }
+ if (key.upArrow) {
+ setActiveField((f) => {
+ if (f === 'type') return f;
+ const idx = FIELDS.indexOf(f as Field);
+ if (idx === 0) return 'type';
+ return FIELDS[idx - 1] ?? f;
+ });
+ }
+ }
+ if (key.escape) back();
+ });
+
+ const handleSubmit = async () => {
+ const errors: Partial> = {};
+ if (!values.name.trim()) errors.name = 'Name ist erforderlich.';
+ if (!values.phone.trim()) errors.phone = 'Telefon ist erforderlich.';
+ if (!values.street.trim()) errors.street = 'Straße ist erforderlich.';
+ if (!values.houseNumber.trim()) errors.houseNumber = 'Hausnummer ist erforderlich.';
+ if (!values.postalCode.trim()) errors.postalCode = 'PLZ ist erforderlich.';
+ if (!values.city.trim()) errors.city = 'Stadt ist erforderlich.';
+ if (!values.country.trim()) errors.country = 'Land ist erforderlich.';
+ setFieldErrors(errors);
+ if (Object.keys(errors).length > 0) return;
+
+ const result = await createCustomer({
+ name: values.name.trim(),
+ type: TYPES[typeIndex] ?? 'B2B',
+ phone: values.phone.trim(),
+ street: values.street.trim(),
+ houseNumber: values.houseNumber.trim(),
+ postalCode: values.postalCode.trim(),
+ city: values.city.trim(),
+ country: values.country.trim(),
+ ...(values.email.trim() ? { email: values.email.trim() } : {}),
+ ...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
+ });
+ if (result) navigate('customer-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 ;
+
+ return (
+
+ Neuer Kunde
+ {error && }
+
+
+ {/* Type Selector */}
+
+ Typ *
+
+ {'< '}
+ {TYPES[typeIndex]}
+ {' >'}
+
+ {activeField === 'type' && ←→ Typ · Tab/Enter weiter}
+
+
+ {FIELDS.map((field) => (
+
+ ))}
+
+
+
+
+ Tab/↑↓ Feld · ←→ Typ · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx
new file mode 100644
index 0000000..0e22505
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/customers/CustomerDetailScreen.tsx
@@ -0,0 +1,248 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { CustomerDTO } from '@effigenix/api-client';
+import { CUSTOMER_PREFERENCE_LABELS } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCustomers } from '../../../hooks/useCustomers.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'
+ | 'add-delivery-address'
+ | 'remove-delivery-address'
+ | 'set-preferences'
+ | 'back';
+
+type Mode = 'menu' | 'confirm-status' | 'select-remove-address';
+
+const MENU_ITEMS: { id: MenuAction; label: (c: CustomerDTO) => string }[] = [
+ { id: 'toggle-status', label: (c) => c.status === 'ACTIVE' ? '[Deaktivieren]' : '[Aktivieren]' },
+ { id: 'add-delivery-address', label: () => '[Lieferadresse hinzufügen]' },
+ { id: 'remove-delivery-address', label: (c) => c.deliveryAddresses.length > 0 ? '[Lieferadresse entfernen]' : '[Entfernen (keine vorhanden)]' },
+ { id: 'set-preferences', label: () => '[Präferenzen setzen]' },
+ { 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');
+}
+
+export function CustomerDetailScreen() {
+ const { params, navigate, back } = useNavigation();
+ const customerId = params['customerId'] ?? '';
+ const { activateCustomer, deactivateCustomer, removeDeliveryAddress } = useCustomers();
+
+ const [customer, setCustomer] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedAction, setSelectedAction] = useState(0);
+ const [mode, setMode] = useState('menu');
+ const [actionLoading, setActionLoading] = useState(false);
+ const [successMessage, setSuccessMessage] = useState(null);
+ const [selectedAddrIndex, setSelectedAddrIndex] = useState(0);
+
+ const loadCustomer = useCallback(() => {
+ setLoading(true);
+ setError(null);
+ client.customers.getById(customerId)
+ .then((c) => { setCustomer(c); setLoading(false); })
+ .catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
+ }, [customerId]);
+
+ useEffect(() => { if (customerId) loadCustomer(); }, [loadCustomer, customerId]);
+
+ useInput((_input, key) => {
+ if (loading || actionLoading) return;
+
+ if (mode === 'select-remove-address' && customer) {
+ if (key.upArrow) setSelectedAddrIndex((i) => Math.max(0, i - 1));
+ if (key.downArrow) setSelectedAddrIndex((i) => Math.min(customer.deliveryAddresses.length - 1, i + 1));
+ if (key.return) {
+ const addr = customer.deliveryAddresses[selectedAddrIndex];
+ if (addr) void handleRemoveAddress(addr.label);
+ }
+ 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 (!customer) return;
+ const item = MENU_ITEMS[selectedAction];
+ if (!item) return;
+
+ switch (item.id) {
+ case 'toggle-status':
+ setMode('confirm-status');
+ break;
+ case 'add-delivery-address':
+ navigate('customer-add-delivery-address', { customerId: customer.id });
+ break;
+ case 'remove-delivery-address':
+ if (customer.deliveryAddresses.length > 0) {
+ setSelectedAddrIndex(0);
+ setMode('select-remove-address');
+ }
+ break;
+ case 'set-preferences':
+ navigate('customer-set-preferences', { customerId: customer.id });
+ break;
+ case 'back':
+ back();
+ break;
+ }
+ };
+
+ const handleToggleStatus = useCallback(async () => {
+ if (!customer) return;
+ setMode('menu');
+ setActionLoading(true);
+ const fn = customer.status === 'ACTIVE' ? deactivateCustomer : activateCustomer;
+ const updated = await fn(customer.id);
+ setActionLoading(false);
+ if (updated) {
+ setCustomer(updated);
+ setSuccessMessage(customer.status === 'ACTIVE' ? 'Kunde deaktiviert.' : 'Kunde aktiviert.');
+ }
+ }, [customer, activateCustomer, deactivateCustomer]);
+
+ const handleRemoveAddress = useCallback(async (label: string) => {
+ if (!customer) return;
+ setMode('menu');
+ setActionLoading(true);
+ const updated = await removeDeliveryAddress(customer.id, label);
+ setActionLoading(false);
+ if (updated) {
+ setCustomer(updated);
+ setSuccessMessage(`Lieferadresse "${label}" entfernt.`);
+ }
+ }, [customer, removeDeliveryAddress]);
+
+ if (loading) return ;
+ if (error && !customer) return ;
+ if (!customer) return Kunde nicht gefunden.;
+
+ const statusColor = customer.status === 'ACTIVE' ? 'green' : 'red';
+
+ return (
+
+ Kunde: {customer.name}
+
+ {error && setError(null)} />}
+ {successMessage && setSuccessMessage(null)} />}
+
+ {/* Info-Box */}
+
+
+ Status:
+ {customer.status}
+ {customer.type}
+
+
+ Telefon:
+ {customer.contactInfo.phone}
+
+ {customer.contactInfo.email && (
+
+ E-Mail:
+ {customer.contactInfo.email}
+
+ )}
+
+ Rechnungsadresse:
+ {`${customer.billingAddress.street} ${customer.billingAddress.houseNumber}, ${customer.billingAddress.postalCode} ${customer.billingAddress.city}`}
+
+ {customer.paymentTerms && (
+
+ Zahlungsziel:
+ {customer.paymentTerms.paymentDueDays} Tage
+
+ )}
+ {customer.preferences.length > 0 && (
+
+ Präferenzen:
+ {customer.preferences.map((p) => CUSTOMER_PREFERENCE_LABELS[p]).join(', ')}
+
+ )}
+ {customer.deliveryAddresses.length > 0 && (
+
+ Lieferadressen ({customer.deliveryAddresses.length}):
+ {customer.deliveryAddresses.map((addr) => (
+
+ •
+ {addr.label}:
+ {`${addr.address.street} ${addr.address.houseNumber}, ${addr.address.city}`}
+
+ ))}
+
+ )}
+ {customer.frameContract && (
+
+ Rahmenvertrag:
+
+ {customer.frameContract.validFrom ? formatDate(customer.frameContract.validFrom) : '–'} – {customer.frameContract.validUntil ? formatDate(customer.frameContract.validUntil) : '–'}
+ ({customer.frameContract.lineItems.length} Positionen)
+
+
+ )}
+
+
+ {/* Confirm Status */}
+ {mode === 'confirm-status' && (
+ void handleToggleStatus()}
+ onCancel={() => setMode('menu')}
+ />
+ )}
+
+ {/* Select Address to Remove */}
+ {mode === 'select-remove-address' && (
+
+ Lieferadresse entfernen:
+ {customer.deliveryAddresses.map((addr, i) => (
+
+
+ {i === selectedAddrIndex ? '▶ ' : ' '}{addr.label}: {addr.address.city}
+
+
+ ))}
+ ↑↓ auswählen · Enter Entfernen · Escape Abbrechen
+
+ )}
+
+ {/* Action Menu */}
+ {mode === 'menu' && (
+
+ Aktionen:
+ {actionLoading && }
+ {!actionLoading && MENU_ITEMS.map((item, index) => (
+
+
+ {index === selectedAction ? '▶ ' : ' '}{item.label(customer)}
+
+
+ ))}
+
+ )}
+
+
+ ↑↓ navigieren · Enter ausführen · Backspace Zurück
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/customers/CustomerListScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/CustomerListScreen.tsx
new file mode 100644
index 0000000..92ff9ed
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/customers/CustomerListScreen.tsx
@@ -0,0 +1,96 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCustomers } from '../../../hooks/useCustomers.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+import type { CustomerStatus, CustomerType } from '@effigenix/api-client';
+
+type StatusFilter = 'ALL' | CustomerStatus;
+type TypeFilter = 'ALL' | CustomerType;
+
+export function CustomerListScreen() {
+ const { navigate, back } = useNavigation();
+ const { customers, loading, error, fetchCustomers, clearError } = useCustomers();
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [statusFilter, setStatusFilter] = useState('ALL');
+ const [typeFilter, setTypeFilter] = useState('ALL');
+
+ useEffect(() => {
+ void fetchCustomers();
+ }, [fetchCustomers]);
+
+ const filtered = customers.filter(
+ (c) =>
+ (statusFilter === 'ALL' || c.status === statusFilter) &&
+ (typeFilter === 'ALL' || c.type === typeFilter),
+ );
+
+ 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 cust = filtered[selectedIndex];
+ if (cust) navigate('customer-detail', { customerId: cust.id });
+ }
+ if (input === 'n') navigate('customer-create');
+ if (input === 'a') { setStatusFilter('ALL'); setSelectedIndex(0); }
+ if (input === 'A') { setStatusFilter('ACTIVE'); setSelectedIndex(0); }
+ if (input === 'I') { setStatusFilter('INACTIVE'); setSelectedIndex(0); }
+ if (input === 'b') { setTypeFilter('ALL'); setSelectedIndex(0); }
+ if (input === 'B') { setTypeFilter('B2B'); setSelectedIndex(0); }
+ if (input === 'C') { setTypeFilter('B2C'); setSelectedIndex(0); }
+ if (key.backspace || key.escape) back();
+ });
+
+ return (
+
+
+ Kunden
+
+ Status: {statusFilter}
+ {' '}Typ: {typeFilter}
+ {' '}({filtered.length})
+
+
+
+ {loading && }
+ {error && !loading && }
+
+ {!loading && !error && (
+
+
+ {' St Typ Name'.padEnd(34)}
+ Adr.
+
+ {filtered.length === 0 && (
+
+ Keine Kunden gefunden.
+
+ )}
+ {filtered.map((cust, index) => {
+ const isSelected = index === selectedIndex;
+ const statusColor = cust.status === 'ACTIVE' ? 'green' : 'red';
+ const textColor = isSelected ? 'cyan' : 'white';
+ return (
+
+ {isSelected ? '▶ ' : ' '}
+ {cust.status === 'ACTIVE' ? '● ' : '○ '}
+ {cust.type.padEnd(4)}
+ {cust.name.substring(0, 26).padEnd(27)}
+ {cust.deliveryAddresses.length} Adr.
+
+ );
+ })}
+
+ )}
+
+
+
+ ↑↓ nav · Enter Details · [n] Neu · [a/A/I] Status · [b/B/C] Typ · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx b/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx
new file mode 100644
index 0000000..d5862a9
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/customers/SetPreferencesScreen.tsx
@@ -0,0 +1,89 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { Box, Text, useInput } from 'ink';
+import type { CustomerPreference } from '@effigenix/api-client';
+import { CUSTOMER_PREFERENCE_LABELS } from '@effigenix/api-client';
+import { useNavigation } from '../../../state/navigation-context.js';
+import { useCustomers } from '../../../hooks/useCustomers.js';
+import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
+import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
+import { client } from '../../../utils/api-client.js';
+
+const ALL_PREFERENCES: CustomerPreference[] = [
+ 'BIO', 'REGIONAL', 'TIERWOHL', 'HALAL', 'KOSHER', 'GLUTENFREI', 'LAKTOSEFREI',
+];
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function SetPreferencesScreen() {
+ const { params, navigate, back } = useNavigation();
+ const customerId = params['customerId'] ?? '';
+ const { setPreferences, loading, error, clearError } = useCustomers();
+
+ const [selectedIndex, setSelectedIndex] = useState(0);
+ const [checked, setChecked] = useState>(new Set());
+ const [initLoading, setInitLoading] = useState(true);
+ const [initError, setInitError] = useState(null);
+
+ useEffect(() => {
+ client.customers.getById(customerId)
+ .then((c) => { setChecked(new Set(c.preferences)); setInitLoading(false); })
+ .catch((err: unknown) => { setInitError(errorMessage(err)); setInitLoading(false); });
+ }, [customerId]);
+
+ useInput((_input, key) => {
+ if (initLoading || loading) return;
+ if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
+ if (key.downArrow) setSelectedIndex((i) => Math.min(ALL_PREFERENCES.length - 1, i + 1));
+ if (_input === ' ') {
+ const pref = ALL_PREFERENCES[selectedIndex];
+ if (pref) {
+ setChecked((prev) => {
+ const next = new Set(prev);
+ if (next.has(pref)) next.delete(pref);
+ else next.add(pref);
+ return next;
+ });
+ }
+ }
+ if (key.return) void handleSave();
+ if (key.escape) back();
+ });
+
+ const handleSave = useCallback(async () => {
+ const updated = await setPreferences(customerId, Array.from(checked));
+ if (updated) navigate('customer-detail', { customerId });
+ }, [customerId, checked, setPreferences, navigate]);
+
+ if (initLoading) return ;
+ if (initError) return ;
+ if (loading) return ;
+
+ return (
+
+ Präferenzen setzen
+ {error && }
+
+
+ {ALL_PREFERENCES.map((pref, i) => {
+ const isSelected = i === selectedIndex;
+ const isChecked = checked.has(pref);
+ return (
+
+ {isSelected ? '▶' : ' '}
+ {isChecked ? '[✓]' : '[ ]'}
+ {CUSTOMER_PREFERENCE_LABELS[pref]}
+
+ );
+ })}
+
+
+
+
+ ↑↓ navigieren · Leertaste togglen · Enter speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx
new file mode 100644
index 0000000..b113131
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/suppliers/AddCertificateScreen.tsx
@@ -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 = {
+ 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>({
+ certificateType: '',
+ issuer: '',
+ validFrom: '',
+ validUntil: '',
+ });
+ const [activeField, setActiveField] = useState('certificateType');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+
+ 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> = {};
+ 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 ;
+
+ return (
+
+ Zertifikat hinzufügen
+ {error && }
+
+
+ {FIELDS.map((field) => (
+
+ ))}
+
+
+
+
+ Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx
new file mode 100644
index 0000000..012ac26
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/suppliers/RateSupplierScreen.tsx
@@ -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 = {
+ quality: 'Qualität',
+ delivery: 'Lieferung',
+ price: 'Preis',
+};
+
+function ScoreSelector({ label, value, active }: { label: string; value: number; active: boolean }) {
+ return (
+
+ {label.padEnd(12)}
+
+ {[1, 2, 3, 4, 5].map((n) => (
+
+ {n <= value ? '★' : '☆'}
+
+ ))}
+ {value}/5
+
+
+ );
+}
+
+export function RateSupplierScreen() {
+ const { params, navigate, back } = useNavigation();
+ const supplierId = params['supplierId'] ?? '';
+ const { rateSupplier, loading, error, clearError } = useSuppliers();
+
+ const [scores, setScores] = useState>({ quality: 3, delivery: 3, price: 3 });
+ const [activeField, setActiveField] = useState('quality');
+ const [successMessage, setSuccessMessage] = useState(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 ;
+
+ return (
+
+ Lieferant bewerten
+
+ {error && }
+ {successMessage && setSuccessMessage(null)} />}
+
+
+ {FIELDS.map((field) => (
+
+ ))}
+
+
+
+
+ ↑↓ Kriterium · ←→ Bewertung ändern · Enter nächstes/Speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx
new file mode 100644
index 0000000..a786407
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierCreateScreen.tsx
@@ -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 = {
+ 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>({
+ name: '', phone: '', email: '', contactPerson: '',
+ street: '', houseNumber: '', postalCode: '', city: '', country: 'Deutschland',
+ paymentDueDays: '',
+ });
+ const [activeField, setActiveField] = useState('name');
+ const [fieldErrors, setFieldErrors] = useState>>({});
+
+ 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> = {};
+ 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 (
+
+
+
+ );
+ }
+
+ return (
+
+ Neuer Lieferant
+ {error && }
+
+
+ {FIELDS.map((field) => (
+
+ ))}
+
+
+
+
+ Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx
new file mode 100644
index 0000000..05b5a75
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierDetailScreen.tsx
@@ -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(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [selectedAction, setSelectedAction] = useState(0);
+ const [mode, setMode] = useState('menu');
+ const [actionLoading, setActionLoading] = useState(false);
+ const [successMessage, setSuccessMessage] = useState(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 ;
+ if (error && !supplier) return ;
+ if (!supplier) return Lieferant nicht gefunden.;
+
+ const statusColor = supplier.status === 'ACTIVE' ? 'green' : 'red';
+
+ return (
+
+ Lieferant: {supplier.name}
+
+ {error && setError(null)} />}
+ {successMessage && setSuccessMessage(null)} />}
+
+ {/* Info-Box */}
+
+
+ Status:
+ {supplier.status}
+
+
+ Telefon:
+ {supplier.contactInfo.phone}
+
+ {supplier.contactInfo.email && (
+
+ E-Mail:
+ {supplier.contactInfo.email}
+
+ )}
+ {supplier.contactInfo.contactPerson && (
+
+ Ansprechpartner:
+ {supplier.contactInfo.contactPerson}
+
+ )}
+ {supplier.address && (
+
+ Adresse:
+ {`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}`}
+
+ )}
+ {supplier.paymentTerms && (
+
+ Zahlungsziel:
+ {supplier.paymentTerms.paymentDueDays} Tage
+
+ )}
+ {supplier.rating && (
+
+ Bewertung:
+
+ {'★ ' + avgRating(supplier.rating)}
+ {` (Q:${supplier.rating.qualityScore} L:${supplier.rating.deliveryScore} P:${supplier.rating.priceScore})`}
+
+
+ )}
+ {supplier.certificates.length > 0 && (
+
+ Zertifikate:
+ {supplier.certificates.map((c) => (
+
+ •
+ {c.certificateType}
+ ({c.issuer}, bis {formatDate(c.validUntil)})
+
+ ))}
+
+ )}
+
+
+ {/* Confirm Status Dialog */}
+ {mode === 'confirm-status' && (
+ void handleToggleStatus()}
+ onCancel={() => setMode('menu')}
+ />
+ )}
+
+ {/* Confirm Remove Certificate */}
+ {mode === 'confirm-remove-cert' && supplier.certificates.length > 0 && (
+
+ Zertifikat auswählen:
+ {supplier.certificates.map((c, i) => (
+
+
+ {i === selectedCertIndex ? '▶ ' : ' '}{c.certificateType} – {c.issuer}
+
+
+ ))}
+ ↑↓ auswählen · Enter Entfernen · Escape Abbrechen
+
+ )}
+
+ {/* Action Menu */}
+ {mode === 'menu' && (
+
+ Aktionen:
+ {actionLoading && }
+ {!actionLoading && MENU_ITEMS.map((item, index) => (
+
+
+ {index === selectedAction ? '▶ ' : ' '}{item.label(supplier)}
+
+
+ ))}
+
+ )}
+
+
+ ↑↓ navigieren · Enter ausführen · Backspace Zurück
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/components/masterdata/suppliers/SupplierListScreen.tsx b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierListScreen.tsx
new file mode 100644
index 0000000..a2d202b
--- /dev/null
+++ b/frontend/apps/cli/src/components/masterdata/suppliers/SupplierListScreen.tsx
@@ -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('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 = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
+
+ return (
+
+
+ Lieferanten
+ Filter: {filterLabel[filter]} ({filtered.length})
+
+
+ {loading && }
+ {error && !loading && }
+
+ {!loading && !error && (
+
+
+ {' Status Name'.padEnd(32)}
+ {'Bewertung Zertifikate'}
+
+ {filtered.length === 0 && (
+
+ Keine Lieferanten gefunden.
+
+ )}
+ {filtered.map((sup, index) => {
+ const isSelected = index === selectedIndex;
+ const statusColor = sup.status === 'ACTIVE' ? 'green' : 'red';
+ const textColor = isSelected ? 'cyan' : 'white';
+ return (
+
+ {isSelected ? '▶ ' : ' '}
+ {(sup.status === 'ACTIVE' ? '● ' : '○ ')}
+ {sup.name.substring(0, 26).padEnd(27)}
+ {`★ ${avgRating(sup.rating)} `}
+ {`${sup.certificates.length} Zert.`}
+
+ );
+ })}
+
+ )}
+
+
+
+ ↑↓ nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · Backspace Zurück
+
+
+
+ );
+}
diff --git a/frontend/apps/cli/src/hooks/useArticles.ts b/frontend/apps/cli/src/hooks/useArticles.ts
new file mode 100644
index 0000000..4162867
--- /dev/null
+++ b/frontend/apps/cli/src/hooks/useArticles.ts
@@ -0,0 +1,183 @@
+import { useState, useCallback } from 'react';
+import type {
+ ArticleDTO,
+ CreateArticleRequest,
+ UpdateArticleRequest,
+ AddSalesUnitRequest,
+ UpdateSalesUnitPriceRequest,
+} from '@effigenix/api-client';
+import { client } from '../utils/api-client.js';
+
+interface ArticlesState {
+ articles: ArticleDTO[];
+ loading: boolean;
+ error: string | null;
+}
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function useArticles() {
+ const [state, setState] = useState({
+ articles: [],
+ loading: false,
+ error: null,
+ });
+
+ const fetchArticles = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const articles = await client.articles.list();
+ setState({ articles, loading: false, error: null });
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ }
+ }, []);
+
+ const createArticle = useCallback(async (request: CreateArticleRequest) => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const article = await client.articles.create(request);
+ setState((s) => ({ articles: [...s.articles, article], loading: false, error: null }));
+ return article;
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const updateArticle = useCallback(async (id: string, request: UpdateArticleRequest) => {
+ try {
+ const updated = await client.articles.update(id, request);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === id ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const activateArticle = useCallback(async (id: string) => {
+ try {
+ const updated = await client.articles.activate(id);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === id ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const deactivateArticle = useCallback(async (id: string) => {
+ try {
+ const updated = await client.articles.deactivate(id);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === id ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const addSalesUnit = useCallback(async (id: string, request: AddSalesUnitRequest) => {
+ try {
+ const updated = await client.articles.addSalesUnit(id, request);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === id ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const removeSalesUnit = useCallback(async (articleId: string, salesUnitId: string) => {
+ try {
+ const updated = await client.articles.removeSalesUnit(articleId, salesUnitId);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === articleId ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const updateSalesUnitPrice = useCallback(
+ async (articleId: string, salesUnitId: string, request: UpdateSalesUnitPriceRequest) => {
+ try {
+ const updated = await client.articles.updateSalesUnitPrice(articleId, salesUnitId, request);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === articleId ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ },
+ [],
+ );
+
+ const assignSupplier = useCallback(async (articleId: string, supplierId: string) => {
+ try {
+ const updated = await client.articles.assignSupplier(articleId, supplierId);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === articleId ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const removeSupplier = useCallback(async (articleId: string, supplierId: string) => {
+ try {
+ const updated = await client.articles.removeSupplier(articleId, supplierId);
+ setState((s) => ({
+ ...s,
+ articles: s.articles.map((a) => (a.id === articleId ? updated : a)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const clearError = useCallback(() => {
+ setState((s) => ({ ...s, error: null }));
+ }, []);
+
+ return {
+ ...state,
+ fetchArticles,
+ createArticle,
+ updateArticle,
+ activateArticle,
+ deactivateArticle,
+ addSalesUnit,
+ removeSalesUnit,
+ updateSalesUnitPrice,
+ assignSupplier,
+ removeSupplier,
+ clearError,
+ };
+}
diff --git a/frontend/apps/cli/src/hooks/useCategories.ts b/frontend/apps/cli/src/hooks/useCategories.ts
new file mode 100644
index 0000000..970e645
--- /dev/null
+++ b/frontend/apps/cli/src/hooks/useCategories.ts
@@ -0,0 +1,88 @@
+import { useState, useCallback } from 'react';
+import type { ProductCategoryDTO } from '@effigenix/api-client';
+import { client } from '../utils/api-client.js';
+
+interface CategoriesState {
+ categories: ProductCategoryDTO[];
+ loading: boolean;
+ error: string | null;
+}
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function useCategories() {
+ const [state, setState] = useState({
+ categories: [],
+ loading: false,
+ error: null,
+ });
+
+ const fetchCategories = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const categories = await client.categories.list();
+ setState({ categories, loading: false, error: null });
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ }
+ }, []);
+
+ const createCategory = useCallback(async (name: string, description?: string) => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const req = description ? { name, description } : { name };
+ const cat = await client.categories.create(req);
+ setState((s) => ({ categories: [...s.categories, cat], loading: false, error: null }));
+ return cat;
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const updateCategory = useCallback(
+ async (id: string, name: string, description: string | null) => {
+ try {
+ const updated = await client.categories.update(id, { name, description });
+ setState((s) => ({
+ ...s,
+ categories: s.categories.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ },
+ [],
+ );
+
+ const deleteCategory = useCallback(async (id: string) => {
+ try {
+ await client.categories.delete(id);
+ setState((s) => ({
+ ...s,
+ categories: s.categories.filter((c) => c.id !== id),
+ }));
+ return true;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return false;
+ }
+ }, []);
+
+ const clearError = useCallback(() => {
+ setState((s) => ({ ...s, error: null }));
+ }, []);
+
+ return {
+ ...state,
+ fetchCategories,
+ createCategory,
+ updateCategory,
+ deleteCategory,
+ clearError,
+ };
+}
diff --git a/frontend/apps/cli/src/hooks/useCustomers.ts b/frontend/apps/cli/src/hooks/useCustomers.ts
new file mode 100644
index 0000000..b3c5122
--- /dev/null
+++ b/frontend/apps/cli/src/hooks/useCustomers.ts
@@ -0,0 +1,150 @@
+import { useState, useCallback } from 'react';
+import type {
+ CustomerDTO,
+ CustomerPreference,
+ CreateCustomerRequest,
+ UpdateCustomerRequest,
+ AddDeliveryAddressRequest,
+} from '@effigenix/api-client';
+import { client } from '../utils/api-client.js';
+
+interface CustomersState {
+ customers: CustomerDTO[];
+ loading: boolean;
+ error: string | null;
+}
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function useCustomers() {
+ const [state, setState] = useState({
+ customers: [],
+ loading: false,
+ error: null,
+ });
+
+ const fetchCustomers = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const customers = await client.customers.list();
+ setState({ customers, loading: false, error: null });
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ }
+ }, []);
+
+ const createCustomer = useCallback(async (request: CreateCustomerRequest) => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const customer = await client.customers.create(request);
+ setState((s) => ({ customers: [...s.customers, customer], loading: false, error: null }));
+ return customer;
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const updateCustomer = useCallback(async (id: string, request: UpdateCustomerRequest) => {
+ try {
+ const updated = await client.customers.update(id, request);
+ setState((s) => ({
+ ...s,
+ customers: s.customers.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const activateCustomer = useCallback(async (id: string) => {
+ try {
+ const updated = await client.customers.activate(id);
+ setState((s) => ({
+ ...s,
+ customers: s.customers.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const deactivateCustomer = useCallback(async (id: string) => {
+ try {
+ const updated = await client.customers.deactivate(id);
+ setState((s) => ({
+ ...s,
+ customers: s.customers.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const addDeliveryAddress = useCallback(async (id: string, request: AddDeliveryAddressRequest) => {
+ try {
+ const updated = await client.customers.addDeliveryAddress(id, request);
+ setState((s) => ({
+ ...s,
+ customers: s.customers.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const removeDeliveryAddress = useCallback(async (id: string, label: string) => {
+ try {
+ const updated = await client.customers.removeDeliveryAddress(id, label);
+ setState((s) => ({
+ ...s,
+ customers: s.customers.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const setPreferences = useCallback(async (id: string, preferences: CustomerPreference[]) => {
+ try {
+ const updated = await client.customers.setPreferences(id, preferences);
+ setState((s) => ({
+ ...s,
+ customers: s.customers.map((c) => (c.id === id ? updated : c)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const clearError = useCallback(() => {
+ setState((s) => ({ ...s, error: null }));
+ }, []);
+
+ return {
+ ...state,
+ fetchCustomers,
+ createCustomer,
+ updateCustomer,
+ activateCustomer,
+ deactivateCustomer,
+ addDeliveryAddress,
+ removeDeliveryAddress,
+ setPreferences,
+ clearError,
+ };
+}
diff --git a/frontend/apps/cli/src/hooks/useSuppliers.ts b/frontend/apps/cli/src/hooks/useSuppliers.ts
new file mode 100644
index 0000000..a53ad21
--- /dev/null
+++ b/frontend/apps/cli/src/hooks/useSuppliers.ts
@@ -0,0 +1,151 @@
+import { useState, useCallback } from 'react';
+import type {
+ SupplierDTO,
+ CreateSupplierRequest,
+ UpdateSupplierRequest,
+ RateSupplierRequest,
+ AddCertificateRequest,
+ RemoveCertificateRequest,
+} from '@effigenix/api-client';
+import { client } from '../utils/api-client.js';
+
+interface SuppliersState {
+ suppliers: SupplierDTO[];
+ loading: boolean;
+ error: string | null;
+}
+
+function errorMessage(err: unknown): string {
+ return err instanceof Error ? err.message : 'Unbekannter Fehler';
+}
+
+export function useSuppliers() {
+ const [state, setState] = useState({
+ suppliers: [],
+ loading: false,
+ error: null,
+ });
+
+ const fetchSuppliers = useCallback(async () => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const suppliers = await client.suppliers.list();
+ setState({ suppliers, loading: false, error: null });
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ }
+ }, []);
+
+ const createSupplier = useCallback(async (request: CreateSupplierRequest) => {
+ setState((s) => ({ ...s, loading: true, error: null }));
+ try {
+ const supplier = await client.suppliers.create(request);
+ setState((s) => ({ suppliers: [...s.suppliers, supplier], loading: false, error: null }));
+ return supplier;
+ } catch (err) {
+ setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const updateSupplier = useCallback(async (id: string, request: UpdateSupplierRequest) => {
+ try {
+ const updated = await client.suppliers.update(id, request);
+ setState((s) => ({
+ ...s,
+ suppliers: s.suppliers.map((sup) => (sup.id === id ? updated : sup)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const activateSupplier = useCallback(async (id: string) => {
+ try {
+ const updated = await client.suppliers.activate(id);
+ setState((s) => ({
+ ...s,
+ suppliers: s.suppliers.map((sup) => (sup.id === id ? updated : sup)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const deactivateSupplier = useCallback(async (id: string) => {
+ try {
+ const updated = await client.suppliers.deactivate(id);
+ setState((s) => ({
+ ...s,
+ suppliers: s.suppliers.map((sup) => (sup.id === id ? updated : sup)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const rateSupplier = useCallback(async (id: string, request: RateSupplierRequest) => {
+ try {
+ const updated = await client.suppliers.rate(id, request);
+ setState((s) => ({
+ ...s,
+ suppliers: s.suppliers.map((sup) => (sup.id === id ? updated : sup)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const addCertificate = useCallback(async (id: string, request: AddCertificateRequest) => {
+ try {
+ const updated = await client.suppliers.addCertificate(id, request);
+ setState((s) => ({
+ ...s,
+ suppliers: s.suppliers.map((sup) => (sup.id === id ? updated : sup)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const removeCertificate = useCallback(async (id: string, request: RemoveCertificateRequest) => {
+ try {
+ const updated = await client.suppliers.removeCertificate(id, request);
+ setState((s) => ({
+ ...s,
+ suppliers: s.suppliers.map((sup) => (sup.id === id ? updated : sup)),
+ }));
+ return updated;
+ } catch (err) {
+ setState((s) => ({ ...s, error: errorMessage(err) }));
+ return null;
+ }
+ }, []);
+
+ const clearError = useCallback(() => {
+ setState((s) => ({ ...s, error: null }));
+ }, []);
+
+ return {
+ ...state,
+ fetchSuppliers,
+ createSupplier,
+ updateSupplier,
+ activateSupplier,
+ deactivateSupplier,
+ rateSupplier,
+ addCertificate,
+ removeCertificate,
+ clearError,
+ };
+}
diff --git a/frontend/apps/cli/src/state/navigation-context.tsx b/frontend/apps/cli/src/state/navigation-context.tsx
index 245d4bd..d423ee2 100644
--- a/frontend/apps/cli/src/state/navigation-context.tsx
+++ b/frontend/apps/cli/src/state/navigation-context.tsx
@@ -8,7 +8,26 @@ export type Screen =
| 'user-detail'
| 'change-password'
| 'role-list'
- | 'role-detail';
+ | 'role-detail'
+ // Stammdaten
+ | 'masterdata-menu'
+ | 'category-list'
+ | 'category-detail'
+ | 'category-create'
+ | 'supplier-list'
+ | 'supplier-detail'
+ | 'supplier-create'
+ | 'supplier-rate'
+ | 'supplier-add-certificate'
+ | 'article-list'
+ | 'article-detail'
+ | 'article-create'
+ | 'article-add-sales-unit'
+ | 'customer-list'
+ | 'customer-detail'
+ | 'customer-create'
+ | 'customer-add-delivery-address'
+ | 'customer-set-preferences';
interface NavigationState {
current: Screen;
diff --git a/frontend/packages/api-client/src/index.ts b/frontend/packages/api-client/src/index.ts
index 04238b6..b692c6d 100644
--- a/frontend/packages/api-client/src/index.ts
+++ b/frontend/packages/api-client/src/index.ts
@@ -18,6 +18,10 @@ export type { TokenProvider } from './token-provider.js';
export { createAuthResource } from './resources/auth.js';
export { createUsersResource } from './resources/users.js';
export { createRolesResource } from './resources/roles.js';
+export { createCategoriesResource } from './resources/categories.js';
+export { createSuppliersResource } from './resources/suppliers.js';
+export { createArticlesResource } from './resources/articles.js';
+export { createCustomersResource } from './resources/customers.js';
export {
ApiError,
AuthenticationError,
@@ -41,11 +45,64 @@ export type {
UsersResource,
} from './resources/users.js';
export type { RolesResource } from './resources/roles.js';
+export type {
+ ProductCategoryDTO,
+ CreateCategoryRequest,
+ UpdateCategoryRequest,
+ CategoriesResource,
+} from './resources/categories.js';
+export type {
+ SupplierDTO,
+ SupplierStatus,
+ AddressDTO,
+ ContactInfoDTO,
+ PaymentTermsDTO,
+ QualityCertificateDTO,
+ SupplierRatingDTO,
+ CreateSupplierRequest,
+ UpdateSupplierRequest,
+ RateSupplierRequest,
+ AddCertificateRequest,
+ RemoveCertificateRequest,
+ SuppliersResource,
+} from './resources/suppliers.js';
+export type {
+ ArticleDTO,
+ ArticleStatus,
+ SalesUnitDTO,
+ Unit,
+ PriceModel,
+ CreateArticleRequest,
+ UpdateArticleRequest,
+ AddSalesUnitRequest,
+ UpdateSalesUnitPriceRequest,
+ ArticlesResource,
+} from './resources/articles.js';
+export { UNIT_LABELS, PRICE_MODEL_LABELS } from './resources/articles.js';
+export type {
+ CustomerDTO,
+ CustomerType,
+ CustomerStatus,
+ CustomerPreference,
+ DeliveryRhythm,
+ DeliveryAddressDTO,
+ FrameContractDTO,
+ ContractLineItemDTO,
+ CreateCustomerRequest,
+ UpdateCustomerRequest,
+ AddDeliveryAddressRequest,
+ CustomersResource,
+} from './resources/customers.js';
+export { CUSTOMER_PREFERENCE_LABELS, DELIVERY_RHYTHM_LABELS } from './resources/customers.js';
import { createApiClient } from './client.js';
import { createAuthResource } from './resources/auth.js';
import { createUsersResource } from './resources/users.js';
import { createRolesResource } from './resources/roles.js';
+import { createCategoriesResource } from './resources/categories.js';
+import { createSuppliersResource } from './resources/suppliers.js';
+import { createArticlesResource } from './resources/articles.js';
+import { createCustomersResource } from './resources/customers.js';
import type { TokenProvider } from './token-provider.js';
import type { ApiConfig } from '@effigenix/config';
@@ -63,6 +120,10 @@ export function createEffigenixClient(
auth: createAuthResource(axiosClient),
users: createUsersResource(axiosClient),
roles: createRolesResource(axiosClient),
+ categories: createCategoriesResource(axiosClient),
+ suppliers: createSuppliersResource(axiosClient),
+ articles: createArticlesResource(axiosClient),
+ customers: createCustomersResource(axiosClient),
};
}
diff --git a/frontend/packages/api-client/src/resources/articles.ts b/frontend/packages/api-client/src/resources/articles.ts
new file mode 100644
index 0000000..d39cb74
--- /dev/null
+++ b/frontend/packages/api-client/src/resources/articles.ts
@@ -0,0 +1,196 @@
+/**
+ * Articles resource – Real HTTP implementation.
+ * Endpoints: GET/POST /api/articles, GET/PUT /api/articles/{id},
+ * POST /api/articles/{id}/activate|deactivate,
+ * POST /api/articles/{id}/sales-units, DELETE /api/articles/{id}/sales-units/{suId},
+ * PUT /api/articles/{id}/sales-units/{suId}/price,
+ * POST /api/articles/{id}/suppliers, DELETE /api/articles/{id}/suppliers/{supplierId}
+ *
+ * NOTE: Backend returns domain objects with nested VOs:
+ * { "id": {"value": "uuid"}, "name": {"value": "..."}, ...,
+ * "supplierReferences": [{"value": "uuid"}],
+ * "salesUnits": [{"id": {"value":"uuid"}, "unit":"KG", "priceModel":"WEIGHT_BASED",
+ * "price": {"amount": 2.49, "currency": "EUR"}}] }
+ * DELETE endpoints for sales-units and suppliers return 204 No Content → re-fetch.
+ */
+
+import type { AxiosInstance } from 'axios';
+
+export type Unit = 'PIECE_FIXED' | 'KG' | 'HUNDRED_GRAM' | 'PIECE_VARIABLE';
+export type PriceModel = 'FIXED' | 'WEIGHT_BASED';
+export type ArticleStatus = 'ACTIVE' | 'INACTIVE';
+
+export const UNIT_LABELS: Record = {
+ PIECE_FIXED: 'Stück (fix)',
+ KG: 'Kilogramm',
+ HUNDRED_GRAM: '100g',
+ PIECE_VARIABLE: 'Stück (variabel)',
+};
+
+export const PRICE_MODEL_LABELS: Record = {
+ FIXED: 'Festpreis',
+ WEIGHT_BASED: 'Gewichtsbasiert',
+};
+
+export interface SalesUnitDTO {
+ id: string;
+ unit: Unit;
+ priceModel: PriceModel;
+ price: number;
+}
+
+export interface ArticleDTO {
+ id: string;
+ name: string;
+ articleNumber: string;
+ categoryId: string;
+ salesUnits: SalesUnitDTO[];
+ status: ArticleStatus;
+ supplierIds: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateArticleRequest {
+ name: string;
+ articleNumber: string;
+ categoryId: string;
+ unit: Unit;
+ priceModel: PriceModel;
+ price: number;
+}
+
+export interface UpdateArticleRequest {
+ name?: string;
+ categoryId?: string;
+}
+
+export interface AddSalesUnitRequest {
+ unit: Unit;
+ priceModel: PriceModel;
+ price: number;
+}
+
+export interface UpdateSalesUnitPriceRequest {
+ price: number;
+}
+
+// ── Backend response shapes (domain objects with nested VOs) ─────────────────
+
+interface BackendSalesUnit {
+ id: { value: string };
+ unit: Unit;
+ priceModel: PriceModel;
+ price: { amount: number; currency: string };
+}
+
+interface BackendArticle {
+ id: { value: string };
+ name: { value: string };
+ articleNumber: { value: string };
+ categoryId: { value: string };
+ salesUnits: BackendSalesUnit[];
+ status: ArticleStatus;
+ supplierReferences: Array<{ value: string }>;
+ createdAt: string;
+ updatedAt: string;
+}
+
+function mapSalesUnit(bsu: BackendSalesUnit): SalesUnitDTO {
+ return {
+ id: bsu.id.value,
+ unit: bsu.unit,
+ priceModel: bsu.priceModel,
+ price: bsu.price.amount,
+ };
+}
+
+function mapArticle(ba: BackendArticle): ArticleDTO {
+ return {
+ id: ba.id.value,
+ name: ba.name.value,
+ articleNumber: ba.articleNumber.value,
+ categoryId: ba.categoryId.value,
+ salesUnits: ba.salesUnits.map(mapSalesUnit),
+ status: ba.status,
+ supplierIds: ba.supplierReferences.map((sr) => sr.value),
+ createdAt: ba.createdAt,
+ updatedAt: ba.updatedAt,
+ };
+}
+
+// ── Resource factory ─────────────────────────────────────────────────────────
+
+export function createArticlesResource(client: AxiosInstance) {
+ return {
+ async list(): Promise {
+ const res = await client.get('/api/articles');
+ return res.data.map(mapArticle);
+ },
+
+ async getById(id: string): Promise {
+ const res = await client.get(`/api/articles/${id}`);
+ return mapArticle(res.data);
+ },
+
+ async create(request: CreateArticleRequest): Promise {
+ const res = await client.post('/api/articles', request);
+ return mapArticle(res.data);
+ },
+
+ async update(id: string, request: UpdateArticleRequest): Promise {
+ const res = await client.put(`/api/articles/${id}`, request);
+ return mapArticle(res.data);
+ },
+
+ async activate(id: string): Promise {
+ const res = await client.post(`/api/articles/${id}/activate`);
+ return mapArticle(res.data);
+ },
+
+ async deactivate(id: string): Promise {
+ const res = await client.post(`/api/articles/${id}/deactivate`);
+ return mapArticle(res.data);
+ },
+
+ async addSalesUnit(id: string, request: AddSalesUnitRequest): Promise {
+ const res = await client.post(`/api/articles/${id}/sales-units`, request);
+ return mapArticle(res.data);
+ },
+
+ // Returns 204 No Content → re-fetch article
+ async removeSalesUnit(articleId: string, salesUnitId: string): Promise {
+ await client.delete(`/api/articles/${articleId}/sales-units/${salesUnitId}`);
+ const res = await client.get(`/api/articles/${articleId}`);
+ return mapArticle(res.data);
+ },
+
+ async updateSalesUnitPrice(
+ articleId: string,
+ salesUnitId: string,
+ request: UpdateSalesUnitPriceRequest,
+ ): Promise {
+ const res = await client.put(
+ `/api/articles/${articleId}/sales-units/${salesUnitId}/price`,
+ request,
+ );
+ return mapArticle(res.data);
+ },
+
+ async assignSupplier(articleId: string, supplierId: string): Promise {
+ const res = await client.post(`/api/articles/${articleId}/suppliers`, {
+ supplierId,
+ });
+ return mapArticle(res.data);
+ },
+
+ // Returns 204 No Content → re-fetch article
+ async removeSupplier(articleId: string, supplierId: string): Promise {
+ await client.delete(`/api/articles/${articleId}/suppliers/${supplierId}`);
+ const res = await client.get(`/api/articles/${articleId}`);
+ return mapArticle(res.data);
+ },
+ };
+}
+
+export type ArticlesResource = ReturnType;
diff --git a/frontend/packages/api-client/src/resources/categories.ts b/frontend/packages/api-client/src/resources/categories.ts
new file mode 100644
index 0000000..9749a96
--- /dev/null
+++ b/frontend/packages/api-client/src/resources/categories.ts
@@ -0,0 +1,77 @@
+/**
+ * Categories resource – Real HTTP implementation.
+ * Endpoints: GET/POST /api/categories, PUT/DELETE /api/categories/{id}
+ *
+ * NOTE: The backend returns domain objects serialized with Jackson field-visibility.
+ * VOs like ProductCategoryId and CategoryName serialize as nested records:
+ * { "id": {"value": "uuid"}, "name": {"value": "string"}, "description": "string|null" }
+ */
+
+import type { AxiosInstance } from 'axios';
+
+export interface ProductCategoryDTO {
+ id: string;
+ name: string;
+ description: string | null;
+}
+
+export interface CreateCategoryRequest {
+ name: string;
+ description?: string;
+}
+
+export interface UpdateCategoryRequest {
+ name?: string;
+ description?: string | null;
+}
+
+// ── Backend response shapes (domain objects with nested VOs) ─────────────────
+
+interface BackendProductCategory {
+ id: { value: string };
+ name: { value: string };
+ description: string | null;
+}
+
+function mapCategory(bc: BackendProductCategory): ProductCategoryDTO {
+ return {
+ id: bc.id.value,
+ name: bc.name.value,
+ description: bc.description,
+ };
+}
+
+// ── Resource factory ─────────────────────────────────────────────────────────
+
+export function createCategoriesResource(client: AxiosInstance) {
+ return {
+ async list(): Promise {
+ const res = await client.get('/api/categories');
+ return res.data.map(mapCategory);
+ },
+
+ // No GET /api/categories/{id} endpoint – implemented as list + filter
+ async getById(id: string): Promise {
+ const res = await client.get('/api/categories');
+ const cat = res.data.find((c) => c.id.value === id);
+ if (!cat) throw new Error(`Kategorie nicht gefunden: ${id}`);
+ return mapCategory(cat);
+ },
+
+ async create(request: CreateCategoryRequest): Promise {
+ const res = await client.post('/api/categories', request);
+ return mapCategory(res.data);
+ },
+
+ async update(id: string, request: UpdateCategoryRequest): Promise {
+ const res = await client.put(`/api/categories/${id}`, request);
+ return mapCategory(res.data);
+ },
+
+ async delete(id: string): Promise {
+ await client.delete(`/api/categories/${id}`);
+ },
+ };
+}
+
+export type CategoriesResource = ReturnType;
diff --git a/frontend/packages/api-client/src/resources/customers.ts b/frontend/packages/api-client/src/resources/customers.ts
new file mode 100644
index 0000000..66801e0
--- /dev/null
+++ b/frontend/packages/api-client/src/resources/customers.ts
@@ -0,0 +1,297 @@
+/**
+ * Customers resource – Real HTTP implementation.
+ * Endpoints: GET/POST /api/customers, GET/PUT /api/customers/{id},
+ * POST /api/customers/{id}/activate|deactivate,
+ * POST /api/customers/{id}/delivery-addresses,
+ * DELETE /api/customers/{id}/delivery-addresses/{label},
+ * PUT /api/customers/{id}/frame-contract,
+ * DELETE /api/customers/{id}/frame-contract,
+ * PUT /api/customers/{id}/preferences
+ *
+ * NOTE: Backend returns domain objects with nested VOs:
+ * { "id": {"value":"uuid"}, "name": {"value":"string"},
+ * "billingAddress": {street, houseNumber, postalCode, city, country},
+ * "contactInfo": {phone, email, contactPerson},
+ * "paymentTerms": {paymentDueDays, description},
+ * "deliveryAddresses": [{label, address: {...}, contactPerson, deliveryNotes}],
+ * "frameContract": {"id": {"value":"uuid"}, validFrom, validUntil, deliveryRhythm, lineItems},
+ * "preferences": ["BIO", ...], "status": "ACTIVE", ... }
+ * DELETE delivery-addresses/{label} and DELETE frame-contract return 204 → re-fetch.
+ */
+
+import type { AxiosInstance } from 'axios';
+import type { AddressDTO, ContactInfoDTO, PaymentTermsDTO } from './suppliers.js';
+
+export type CustomerType = 'B2B' | 'B2C';
+export type CustomerStatus = 'ACTIVE' | 'INACTIVE';
+export type CustomerPreference =
+ | 'BIO'
+ | 'REGIONAL'
+ | 'TIERWOHL'
+ | 'HALAL'
+ | 'KOSHER'
+ | 'GLUTENFREI'
+ | 'LAKTOSEFREI';
+export type DeliveryRhythm = 'DAILY' | 'WEEKLY' | 'BIWEEKLY' | 'MONTHLY' | 'ON_DEMAND';
+
+export const CUSTOMER_PREFERENCE_LABELS: Record = {
+ BIO: 'Bio',
+ REGIONAL: 'Regional',
+ TIERWOHL: 'Tierwohl',
+ HALAL: 'Halal',
+ KOSHER: 'Koscher',
+ GLUTENFREI: 'Glutenfrei',
+ LAKTOSEFREI: 'Laktosefrei',
+};
+
+export const DELIVERY_RHYTHM_LABELS: Record = {
+ DAILY: 'Täglich',
+ WEEKLY: 'Wöchentlich',
+ BIWEEKLY: 'Zweiwöchentlich',
+ MONTHLY: 'Monatlich',
+ ON_DEMAND: 'Nach Bedarf',
+};
+
+export interface DeliveryAddressDTO {
+ label: string;
+ address: AddressDTO;
+ contactPerson: string | null;
+ deliveryNotes: string | null;
+}
+
+export interface ContractLineItemDTO {
+ articleId: string;
+ agreedPrice: number;
+ agreedQuantity: number | null;
+ unit: string | null;
+}
+
+export interface FrameContractDTO {
+ id: string;
+ validFrom: string | null;
+ validUntil: string | null;
+ deliveryRhythm: DeliveryRhythm;
+ lineItems: ContractLineItemDTO[];
+}
+
+export interface CustomerDTO {
+ id: string;
+ name: string;
+ type: CustomerType;
+ status: CustomerStatus;
+ billingAddress: AddressDTO;
+ contactInfo: ContactInfoDTO;
+ paymentTerms: PaymentTermsDTO | null;
+ deliveryAddresses: DeliveryAddressDTO[];
+ frameContract: FrameContractDTO | null;
+ preferences: CustomerPreference[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateCustomerRequest {
+ name: string;
+ type: CustomerType;
+ phone: string;
+ street: string;
+ houseNumber: string;
+ postalCode: string;
+ city: string;
+ country: string;
+ email?: string;
+ contactPerson?: string;
+ paymentDueDays?: number;
+ paymentDescription?: string;
+}
+
+export interface UpdateCustomerRequest {
+ name?: string;
+ phone?: string;
+ email?: string | null;
+ contactPerson?: string | null;
+ street?: string;
+ houseNumber?: string;
+ postalCode?: string;
+ city?: string;
+ country?: string;
+ paymentDueDays?: number | null;
+ paymentDescription?: string | null;
+}
+
+export interface AddDeliveryAddressRequest {
+ label: string;
+ street: string;
+ houseNumber: string;
+ postalCode: string;
+ city: string;
+ country: string;
+ contactPerson?: string;
+ deliveryNotes?: string;
+}
+
+export interface SetFrameContractLineItem {
+ articleId: string;
+ agreedPrice: number;
+ agreedQuantity?: number;
+ unit?: string;
+}
+
+export interface SetFrameContractRequest {
+ validFrom?: string;
+ validUntil?: string;
+ rhythm: DeliveryRhythm;
+ lineItems: SetFrameContractLineItem[];
+}
+
+// ── Backend response shapes (domain objects with nested VOs) ─────────────────
+
+interface BackendPaymentTerms {
+ paymentDueDays: number;
+ description: string | null; // Note: backend field is "description", not "paymentDescription"
+}
+
+interface BackendContractLineItem {
+ articleId: { value: string };
+ agreedPrice: { amount: number; currency: string };
+ agreedQuantity: number | null;
+ unit: string | null;
+}
+
+interface BackendFrameContract {
+ id: { value: string };
+ validFrom: string | null;
+ validUntil: string | null;
+ deliveryRhythm: DeliveryRhythm;
+ lineItems: BackendContractLineItem[];
+}
+
+interface BackendCustomer {
+ id: { value: string };
+ name: { value: string };
+ type: CustomerType;
+ status: CustomerStatus;
+ billingAddress: AddressDTO;
+ contactInfo: ContactInfoDTO;
+ paymentTerms: BackendPaymentTerms | null;
+ deliveryAddresses: DeliveryAddressDTO[]; // DeliveryAddress is a record → matches DTO shape
+ frameContract: BackendFrameContract | null;
+ preferences: CustomerPreference[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+function mapLineItem(bli: BackendContractLineItem): ContractLineItemDTO {
+ return {
+ articleId: bli.articleId.value,
+ agreedPrice: bli.agreedPrice.amount,
+ agreedQuantity: bli.agreedQuantity,
+ unit: bli.unit,
+ };
+}
+
+function mapFrameContract(bfc: BackendFrameContract): FrameContractDTO {
+ return {
+ id: bfc.id.value,
+ validFrom: bfc.validFrom,
+ validUntil: bfc.validUntil,
+ deliveryRhythm: bfc.deliveryRhythm,
+ lineItems: bfc.lineItems.map(mapLineItem),
+ };
+}
+
+function mapCustomer(bc: BackendCustomer): CustomerDTO {
+ return {
+ id: bc.id.value,
+ name: bc.name.value,
+ type: bc.type,
+ status: bc.status,
+ billingAddress: bc.billingAddress,
+ contactInfo: bc.contactInfo,
+ paymentTerms: bc.paymentTerms
+ ? {
+ paymentDueDays: bc.paymentTerms.paymentDueDays,
+ paymentDescription: bc.paymentTerms.description,
+ }
+ : null,
+ deliveryAddresses: bc.deliveryAddresses,
+ frameContract: bc.frameContract ? mapFrameContract(bc.frameContract) : null,
+ preferences: bc.preferences,
+ createdAt: bc.createdAt,
+ updatedAt: bc.updatedAt,
+ };
+}
+
+// ── Resource factory ─────────────────────────────────────────────────────────
+
+export function createCustomersResource(client: AxiosInstance) {
+ return {
+ async list(): Promise {
+ const res = await client.get('/api/customers');
+ return res.data.map(mapCustomer);
+ },
+
+ async getById(id: string): Promise {
+ const res = await client.get(`/api/customers/${id}`);
+ return mapCustomer(res.data);
+ },
+
+ async create(request: CreateCustomerRequest): Promise {
+ const res = await client.post('/api/customers', request);
+ return mapCustomer(res.data);
+ },
+
+ async update(id: string, request: UpdateCustomerRequest): Promise {
+ const res = await client.put(`/api/customers/${id}`, request);
+ return mapCustomer(res.data);
+ },
+
+ async activate(id: string): Promise {
+ const res = await client.post(`/api/customers/${id}/activate`);
+ return mapCustomer(res.data);
+ },
+
+ async deactivate(id: string): Promise {
+ const res = await client.post(`/api/customers/${id}/deactivate`);
+ return mapCustomer(res.data);
+ },
+
+ async addDeliveryAddress(id: string, request: AddDeliveryAddressRequest): Promise {
+ const res = await client.post(
+ `/api/customers/${id}/delivery-addresses`,
+ request,
+ );
+ return mapCustomer(res.data);
+ },
+
+ // Returns 204 No Content → re-fetch customer
+ async removeDeliveryAddress(id: string, label: string): Promise {
+ await client.delete(`/api/customers/${id}/delivery-addresses/${encodeURIComponent(label)}`);
+ const res = await client.get(`/api/customers/${id}`);
+ return mapCustomer(res.data);
+ },
+
+ async setFrameContract(id: string, request: SetFrameContractRequest): Promise {
+ const res = await client.put(
+ `/api/customers/${id}/frame-contract`,
+ request,
+ );
+ return mapCustomer(res.data);
+ },
+
+ // Returns 204 No Content → re-fetch customer
+ async removeFrameContract(id: string): Promise {
+ await client.delete(`/api/customers/${id}/frame-contract`);
+ const res = await client.get(`/api/customers/${id}`);
+ return mapCustomer(res.data);
+ },
+
+ async setPreferences(id: string, preferences: CustomerPreference[]): Promise {
+ const res = await client.put(`/api/customers/${id}/preferences`, {
+ preferences,
+ });
+ return mapCustomer(res.data);
+ },
+ };
+}
+
+export type CustomersResource = ReturnType;
diff --git a/frontend/packages/api-client/src/resources/suppliers.ts b/frontend/packages/api-client/src/resources/suppliers.ts
new file mode 100644
index 0000000..5314d86
--- /dev/null
+++ b/frontend/packages/api-client/src/resources/suppliers.ts
@@ -0,0 +1,236 @@
+/**
+ * Suppliers resource – Real HTTP implementation.
+ * Endpoints: GET/POST /api/suppliers, GET/PUT /api/suppliers/{id},
+ * POST /api/suppliers/{id}/activate|deactivate,
+ * POST /api/suppliers/{id}/rating,
+ * POST /api/suppliers/{id}/certificates,
+ * DELETE /api/suppliers/{id}/certificates (with body)
+ *
+ * NOTE: Backend returns domain objects with nested VOs:
+ * { "id": {"value":"uuid"}, "name": {"value":"string"},
+ * "address": {"street":"...","houseNumber":"...","postalCode":"...","city":"...","country":"DE"},
+ * "contactInfo": {"phone":"...","email":"...","contactPerson":"..."},
+ * "paymentTerms": {"paymentDueDays":30,"description":"..."},
+ * "certificates": [{"certificateType":"...","issuer":"...","validFrom":"2024-01-01","validUntil":"2026-12-31"}],
+ * "rating": {"qualityScore":4,"deliveryScore":4,"priceScore":5},
+ * "status": "ACTIVE", "createdAt":"...", "updatedAt":"..." }
+ * DELETE /api/suppliers/{id}/certificates returns 204 No Content → re-fetch.
+ */
+
+import type { AxiosInstance } from 'axios';
+
+export interface AddressDTO {
+ street: string;
+ houseNumber: string | null;
+ postalCode: string;
+ city: string;
+ country: string;
+}
+
+export interface ContactInfoDTO {
+ phone: string;
+ email: string | null;
+ contactPerson: string | null;
+}
+
+export interface PaymentTermsDTO {
+ paymentDueDays: number;
+ paymentDescription: string | null;
+}
+
+export interface QualityCertificateDTO {
+ certificateType: string;
+ issuer: string;
+ validFrom: string;
+ validUntil: string;
+}
+
+export interface SupplierRatingDTO {
+ qualityScore: number;
+ deliveryScore: number;
+ priceScore: number;
+}
+
+export type SupplierStatus = 'ACTIVE' | 'INACTIVE';
+
+export interface SupplierDTO {
+ id: string;
+ name: string;
+ status: SupplierStatus;
+ address: AddressDTO | null;
+ contactInfo: ContactInfoDTO;
+ paymentTerms: PaymentTermsDTO | null;
+ certificates: QualityCertificateDTO[];
+ rating: SupplierRatingDTO | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface CreateSupplierRequest {
+ name: string;
+ phone: string;
+ email?: string;
+ contactPerson?: string;
+ street?: string;
+ houseNumber?: string;
+ postalCode?: string;
+ city?: string;
+ country?: string;
+ paymentDueDays?: number;
+ paymentDescription?: string;
+}
+
+export interface UpdateSupplierRequest {
+ name?: string;
+ phone?: string;
+ email?: string | null;
+ contactPerson?: string | null;
+ street?: string | null;
+ houseNumber?: string | null;
+ postalCode?: string | null;
+ city?: string | null;
+ country?: string | null;
+ paymentDueDays?: number | null;
+ paymentDescription?: string | null;
+}
+
+export interface RateSupplierRequest {
+ qualityScore: number;
+ deliveryScore: number;
+ priceScore: number;
+}
+
+export interface AddCertificateRequest {
+ certificateType: string;
+ issuer: string;
+ validFrom: string;
+ validUntil: string;
+}
+
+export interface RemoveCertificateRequest {
+ certificateType: string;
+ issuer: string;
+ validFrom: string;
+}
+
+// ── Backend response shapes (domain objects with nested VOs) ─────────────────
+
+interface BackendAddress {
+ street: string;
+ houseNumber: string | null;
+ postalCode: string;
+ city: string;
+ country: string;
+}
+
+interface BackendContactInfo {
+ phone: string;
+ email: string | null;
+ contactPerson: string | null;
+}
+
+interface BackendPaymentTerms {
+ paymentDueDays: number;
+ description: string | null; // Note: backend field is "description", not "paymentDescription"
+}
+
+interface BackendQualityCertificate {
+ certificateType: string;
+ issuer: string;
+ validFrom: string; // LocalDate → "2024-01-01"
+ validUntil: string;
+}
+
+interface BackendSupplierRating {
+ qualityScore: number;
+ deliveryScore: number;
+ priceScore: number;
+}
+
+interface BackendSupplier {
+ id: { value: string };
+ name: { value: string };
+ address: BackendAddress | null;
+ contactInfo: BackendContactInfo;
+ paymentTerms: BackendPaymentTerms | null;
+ certificates: BackendQualityCertificate[];
+ rating: BackendSupplierRating | null;
+ status: SupplierStatus;
+ createdAt: string;
+ updatedAt: string;
+}
+
+function mapSupplier(bs: BackendSupplier): SupplierDTO {
+ return {
+ id: bs.id.value,
+ name: bs.name.value,
+ status: bs.status,
+ address: bs.address,
+ contactInfo: bs.contactInfo,
+ paymentTerms: bs.paymentTerms
+ ? {
+ paymentDueDays: bs.paymentTerms.paymentDueDays,
+ paymentDescription: bs.paymentTerms.description,
+ }
+ : null,
+ certificates: bs.certificates,
+ rating: bs.rating,
+ createdAt: bs.createdAt,
+ updatedAt: bs.updatedAt,
+ };
+}
+
+// ── Resource factory ─────────────────────────────────────────────────────────
+
+export function createSuppliersResource(client: AxiosInstance) {
+ return {
+ async list(): Promise {
+ const res = await client.get('/api/suppliers');
+ return res.data.map(mapSupplier);
+ },
+
+ async getById(id: string): Promise {
+ const res = await client.get(`/api/suppliers/${id}`);
+ return mapSupplier(res.data);
+ },
+
+ async create(request: CreateSupplierRequest): Promise {
+ const res = await client.post('/api/suppliers', request);
+ return mapSupplier(res.data);
+ },
+
+ async update(id: string, request: UpdateSupplierRequest): Promise {
+ const res = await client.put(`/api/suppliers/${id}`, request);
+ return mapSupplier(res.data);
+ },
+
+ async activate(id: string): Promise {
+ const res = await client.post(`/api/suppliers/${id}/activate`);
+ return mapSupplier(res.data);
+ },
+
+ async deactivate(id: string): Promise {
+ const res = await client.post(`/api/suppliers/${id}/deactivate`);
+ return mapSupplier(res.data);
+ },
+
+ async rate(id: string, request: RateSupplierRequest): Promise {
+ const res = await client.post(`/api/suppliers/${id}/rating`, request);
+ return mapSupplier(res.data);
+ },
+
+ async addCertificate(id: string, request: AddCertificateRequest): Promise {
+ const res = await client.post(`/api/suppliers/${id}/certificates`, request);
+ return mapSupplier(res.data);
+ },
+
+ // Returns 204 No Content → re-fetch supplier
+ async removeCertificate(id: string, request: RemoveCertificateRequest): Promise {
+ await client.delete(`/api/suppliers/${id}/certificates`, { data: request });
+ const res = await client.get(`/api/suppliers/${id}`);
+ return mapSupplier(res.data);
+ },
+ };
+}
+
+export type SuppliersResource = ReturnType;