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;