mirror of
https://github.com/s-frick/effigenix.git
synced 2026-03-28 08:29:36 +01:00
feat(cli): Stammdaten-TUI mit Master Data API-Anbindung
- Neue Screens: Kategorien, Lieferanten, Artikel, Kunden (jeweils Liste, Detail, Anlegen + Detailaktionen wie Bewertung, Zertifikate, Verkaufseinheiten, Lieferadressen, Präferenzen) - API-Client: Resources für alle 4 Stammdaten-Aggregate implementiert (categories, suppliers, articles, customers) mit Mapping von verschachtelten Domain-VOs auf flache DTOs - Lieferant, Artikel, Kategorie: echte HTTP-Calls gegen Backend (/api/suppliers, /api/articles, /api/categories, /api/customers) - 204-No-Content-Endpoints (removeSalesUnit, removeSupplier, removeCertificate, removeDeliveryAddress, removeFrameContract) lösen Re-Fetch des Aggregats aus - MasterdataMenu, Navigation-Erweiterung, App.tsx-Routing
This commit is contained in:
parent
797f435a49
commit
d27dbaa843
30 changed files with 3882 additions and 1 deletions
|
|
@ -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' && <ChangePasswordScreen />}
|
||||
{current === 'role-list' && <RoleListScreen />}
|
||||
{current === 'role-detail' && <RoleDetailScreen />}
|
||||
{/* Stammdaten */}
|
||||
{current === 'masterdata-menu' && <MasterdataMenu />}
|
||||
{current === 'category-list' && <CategoryListScreen />}
|
||||
{current === 'category-detail' && <CategoryDetailScreen />}
|
||||
{current === 'category-create' && <CategoryCreateScreen />}
|
||||
{current === 'supplier-list' && <SupplierListScreen />}
|
||||
{current === 'supplier-detail' && <SupplierDetailScreen />}
|
||||
{current === 'supplier-create' && <SupplierCreateScreen />}
|
||||
{current === 'supplier-rate' && <RateSupplierScreen />}
|
||||
{current === 'supplier-add-certificate' && <AddCertificateScreen />}
|
||||
{current === 'article-list' && <ArticleListScreen />}
|
||||
{current === 'article-detail' && <ArticleDetailScreen />}
|
||||
{current === 'article-create' && <ArticleCreateScreen />}
|
||||
{current === 'article-add-sales-unit' && <AddSalesUnitScreen />}
|
||||
{current === 'customer-list' && <CustomerListScreen />}
|
||||
{current === 'customer-detail' && <CustomerDetailScreen />}
|
||||
{current === 'customer-create' && <CustomerCreateScreen />}
|
||||
{current === 'customer-add-delivery-address' && <AddDeliveryAddressScreen />}
|
||||
{current === 'customer-set-preferences' && <SetPreferencesScreen />}
|
||||
</MainLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box flexDirection="column" paddingY={1}>
|
||||
<Box marginBottom={1}>
|
||||
<Text color="cyan" bold>Stammdaten</Text>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="gray"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
width={50}
|
||||
>
|
||||
{MENU_ITEMS.map((item, index) => (
|
||||
<Box key={item.screen} flexDirection="column" marginBottom={index < MENU_ITEMS.length - 1 ? 0 : 0}>
|
||||
<Text color={index === selectedIndex ? 'cyan' : 'white'}>
|
||||
{index === selectedIndex ? '▶ ' : ' '}
|
||||
{item.label}
|
||||
</Text>
|
||||
{index === selectedIndex && (
|
||||
<Box paddingLeft={4}>
|
||||
<Text color="gray" dimColor>{item.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>↑↓ navigieren · Enter auswählen · Backspace Zurück</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Unit, PriceModel> = {
|
||||
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<string | null>(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 <Box paddingY={2}><LoadingSpinner label="Verkaufseinheit wird hinzugefügt..." /></Box>;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Verkaufseinheit hinzufügen</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>Einheit *</Text>
|
||||
<Box gap={1}>
|
||||
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>{'< '}</Text>
|
||||
<Text color={activeField === 'unit' ? 'white' : 'gray'}>{UNIT_LABELS[selectedUnit]}</Text>
|
||||
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>{' >'}</Text>
|
||||
<Text color="gray" dimColor>→ {PRICE_MODEL_LABELS[autoModel]}</Text>
|
||||
</Box>
|
||||
{activeField === 'unit' && <Text color="gray" dimColor>←→ Einheit · Tab/Enter weiter</Text>}
|
||||
</Box>
|
||||
|
||||
<FormInput
|
||||
label="Preis (€) *"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
focus={activeField === 'price'}
|
||||
placeholder="z.B. 2.49"
|
||||
{...(priceError ? { error: priceError } : {})}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>Tab/↑↓ Feld · ←→ Einheit · Enter speichern · Escape Abbrechen</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Unit, PriceModel> = {
|
||||
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<Field>('name');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||
|
||||
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<Record<Field, string>> = {};
|
||||
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 <Box paddingY={2}><LoadingSpinner label="Artikel wird angelegt..." /></Box>;
|
||||
|
||||
const selectedCategory = categories[categoryIndex];
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Neuer Artikel</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
<FormInput
|
||||
label="Name *"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onSubmit={() => setActiveField('articleNumber')}
|
||||
focus={activeField === 'name'}
|
||||
{...(fieldErrors.name ? { error: fieldErrors.name } : {})}
|
||||
/>
|
||||
<FormInput
|
||||
label="Artikelnummer *"
|
||||
value={articleNumber}
|
||||
onChange={setArticleNumber}
|
||||
onSubmit={() => setActiveField('categoryId')}
|
||||
focus={activeField === 'articleNumber'}
|
||||
placeholder="z.B. OG-001"
|
||||
{...(fieldErrors.articleNumber ? { error: fieldErrors.articleNumber } : {})}
|
||||
/>
|
||||
|
||||
{/* Category Selector */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'categoryId' ? 'cyan' : 'gray'}>Kategorie *</Text>
|
||||
<Box gap={1}>
|
||||
<Text color={activeField === 'categoryId' ? 'cyan' : 'gray'}>{'< '}</Text>
|
||||
<Text color={activeField === 'categoryId' ? 'white' : 'gray'}>
|
||||
{selectedCategory?.name ?? 'Keine Kategorien'}
|
||||
</Text>
|
||||
<Text color={activeField === 'categoryId' ? 'cyan' : 'gray'}>{' >'}</Text>
|
||||
</Box>
|
||||
{fieldErrors.categoryId && <Text color="red">{fieldErrors.categoryId}</Text>}
|
||||
{activeField === 'categoryId' && <Text color="gray" dimColor>←→ Kategorie wechseln · Tab weiter</Text>}
|
||||
</Box>
|
||||
|
||||
{/* Unit Selector */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>Einheit *</Text>
|
||||
<Box gap={1}>
|
||||
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>{'< '}</Text>
|
||||
<Text color={activeField === 'unit' ? 'white' : 'gray'}>{UNIT_LABELS[selectedUnit]}</Text>
|
||||
<Text color={activeField === 'unit' ? 'cyan' : 'gray'}>{' >'}</Text>
|
||||
<Text color="gray" dimColor>→ {PRICE_MODEL_LABELS[autoModel]}</Text>
|
||||
</Box>
|
||||
{activeField === 'unit' && <Text color="gray" dimColor>←→ Einheit wechseln · Tab weiter</Text>}
|
||||
</Box>
|
||||
|
||||
<FormInput
|
||||
label="Preis (€) *"
|
||||
value={price}
|
||||
onChange={setPrice}
|
||||
onSubmit={() => void handleSubmit()}
|
||||
focus={activeField === 'price'}
|
||||
placeholder="z.B. 2.49"
|
||||
{...(fieldErrors.price ? { error: fieldErrors.price } : {})}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld · ←→ Auswahl · Enter auf Preis speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ArticleDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAction, setSelectedAction] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>('menu');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [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 <LoadingSpinner label="Lade Artikel..." />;
|
||||
if (error && !article) return <ErrorDisplay message={error} onDismiss={back} />;
|
||||
if (!article) return <Text color="red">Artikel nicht gefunden.</Text>;
|
||||
|
||||
const statusColor = article.status === 'ACTIVE' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Artikel: {article.articleNumber} – {article.name}</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
|
||||
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
|
||||
|
||||
{/* Info-Box */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Status:</Text>
|
||||
<Text color={statusColor} bold>{article.status}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Artikelnummer:</Text>
|
||||
<Text>{article.articleNumber}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Kategorie-ID:</Text>
|
||||
<Text>{article.categoryId}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">Verkaufseinheiten ({article.salesUnits.length}):</Text>
|
||||
{article.salesUnits.map((su) => (
|
||||
<Box key={su.id} paddingLeft={2} gap={1}>
|
||||
<Text color="yellow">•</Text>
|
||||
<Text>{UNIT_LABELS[su.unit]}</Text>
|
||||
<Text color="gray">({PRICE_MODEL_LABELS[su.priceModel]})</Text>
|
||||
<Text color="green">{su.price.toFixed(2)} €</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{article.supplierIds.length > 0 && (
|
||||
<Box gap={2} marginTop={1}>
|
||||
<Text color="gray">Lieferanten:</Text>
|
||||
<Text>{article.supplierIds.length} zugewiesen</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Confirm Status */}
|
||||
{mode === 'confirm-status' && (
|
||||
<ConfirmDialog
|
||||
message={article.status === 'ACTIVE' ? 'Artikel deaktivieren?' : 'Artikel aktivieren?'}
|
||||
onConfirm={() => void handleToggleStatus()}
|
||||
onCancel={() => setMode('menu')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Select Sales Unit to Remove */}
|
||||
{mode === 'select-sales-unit' && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="yellow">Verkaufseinheit entfernen:</Text>
|
||||
{article.salesUnits.map((su, i) => (
|
||||
<Box key={su.id}>
|
||||
<Text color={i === selectedSuIndex ? 'cyan' : 'white'}>
|
||||
{i === selectedSuIndex ? '▶ ' : ' '}
|
||||
{UNIT_LABELS[su.unit]} – {su.price.toFixed(2)} €
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text color="gray" dimColor>↑↓ auswählen · Enter Entfernen · Escape Abbrechen</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Action Menu */}
|
||||
{mode === 'menu' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray" bold>Aktionen:</Text>
|
||||
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
|
||||
{!actionLoading && MENU_ITEMS.map((item, index) => (
|
||||
<Box key={item.id}>
|
||||
<Text color={index === selectedAction ? 'cyan' : 'white'}>
|
||||
{index === selectedAction ? '▶ ' : ' '}{item.label(article)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>↑↓ navigieren · Enter ausführen · Backspace Zurück</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Filter>('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<Filter, string> = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="cyan" bold>Artikel</Text>
|
||||
<Text color="gray" dimColor>Filter: <Text color="yellow">{filterLabel[filter]}</Text> ({filtered.length})</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Artikel..." />}
|
||||
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
{!loading && !error && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Box paddingX={1}>
|
||||
<Text color="gray" bold>{' St Nummer Name'.padEnd(38)}</Text>
|
||||
<Text color="gray" bold>Einh.</Text>
|
||||
</Box>
|
||||
{filtered.length === 0 && (
|
||||
<Box paddingX={1} paddingY={1}>
|
||||
<Text color="gray" dimColor>Keine Artikel gefunden.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filtered.map((art, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const statusColor = art.status === 'ACTIVE' ? 'green' : 'red';
|
||||
const textColor = isSelected ? 'cyan' : 'white';
|
||||
return (
|
||||
<Box key={art.id} paddingX={1}>
|
||||
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text color={statusColor}>{art.status === 'ACTIVE' ? '● ' : '○ '}</Text>
|
||||
<Text color={textColor}>{art.articleNumber.padEnd(10)}</Text>
|
||||
<Text color={textColor}>{art.name.substring(0, 22).padEnd(23)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{art.salesUnits.length} VE</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Field>('name');
|
||||
const [nameError, setNameError] = useState<string | null>(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 (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<LoadingSpinner label="Kategorie wird angelegt..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Neue Produktkategorie</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
<FormInput
|
||||
label="Name *"
|
||||
value={name}
|
||||
onChange={setName}
|
||||
onSubmit={handleFieldSubmit('name')}
|
||||
focus={activeField === 'name'}
|
||||
placeholder="z.B. Obst & Gemüse"
|
||||
{...(nameError !== null ? { error: nameError } : {})}
|
||||
/>
|
||||
<FormInput
|
||||
label="Beschreibung"
|
||||
value={description}
|
||||
onChange={setDescription}
|
||||
onSubmit={handleFieldSubmit('description')}
|
||||
focus={activeField === 'description'}
|
||||
placeholder="(optional)"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<ProductCategoryDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<Mode>('view');
|
||||
const [activeField, setActiveField] = useState<EditField>('name');
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editDescription, setEditDescription] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(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 <LoadingSpinner label="Lade Kategorie..." />;
|
||||
if (error && !category) return <ErrorDisplay message={error} onDismiss={back} />;
|
||||
if (!category) return <Text color="red">Kategorie nicht gefunden.</Text>;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Kategorie: {category.name}</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
|
||||
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
|
||||
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">ID:</Text>
|
||||
<Text dimColor>{category.id}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Name:</Text>
|
||||
<Text bold>{category.name}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Beschreibung:</Text>
|
||||
<Text>{category.description ?? '–'}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
[e]/Enter Bearbeiten · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
{saving && <LoadingSpinner label="Speichere..." />}
|
||||
{!saving && (
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
<FormInput
|
||||
label="Name *"
|
||||
value={editName}
|
||||
onChange={setEditName}
|
||||
onSubmit={() => {
|
||||
if (activeField === 'description') void handleSave();
|
||||
else setActiveField('description');
|
||||
}}
|
||||
focus={activeField === 'name'}
|
||||
/>
|
||||
<FormInput
|
||||
label="Beschreibung"
|
||||
value={editDescription}
|
||||
onChange={setEditDescription}
|
||||
onSubmit={() => void handleSave()}
|
||||
focus={activeField === 'description'}
|
||||
placeholder="(optional)"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box>
|
||||
<Text color="cyan" bold>Produktkategorien</Text>
|
||||
<Text color="gray" dimColor> – {categories.length} Einträge</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Kategorien..." />}
|
||||
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
|
||||
|
||||
{confirmDeleteId && (
|
||||
<ConfirmDialog
|
||||
message={`Kategorie "${categories.find((c) => c.id === confirmDeleteId)?.name}" löschen?`}
|
||||
onConfirm={() => void handleDelete()}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1} paddingY={0}>
|
||||
<Box borderStyle="single" borderBottom borderTop={false} borderLeft={false} borderRight={false} borderColor="gray" paddingX={1}>
|
||||
<Text color="gray" bold>{' Name'.padEnd(30)}</Text>
|
||||
<Text color="gray" bold>Beschreibung</Text>
|
||||
</Box>
|
||||
{categories.length === 0 && (
|
||||
<Box paddingX={1} paddingY={1}>
|
||||
<Text color="gray" dimColor>Keine Kategorien vorhanden.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{categories.map((cat, index) => (
|
||||
<Box key={cat.id} paddingX={1}>
|
||||
<Text color={index === selectedIndex ? 'cyan' : 'white'}>
|
||||
{index === selectedIndex ? '▶ ' : ' '}
|
||||
{cat.name.padEnd(28)}
|
||||
</Text>
|
||||
<Text color={index === selectedIndex ? 'cyan' : 'gray'}>
|
||||
{cat.description ?? '–'}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Enter Details · [n] Neu · [d] Löschen · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Field, string> = {
|
||||
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<Record<Field, string>>({
|
||||
label: '', street: '', houseNumber: '', postalCode: '',
|
||||
city: '', country: 'Deutschland', contactPerson: '', deliveryNotes: '',
|
||||
});
|
||||
const [activeField, setActiveField] = useState<Field>('label');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||
|
||||
const setField = (field: Field) => (value: string) => {
|
||||
setValues((v) => ({ ...v, [field]: value }));
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) back();
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors: Partial<Record<Field, string>> = {};
|
||||
if (!values.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 <Box paddingY={2}><LoadingSpinner label="Adresse wird hinzugefügt..." /></Box>;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Lieferadresse hinzufügen</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
{FIELDS.map((field) => (
|
||||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,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<Field, string> = {
|
||||
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<Record<Field, string>>({
|
||||
name: '', phone: '', email: '', street: '', houseNumber: '',
|
||||
postalCode: '', city: '', country: 'Deutschland', paymentDueDays: '',
|
||||
});
|
||||
const [typeIndex, setTypeIndex] = useState(0);
|
||||
const [activeField, setActiveField] = useState<Field | 'type'>('name');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field | 'type', string>>>({});
|
||||
|
||||
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<Record<Field | 'type', string>> = {};
|
||||
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 <Box paddingY={2}><LoadingSpinner label="Kunde wird angelegt..." /></Box>;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Neuer Kunde</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
{/* Type Selector */}
|
||||
<Box flexDirection="column">
|
||||
<Text color={activeField === 'type' ? 'cyan' : 'gray'}>Typ *</Text>
|
||||
<Box gap={1}>
|
||||
<Text color={activeField === 'type' ? 'cyan' : 'gray'}>{'< '}</Text>
|
||||
<Text color={activeField === 'type' ? 'white' : 'gray'}>{TYPES[typeIndex]}</Text>
|
||||
<Text color={activeField === 'type' ? 'cyan' : 'gray'}>{' >'}</Text>
|
||||
</Box>
|
||||
{activeField === 'type' && <Text color="gray" dimColor>←→ Typ · Tab/Enter weiter</Text>}
|
||||
</Box>
|
||||
|
||||
{FIELDS.map((field) => (
|
||||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld · ←→ Typ · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<CustomerDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAction, setSelectedAction] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>('menu');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [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 <LoadingSpinner label="Lade Kunde..." />;
|
||||
if (error && !customer) return <ErrorDisplay message={error} onDismiss={back} />;
|
||||
if (!customer) return <Text color="red">Kunde nicht gefunden.</Text>;
|
||||
|
||||
const statusColor = customer.status === 'ACTIVE' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Kunde: {customer.name}</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
|
||||
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
|
||||
|
||||
{/* Info-Box */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Status:</Text>
|
||||
<Text color={statusColor} bold>{customer.status}</Text>
|
||||
<Text color="yellow">{customer.type}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Telefon:</Text>
|
||||
<Text>{customer.contactInfo.phone}</Text>
|
||||
</Box>
|
||||
{customer.contactInfo.email && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">E-Mail:</Text>
|
||||
<Text>{customer.contactInfo.email}</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Rechnungsadresse:</Text>
|
||||
<Text>{`${customer.billingAddress.street} ${customer.billingAddress.houseNumber}, ${customer.billingAddress.postalCode} ${customer.billingAddress.city}`}</Text>
|
||||
</Box>
|
||||
{customer.paymentTerms && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Zahlungsziel:</Text>
|
||||
<Text>{customer.paymentTerms.paymentDueDays} Tage</Text>
|
||||
</Box>
|
||||
)}
|
||||
{customer.preferences.length > 0 && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Präferenzen:</Text>
|
||||
<Text>{customer.preferences.map((p) => CUSTOMER_PREFERENCE_LABELS[p]).join(', ')}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{customer.deliveryAddresses.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">Lieferadressen ({customer.deliveryAddresses.length}):</Text>
|
||||
{customer.deliveryAddresses.map((addr) => (
|
||||
<Box key={addr.label} paddingLeft={2} gap={1}>
|
||||
<Text color="yellow">•</Text>
|
||||
<Text bold>{addr.label}:</Text>
|
||||
<Text>{`${addr.address.street} ${addr.address.houseNumber}, ${addr.address.city}`}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{customer.frameContract && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">Rahmenvertrag:</Text>
|
||||
<Box paddingLeft={2} gap={1}>
|
||||
<Text>{customer.frameContract.validFrom ? formatDate(customer.frameContract.validFrom) : '–'} – {customer.frameContract.validUntil ? formatDate(customer.frameContract.validUntil) : '–'}</Text>
|
||||
<Text color="gray">({customer.frameContract.lineItems.length} Positionen)</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Confirm Status */}
|
||||
{mode === 'confirm-status' && (
|
||||
<ConfirmDialog
|
||||
message={customer.status === 'ACTIVE' ? 'Kunde deaktivieren?' : 'Kunde aktivieren?'}
|
||||
onConfirm={() => void handleToggleStatus()}
|
||||
onCancel={() => setMode('menu')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Select Address to Remove */}
|
||||
{mode === 'select-remove-address' && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="yellow">Lieferadresse entfernen:</Text>
|
||||
{customer.deliveryAddresses.map((addr, i) => (
|
||||
<Box key={addr.label}>
|
||||
<Text color={i === selectedAddrIndex ? 'cyan' : 'white'}>
|
||||
{i === selectedAddrIndex ? '▶ ' : ' '}{addr.label}: {addr.address.city}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text color="gray" dimColor>↑↓ auswählen · Enter Entfernen · Escape Abbrechen</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Action Menu */}
|
||||
{mode === 'menu' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray" bold>Aktionen:</Text>
|
||||
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
|
||||
{!actionLoading && MENU_ITEMS.map((item, index) => (
|
||||
<Box key={item.id}>
|
||||
<Text color={index === selectedAction ? 'cyan' : 'white'}>
|
||||
{index === selectedAction ? '▶ ' : ' '}{item.label(customer)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>↑↓ navigieren · Enter ausführen · Backspace Zurück</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<StatusFilter>('ALL');
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>('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 (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="cyan" bold>Kunden</Text>
|
||||
<Text color="gray" dimColor>
|
||||
Status: <Text color="yellow">{statusFilter}</Text>
|
||||
{' '}Typ: <Text color="yellow">{typeFilter}</Text>
|
||||
{' '}({filtered.length})
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Kunden..." />}
|
||||
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
{!loading && !error && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Box paddingX={1}>
|
||||
<Text color="gray" bold>{' St Typ Name'.padEnd(34)}</Text>
|
||||
<Text color="gray" bold>Adr.</Text>
|
||||
</Box>
|
||||
{filtered.length === 0 && (
|
||||
<Box paddingX={1} paddingY={1}>
|
||||
<Text color="gray" dimColor>Keine Kunden gefunden.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filtered.map((cust, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const statusColor = cust.status === 'ACTIVE' ? 'green' : 'red';
|
||||
const textColor = isSelected ? 'cyan' : 'white';
|
||||
return (
|
||||
<Box key={cust.id} paddingX={1}>
|
||||
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text color={statusColor}>{cust.status === 'ACTIVE' ? '● ' : '○ '}</Text>
|
||||
<Text color="yellow">{cust.type.padEnd(4)} </Text>
|
||||
<Text color={textColor}>{cust.name.substring(0, 26).padEnd(27)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{cust.deliveryAddresses.length} Adr.</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ nav · Enter Details · [n] Neu · [a/A/I] Status · [b/B/C] Typ · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<Set<CustomerPreference>>(new Set());
|
||||
const [initLoading, setInitLoading] = useState(true);
|
||||
const [initError, setInitError] = useState<string | null>(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 <LoadingSpinner label="Lade Präferenzen..." />;
|
||||
if (initError) return <ErrorDisplay message={initError} onDismiss={back} />;
|
||||
if (loading) return <LoadingSpinner label="Speichere Präferenzen..." />;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Präferenzen setzen</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} gap={0}>
|
||||
{ALL_PREFERENCES.map((pref, i) => {
|
||||
const isSelected = i === selectedIndex;
|
||||
const isChecked = checked.has(pref);
|
||||
return (
|
||||
<Box key={pref} gap={1}>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{isSelected ? '▶' : ' '}</Text>
|
||||
<Text color={isChecked ? 'green' : 'gray'}>{isChecked ? '[✓]' : '[ ]'}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'white'}>{CUSTOMER_PREFERENCE_LABELS[pref]}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ navigieren · Leertaste togglen · Enter speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../../state/navigation-context.js';
|
||||
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
||||
import { FormInput } from '../../shared/FormInput.js';
|
||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||
|
||||
type Field = 'certificateType' | 'issuer' | 'validFrom' | 'validUntil';
|
||||
const FIELDS: Field[] = ['certificateType', 'issuer', 'validFrom', 'validUntil'];
|
||||
|
||||
const FIELD_LABELS: Record<Field, string> = {
|
||||
certificateType: 'Zertifikat-Typ *',
|
||||
issuer: 'Aussteller *',
|
||||
validFrom: 'Gültig ab * (YYYY-MM-DD)',
|
||||
validUntil: 'Gültig bis * (YYYY-MM-DD)',
|
||||
};
|
||||
|
||||
function isValidDate(s: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(s) && !isNaN(Date.parse(s));
|
||||
}
|
||||
|
||||
export function AddCertificateScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const supplierId = params['supplierId'] ?? '';
|
||||
const { addCertificate, loading, error, clearError } = useSuppliers();
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
certificateType: '',
|
||||
issuer: '',
|
||||
validFrom: '',
|
||||
validUntil: '',
|
||||
});
|
||||
const [activeField, setActiveField] = useState<Field>('certificateType');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||
|
||||
const setField = (field: Field) => (value: string) => {
|
||||
setValues((v) => ({ ...v, [field]: value }));
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) back();
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors: Partial<Record<Field, string>> = {};
|
||||
if (!values.certificateType.trim()) errors.certificateType = 'Typ ist erforderlich.';
|
||||
if (!values.issuer.trim()) errors.issuer = 'Aussteller ist erforderlich.';
|
||||
if (!isValidDate(values.validFrom)) errors.validFrom = 'Ungültiges Datum (YYYY-MM-DD).';
|
||||
if (!isValidDate(values.validUntil)) errors.validUntil = 'Ungültiges Datum (YYYY-MM-DD).';
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
const updated = await addCertificate(supplierId, {
|
||||
certificateType: values.certificateType.trim().toUpperCase(),
|
||||
issuer: values.issuer.trim(),
|
||||
validFrom: values.validFrom,
|
||||
validUntil: values.validUntil,
|
||||
});
|
||||
if (updated) navigate('supplier-detail', { supplierId });
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (_value: string) => {
|
||||
const idx = FIELDS.indexOf(field);
|
||||
if (idx < FIELDS.length - 1) {
|
||||
setActiveField(FIELDS[idx + 1] ?? field);
|
||||
} else {
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner label="Zertifikat wird hinzugefügt..." />;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Zertifikat hinzufügen</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
{FIELDS.map((field) => (
|
||||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
placeholder={field.includes('valid') ? '2025-01-01' : ''}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../../state/navigation-context.js';
|
||||
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||
import { SuccessDisplay } from '../../shared/SuccessDisplay.js';
|
||||
|
||||
type ScoreField = 'quality' | 'delivery' | 'price';
|
||||
const FIELDS: ScoreField[] = ['quality', 'delivery', 'price'];
|
||||
|
||||
const FIELD_LABELS: Record<ScoreField, string> = {
|
||||
quality: 'Qualität',
|
||||
delivery: 'Lieferung',
|
||||
price: 'Preis',
|
||||
};
|
||||
|
||||
function ScoreSelector({ label, value, active }: { label: string; value: number; active: boolean }) {
|
||||
return (
|
||||
<Box gap={2}>
|
||||
<Text color={active ? 'cyan' : 'gray'} bold={active}>{label.padEnd(12)}</Text>
|
||||
<Box gap={1}>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<Text key={n} color={n <= value ? 'yellow' : 'gray'}>
|
||||
{n <= value ? '★' : '☆'}
|
||||
</Text>
|
||||
))}
|
||||
<Text color={active ? 'cyan' : 'gray'}> {value}/5</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function RateSupplierScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const supplierId = params['supplierId'] ?? '';
|
||||
const { rateSupplier, loading, error, clearError } = useSuppliers();
|
||||
|
||||
const [scores, setScores] = useState<Record<ScoreField, number>>({ quality: 3, delivery: 3, price: 3 });
|
||||
const [activeField, setActiveField] = useState<ScoreField>('quality');
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[Math.max(0, idx - 1)] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[Math.min(FIELDS.length - 1, idx + 1)] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
setScores((s) => ({ ...s, [activeField]: Math.max(1, s[activeField] - 1) }));
|
||||
}
|
||||
if (key.rightArrow) {
|
||||
setScores((s) => ({ ...s, [activeField]: Math.min(5, s[activeField] + 1) }));
|
||||
}
|
||||
if (key.return && activeField === 'price') {
|
||||
void handleSubmit();
|
||||
}
|
||||
if (key.return && activeField !== 'price') {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[idx + 1] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) back();
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const updated = await rateSupplier(supplierId, {
|
||||
qualityScore: scores.quality,
|
||||
deliveryScore: scores.delivery,
|
||||
priceScore: scores.price,
|
||||
});
|
||||
if (updated) {
|
||||
setSuccessMessage('Bewertung gespeichert.');
|
||||
setTimeout(() => navigate('supplier-detail', { supplierId }), 1000);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingSpinner label="Bewertung wird gespeichert..." />;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Lieferant bewerten</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
|
||||
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1} gap={1}>
|
||||
{FIELDS.map((field) => (
|
||||
<ScoreSelector
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={scores[field]}
|
||||
active={activeField === field}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ Kriterium · ←→ Bewertung ändern · Enter nächstes/Speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../../state/navigation-context.js';
|
||||
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
||||
import { FormInput } from '../../shared/FormInput.js';
|
||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||
|
||||
type Field = 'name' | 'phone' | 'email' | 'contactPerson' | 'street' | 'houseNumber' | 'postalCode' | 'city' | 'country' | 'paymentDueDays';
|
||||
const FIELDS: Field[] = ['name', 'phone', 'email', 'contactPerson', 'street', 'houseNumber', 'postalCode', 'city', 'country', 'paymentDueDays'];
|
||||
|
||||
const FIELD_LABELS: Record<Field, string> = {
|
||||
name: 'Name *',
|
||||
phone: 'Telefon *',
|
||||
email: 'E-Mail',
|
||||
contactPerson: 'Ansprechpartner',
|
||||
street: 'Straße',
|
||||
houseNumber: 'Hausnummer',
|
||||
postalCode: 'PLZ',
|
||||
city: 'Stadt',
|
||||
country: 'Land',
|
||||
paymentDueDays: 'Zahlungsziel (Tage)',
|
||||
};
|
||||
|
||||
export function SupplierCreateScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { createSupplier, loading, error, clearError } = useSuppliers();
|
||||
|
||||
const [values, setValues] = useState<Record<Field, string>>({
|
||||
name: '', phone: '', email: '', contactPerson: '',
|
||||
street: '', houseNumber: '', postalCode: '', city: '', country: 'Deutschland',
|
||||
paymentDueDays: '',
|
||||
});
|
||||
const [activeField, setActiveField] = useState<Field>('name');
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<Field, string>>>({});
|
||||
|
||||
const setField = (field: Field) => (value: string) => {
|
||||
setValues((v) => ({ ...v, [field]: value }));
|
||||
};
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading) return;
|
||||
if (key.tab || key.downArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx + 1) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.upArrow) {
|
||||
setActiveField((f) => {
|
||||
const idx = FIELDS.indexOf(f);
|
||||
return FIELDS[(idx - 1 + FIELDS.length) % FIELDS.length] ?? f;
|
||||
});
|
||||
}
|
||||
if (key.escape) back();
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors: Partial<Record<Field, string>> = {};
|
||||
if (!values.name.trim()) errors.name = 'Name ist erforderlich.';
|
||||
if (!values.phone.trim()) errors.phone = 'Telefon ist erforderlich.';
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length > 0) return;
|
||||
|
||||
const result = await createSupplier({
|
||||
name: values.name.trim(),
|
||||
phone: values.phone.trim(),
|
||||
...(values.email.trim() ? { email: values.email.trim() } : {}),
|
||||
...(values.contactPerson.trim() ? { contactPerson: values.contactPerson.trim() } : {}),
|
||||
...(values.street.trim() ? { street: values.street.trim() } : {}),
|
||||
...(values.houseNumber.trim() ? { houseNumber: values.houseNumber.trim() } : {}),
|
||||
...(values.postalCode.trim() ? { postalCode: values.postalCode.trim() } : {}),
|
||||
...(values.city.trim() ? { city: values.city.trim() } : {}),
|
||||
...(values.country.trim() ? { country: values.country.trim() } : {}),
|
||||
...(values.paymentDueDays.trim() ? { paymentDueDays: parseInt(values.paymentDueDays, 10) } : {}),
|
||||
});
|
||||
if (result) navigate('supplier-list');
|
||||
};
|
||||
|
||||
const handleFieldSubmit = (field: Field) => (_value: string) => {
|
||||
const idx = FIELDS.indexOf(field);
|
||||
if (idx < FIELDS.length - 1) {
|
||||
setActiveField(FIELDS[idx + 1] ?? field);
|
||||
} else {
|
||||
void handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box flexDirection="column" alignItems="center" paddingY={2}>
|
||||
<LoadingSpinner label="Lieferant wird angelegt..." />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Neuer Lieferant</Text>
|
||||
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
<Box flexDirection="column" gap={1} width={60}>
|
||||
{FIELDS.map((field) => (
|
||||
<FormInput
|
||||
key={field}
|
||||
label={FIELD_LABELS[field]}
|
||||
value={values[field]}
|
||||
onChange={setField(field)}
|
||||
onSubmit={handleFieldSubmit(field)}
|
||||
focus={activeField === field}
|
||||
{...(fieldErrors[field] ? { error: fieldErrors[field] } : {})}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
Tab/↑↓ Feld wechseln · Enter auf letztem Feld speichern · Escape Abbrechen
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import type { SupplierDTO, QualityCertificateDTO } from '@effigenix/api-client';
|
||||
import { useNavigation } from '../../../state/navigation-context.js';
|
||||
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||
import { SuccessDisplay } from '../../shared/SuccessDisplay.js';
|
||||
import { ConfirmDialog } from '../../shared/ConfirmDialog.js';
|
||||
import { client } from '../../../utils/api-client.js';
|
||||
|
||||
type MenuAction =
|
||||
| 'toggle-status'
|
||||
| 'rate'
|
||||
| 'add-certificate'
|
||||
| 'remove-certificate'
|
||||
| 'back';
|
||||
|
||||
type Mode = 'menu' | 'confirm-status' | 'confirm-remove-cert';
|
||||
|
||||
const MENU_ITEMS: { id: MenuAction; label: (s: SupplierDTO) => string }[] = [
|
||||
{ id: 'toggle-status', label: (s) => s.status === 'ACTIVE' ? '[Deaktivieren]' : '[Aktivieren]' },
|
||||
{ id: 'rate', label: () => '[Bewerten]' },
|
||||
{ id: 'add-certificate', label: () => '[Zertifikat hinzufügen]' },
|
||||
{ id: 'remove-certificate', label: (s) => s.certificates.length > 0 ? '[Zertifikat entfernen]' : '[Zertifikat entfernen (keine vorhanden)]' },
|
||||
{ id: 'back', label: () => '[Zurück]' },
|
||||
];
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : 'Unbekannter Fehler';
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('de-DE');
|
||||
}
|
||||
|
||||
function avgRating(r: SupplierDTO['rating']): string {
|
||||
if (!r) return '–';
|
||||
return ((r.qualityScore + r.deliveryScore + r.priceScore) / 3).toFixed(1);
|
||||
}
|
||||
|
||||
export function SupplierDetailScreen() {
|
||||
const { params, navigate, back } = useNavigation();
|
||||
const supplierId = params['supplierId'] ?? '';
|
||||
const { activateSupplier, deactivateSupplier, removeCertificate } = useSuppliers();
|
||||
|
||||
const [supplier, setSupplier] = useState<SupplierDTO | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedAction, setSelectedAction] = useState(0);
|
||||
const [mode, setMode] = useState<Mode>('menu');
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [selectedCertIndex, setSelectedCertIndex] = useState(0);
|
||||
|
||||
const loadSupplier = useCallback(() => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
client.suppliers.getById(supplierId)
|
||||
.then((s) => { setSupplier(s); setLoading(false); })
|
||||
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
|
||||
}, [supplierId]);
|
||||
|
||||
useEffect(() => { if (supplierId) loadSupplier(); }, [loadSupplier, supplierId]);
|
||||
|
||||
useInput((_input, key) => {
|
||||
if (loading || actionLoading) return;
|
||||
|
||||
if (mode === 'confirm-remove-cert' && supplier) {
|
||||
if (key.upArrow) setSelectedCertIndex((i) => Math.max(0, i - 1));
|
||||
if (key.downArrow) setSelectedCertIndex((i) => Math.min(supplier.certificates.length - 1, i + 1));
|
||||
if (key.return) {
|
||||
const c = supplier.certificates[selectedCertIndex];
|
||||
if (c) void handleRemoveCert(c);
|
||||
}
|
||||
if (key.escape) setMode('menu');
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'menu') return;
|
||||
if (key.upArrow) setSelectedAction((i) => Math.max(0, i - 1));
|
||||
if (key.downArrow) setSelectedAction((i) => Math.min(MENU_ITEMS.length - 1, i + 1));
|
||||
if (key.return) void handleAction();
|
||||
if (key.backspace || key.escape) back();
|
||||
});
|
||||
|
||||
const handleAction = async () => {
|
||||
if (!supplier) return;
|
||||
const item = MENU_ITEMS[selectedAction];
|
||||
if (!item) return;
|
||||
|
||||
switch (item.id) {
|
||||
case 'toggle-status':
|
||||
setMode('confirm-status');
|
||||
break;
|
||||
case 'rate':
|
||||
navigate('supplier-rate', { supplierId: supplier.id });
|
||||
break;
|
||||
case 'add-certificate':
|
||||
navigate('supplier-add-certificate', { supplierId: supplier.id });
|
||||
break;
|
||||
case 'remove-certificate':
|
||||
if (supplier.certificates.length > 0) {
|
||||
setSelectedCertIndex(0);
|
||||
setMode('confirm-remove-cert');
|
||||
}
|
||||
break;
|
||||
case 'back':
|
||||
back();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = useCallback(async () => {
|
||||
if (!supplier) return;
|
||||
setMode('menu');
|
||||
setActionLoading(true);
|
||||
const fn = supplier.status === 'ACTIVE' ? deactivateSupplier : activateSupplier;
|
||||
const updated = await fn(supplier.id);
|
||||
setActionLoading(false);
|
||||
if (updated) {
|
||||
setSupplier(updated);
|
||||
setSuccessMessage(supplier.status === 'ACTIVE' ? 'Lieferant deaktiviert.' : 'Lieferant aktiviert.');
|
||||
}
|
||||
}, [supplier, activateSupplier, deactivateSupplier]);
|
||||
|
||||
const handleRemoveCert = useCallback(async (cert: QualityCertificateDTO) => {
|
||||
if (!supplier) return;
|
||||
setMode('menu');
|
||||
setActionLoading(true);
|
||||
const updated = await removeCertificate(supplier.id, {
|
||||
certificateType: cert.certificateType,
|
||||
issuer: cert.issuer,
|
||||
validFrom: cert.validFrom,
|
||||
});
|
||||
setActionLoading(false);
|
||||
if (updated) {
|
||||
setSupplier(updated);
|
||||
setSuccessMessage(`Zertifikat "${cert.certificateType}" entfernt.`);
|
||||
}
|
||||
}, [supplier, removeCertificate]);
|
||||
|
||||
if (loading) return <LoadingSpinner label="Lade Lieferant..." />;
|
||||
if (error && !supplier) return <ErrorDisplay message={error} onDismiss={back} />;
|
||||
if (!supplier) return <Text color="red">Lieferant nicht gefunden.</Text>;
|
||||
|
||||
const statusColor = supplier.status === 'ACTIVE' ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="cyan" bold>Lieferant: {supplier.name}</Text>
|
||||
|
||||
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
|
||||
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
|
||||
|
||||
{/* Info-Box */}
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Status:</Text>
|
||||
<Text color={statusColor} bold>{supplier.status}</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Telefon:</Text>
|
||||
<Text>{supplier.contactInfo.phone}</Text>
|
||||
</Box>
|
||||
{supplier.contactInfo.email && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">E-Mail:</Text>
|
||||
<Text>{supplier.contactInfo.email}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{supplier.contactInfo.contactPerson && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Ansprechpartner:</Text>
|
||||
<Text>{supplier.contactInfo.contactPerson}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{supplier.address && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Adresse:</Text>
|
||||
<Text>{`${supplier.address.street} ${supplier.address.houseNumber}, ${supplier.address.postalCode} ${supplier.address.city}`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{supplier.paymentTerms && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Zahlungsziel:</Text>
|
||||
<Text>{supplier.paymentTerms.paymentDueDays} Tage</Text>
|
||||
</Box>
|
||||
)}
|
||||
{supplier.rating && (
|
||||
<Box gap={2}>
|
||||
<Text color="gray">Bewertung:</Text>
|
||||
<Text>
|
||||
{'★ ' + avgRating(supplier.rating)}
|
||||
{` (Q:${supplier.rating.qualityScore} L:${supplier.rating.deliveryScore} P:${supplier.rating.priceScore})`}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{supplier.certificates.length > 0 && (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color="gray">Zertifikate:</Text>
|
||||
{supplier.certificates.map((c) => (
|
||||
<Box key={`${c.certificateType}-${c.validFrom}`} paddingLeft={2} gap={1}>
|
||||
<Text color="yellow">•</Text>
|
||||
<Text>{c.certificateType}</Text>
|
||||
<Text color="gray">({c.issuer}, bis {formatDate(c.validUntil)})</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Confirm Status Dialog */}
|
||||
{mode === 'confirm-status' && (
|
||||
<ConfirmDialog
|
||||
message={supplier.status === 'ACTIVE' ? 'Lieferant deaktivieren?' : 'Lieferant aktivieren?'}
|
||||
onConfirm={() => void handleToggleStatus()}
|
||||
onCancel={() => setMode('menu')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Confirm Remove Certificate */}
|
||||
{mode === 'confirm-remove-cert' && supplier.certificates.length > 0 && (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Text color="yellow">Zertifikat auswählen:</Text>
|
||||
{supplier.certificates.map((c, i) => (
|
||||
<Box key={`${c.certificateType}-${c.validFrom}`}>
|
||||
<Text color={i === selectedCertIndex ? 'cyan' : 'white'}>
|
||||
{i === selectedCertIndex ? '▶ ' : ' '}{c.certificateType} – {c.issuer}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Text color="gray" dimColor>↑↓ auswählen · Enter Entfernen · Escape Abbrechen</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Action Menu */}
|
||||
{mode === 'menu' && (
|
||||
<Box flexDirection="column">
|
||||
<Text color="gray" bold>Aktionen:</Text>
|
||||
{actionLoading && <LoadingSpinner label="Aktion wird ausgeführt..." />}
|
||||
{!actionLoading && MENU_ITEMS.map((item, index) => (
|
||||
<Box key={item.id}>
|
||||
<Text color={index === selectedAction ? 'cyan' : 'white'}>
|
||||
{index === selectedAction ? '▶ ' : ' '}{item.label(supplier)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>↑↓ navigieren · Enter ausführen · Backspace Zurück</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import { useNavigation } from '../../../state/navigation-context.js';
|
||||
import { useSuppliers } from '../../../hooks/useSuppliers.js';
|
||||
import { LoadingSpinner } from '../../shared/LoadingSpinner.js';
|
||||
import { ErrorDisplay } from '../../shared/ErrorDisplay.js';
|
||||
import type { SupplierStatus } from '@effigenix/api-client';
|
||||
|
||||
type Filter = 'ALL' | SupplierStatus;
|
||||
|
||||
function avgRating(rating: { qualityScore: number; deliveryScore: number; priceScore: number } | null): string {
|
||||
if (!rating) return '–';
|
||||
const avg = (rating.qualityScore + rating.deliveryScore + rating.priceScore) / 3;
|
||||
return avg.toFixed(1);
|
||||
}
|
||||
|
||||
export function SupplierListScreen() {
|
||||
const { navigate, back } = useNavigation();
|
||||
const { suppliers, loading, error, fetchSuppliers, clearError } = useSuppliers();
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [filter, setFilter] = useState<Filter>('ALL');
|
||||
|
||||
useEffect(() => {
|
||||
void fetchSuppliers();
|
||||
}, [fetchSuppliers]);
|
||||
|
||||
const filtered = filter === 'ALL' ? suppliers : suppliers.filter((s) => s.status === filter);
|
||||
|
||||
useInput((input, key) => {
|
||||
if (loading) return;
|
||||
|
||||
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
|
||||
if (key.downArrow) setSelectedIndex((i) => Math.min(filtered.length - 1, i + 1));
|
||||
|
||||
if (key.return && filtered.length > 0) {
|
||||
const sup = filtered[selectedIndex];
|
||||
if (sup) navigate('supplier-detail', { supplierId: sup.id });
|
||||
}
|
||||
if (input === 'n') navigate('supplier-create');
|
||||
if (input === 'a') { setFilter('ALL'); setSelectedIndex(0); }
|
||||
if (input === 'A') { setFilter('ACTIVE'); setSelectedIndex(0); }
|
||||
if (input === 'I') { setFilter('INACTIVE'); setSelectedIndex(0); }
|
||||
if (key.backspace || key.escape) back();
|
||||
});
|
||||
|
||||
const filterLabel: Record<Filter, string> = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Text color="cyan" bold>Lieferanten</Text>
|
||||
<Text color="gray" dimColor>Filter: <Text color="yellow">{filterLabel[filter]}</Text> ({filtered.length})</Text>
|
||||
</Box>
|
||||
|
||||
{loading && <LoadingSpinner label="Lade Lieferanten..." />}
|
||||
{error && !loading && <ErrorDisplay message={error} onDismiss={clearError} />}
|
||||
|
||||
{!loading && !error && (
|
||||
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={1}>
|
||||
<Box paddingX={1}>
|
||||
<Text color="gray" bold>{' Status Name'.padEnd(32)}</Text>
|
||||
<Text color="gray" bold>{'Bewertung Zertifikate'}</Text>
|
||||
</Box>
|
||||
{filtered.length === 0 && (
|
||||
<Box paddingX={1} paddingY={1}>
|
||||
<Text color="gray" dimColor>Keine Lieferanten gefunden.</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filtered.map((sup, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const statusColor = sup.status === 'ACTIVE' ? 'green' : 'red';
|
||||
const textColor = isSelected ? 'cyan' : 'white';
|
||||
return (
|
||||
<Box key={sup.id} paddingX={1}>
|
||||
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
|
||||
<Text color={statusColor}>{(sup.status === 'ACTIVE' ? '● ' : '○ ')}</Text>
|
||||
<Text color={textColor}>{sup.name.substring(0, 26).padEnd(27)}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{`★ ${avgRating(sup.rating)} `}</Text>
|
||||
<Text color={isSelected ? 'cyan' : 'gray'}>{`${sup.certificates.length} Zert.`}</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray" dimColor>
|
||||
↑↓ nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · Backspace Zurück
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
183
frontend/apps/cli/src/hooks/useArticles.ts
Normal file
183
frontend/apps/cli/src/hooks/useArticles.ts
Normal file
|
|
@ -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<ArticlesState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
88
frontend/apps/cli/src/hooks/useCategories.ts
Normal file
88
frontend/apps/cli/src/hooks/useCategories.ts
Normal file
|
|
@ -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<CategoriesState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
150
frontend/apps/cli/src/hooks/useCustomers.ts
Normal file
150
frontend/apps/cli/src/hooks/useCustomers.ts
Normal file
|
|
@ -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<CustomersState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
151
frontend/apps/cli/src/hooks/useSuppliers.ts
Normal file
151
frontend/apps/cli/src/hooks/useSuppliers.ts
Normal file
|
|
@ -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<SuppliersState>({
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
196
frontend/packages/api-client/src/resources/articles.ts
Normal file
196
frontend/packages/api-client/src/resources/articles.ts
Normal file
|
|
@ -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<Unit, string> = {
|
||||
PIECE_FIXED: 'Stück (fix)',
|
||||
KG: 'Kilogramm',
|
||||
HUNDRED_GRAM: '100g',
|
||||
PIECE_VARIABLE: 'Stück (variabel)',
|
||||
};
|
||||
|
||||
export const PRICE_MODEL_LABELS: Record<PriceModel, string> = {
|
||||
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<ArticleDTO[]> {
|
||||
const res = await client.get<BackendArticle[]>('/api/articles');
|
||||
return res.data.map(mapArticle);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<ArticleDTO> {
|
||||
const res = await client.get<BackendArticle>(`/api/articles/${id}`);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async create(request: CreateArticleRequest): Promise<ArticleDTO> {
|
||||
const res = await client.post<BackendArticle>('/api/articles', request);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async update(id: string, request: UpdateArticleRequest): Promise<ArticleDTO> {
|
||||
const res = await client.put<BackendArticle>(`/api/articles/${id}`, request);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async activate(id: string): Promise<ArticleDTO> {
|
||||
const res = await client.post<BackendArticle>(`/api/articles/${id}/activate`);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async deactivate(id: string): Promise<ArticleDTO> {
|
||||
const res = await client.post<BackendArticle>(`/api/articles/${id}/deactivate`);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async addSalesUnit(id: string, request: AddSalesUnitRequest): Promise<ArticleDTO> {
|
||||
const res = await client.post<BackendArticle>(`/api/articles/${id}/sales-units`, request);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
// Returns 204 No Content → re-fetch article
|
||||
async removeSalesUnit(articleId: string, salesUnitId: string): Promise<ArticleDTO> {
|
||||
await client.delete(`/api/articles/${articleId}/sales-units/${salesUnitId}`);
|
||||
const res = await client.get<BackendArticle>(`/api/articles/${articleId}`);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async updateSalesUnitPrice(
|
||||
articleId: string,
|
||||
salesUnitId: string,
|
||||
request: UpdateSalesUnitPriceRequest,
|
||||
): Promise<ArticleDTO> {
|
||||
const res = await client.put<BackendArticle>(
|
||||
`/api/articles/${articleId}/sales-units/${salesUnitId}/price`,
|
||||
request,
|
||||
);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
async assignSupplier(articleId: string, supplierId: string): Promise<ArticleDTO> {
|
||||
const res = await client.post<BackendArticle>(`/api/articles/${articleId}/suppliers`, {
|
||||
supplierId,
|
||||
});
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
|
||||
// Returns 204 No Content → re-fetch article
|
||||
async removeSupplier(articleId: string, supplierId: string): Promise<ArticleDTO> {
|
||||
await client.delete(`/api/articles/${articleId}/suppliers/${supplierId}`);
|
||||
const res = await client.get<BackendArticle>(`/api/articles/${articleId}`);
|
||||
return mapArticle(res.data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ArticlesResource = ReturnType<typeof createArticlesResource>;
|
||||
77
frontend/packages/api-client/src/resources/categories.ts
Normal file
77
frontend/packages/api-client/src/resources/categories.ts
Normal file
|
|
@ -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<ProductCategoryDTO[]> {
|
||||
const res = await client.get<BackendProductCategory[]>('/api/categories');
|
||||
return res.data.map(mapCategory);
|
||||
},
|
||||
|
||||
// No GET /api/categories/{id} endpoint – implemented as list + filter
|
||||
async getById(id: string): Promise<ProductCategoryDTO> {
|
||||
const res = await client.get<BackendProductCategory[]>('/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<ProductCategoryDTO> {
|
||||
const res = await client.post<BackendProductCategory>('/api/categories', request);
|
||||
return mapCategory(res.data);
|
||||
},
|
||||
|
||||
async update(id: string, request: UpdateCategoryRequest): Promise<ProductCategoryDTO> {
|
||||
const res = await client.put<BackendProductCategory>(`/api/categories/${id}`, request);
|
||||
return mapCategory(res.data);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
await client.delete(`/api/categories/${id}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CategoriesResource = ReturnType<typeof createCategoriesResource>;
|
||||
297
frontend/packages/api-client/src/resources/customers.ts
Normal file
297
frontend/packages/api-client/src/resources/customers.ts
Normal file
|
|
@ -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<CustomerPreference, string> = {
|
||||
BIO: 'Bio',
|
||||
REGIONAL: 'Regional',
|
||||
TIERWOHL: 'Tierwohl',
|
||||
HALAL: 'Halal',
|
||||
KOSHER: 'Koscher',
|
||||
GLUTENFREI: 'Glutenfrei',
|
||||
LAKTOSEFREI: 'Laktosefrei',
|
||||
};
|
||||
|
||||
export const DELIVERY_RHYTHM_LABELS: Record<DeliveryRhythm, string> = {
|
||||
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<CustomerDTO[]> {
|
||||
const res = await client.get<BackendCustomer[]>('/api/customers');
|
||||
return res.data.map(mapCustomer);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<CustomerDTO> {
|
||||
const res = await client.get<BackendCustomer>(`/api/customers/${id}`);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async create(request: CreateCustomerRequest): Promise<CustomerDTO> {
|
||||
const res = await client.post<BackendCustomer>('/api/customers', request);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async update(id: string, request: UpdateCustomerRequest): Promise<CustomerDTO> {
|
||||
const res = await client.put<BackendCustomer>(`/api/customers/${id}`, request);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async activate(id: string): Promise<CustomerDTO> {
|
||||
const res = await client.post<BackendCustomer>(`/api/customers/${id}/activate`);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async deactivate(id: string): Promise<CustomerDTO> {
|
||||
const res = await client.post<BackendCustomer>(`/api/customers/${id}/deactivate`);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async addDeliveryAddress(id: string, request: AddDeliveryAddressRequest): Promise<CustomerDTO> {
|
||||
const res = await client.post<BackendCustomer>(
|
||||
`/api/customers/${id}/delivery-addresses`,
|
||||
request,
|
||||
);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
// Returns 204 No Content → re-fetch customer
|
||||
async removeDeliveryAddress(id: string, label: string): Promise<CustomerDTO> {
|
||||
await client.delete(`/api/customers/${id}/delivery-addresses/${encodeURIComponent(label)}`);
|
||||
const res = await client.get<BackendCustomer>(`/api/customers/${id}`);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async setFrameContract(id: string, request: SetFrameContractRequest): Promise<CustomerDTO> {
|
||||
const res = await client.put<BackendCustomer>(
|
||||
`/api/customers/${id}/frame-contract`,
|
||||
request,
|
||||
);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
// Returns 204 No Content → re-fetch customer
|
||||
async removeFrameContract(id: string): Promise<CustomerDTO> {
|
||||
await client.delete(`/api/customers/${id}/frame-contract`);
|
||||
const res = await client.get<BackendCustomer>(`/api/customers/${id}`);
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
|
||||
async setPreferences(id: string, preferences: CustomerPreference[]): Promise<CustomerDTO> {
|
||||
const res = await client.put<BackendCustomer>(`/api/customers/${id}/preferences`, {
|
||||
preferences,
|
||||
});
|
||||
return mapCustomer(res.data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type CustomersResource = ReturnType<typeof createCustomersResource>;
|
||||
236
frontend/packages/api-client/src/resources/suppliers.ts
Normal file
236
frontend/packages/api-client/src/resources/suppliers.ts
Normal file
|
|
@ -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<SupplierDTO[]> {
|
||||
const res = await client.get<BackendSupplier[]>('/api/suppliers');
|
||||
return res.data.map(mapSupplier);
|
||||
},
|
||||
|
||||
async getById(id: string): Promise<SupplierDTO> {
|
||||
const res = await client.get<BackendSupplier>(`/api/suppliers/${id}`);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
async create(request: CreateSupplierRequest): Promise<SupplierDTO> {
|
||||
const res = await client.post<BackendSupplier>('/api/suppliers', request);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
async update(id: string, request: UpdateSupplierRequest): Promise<SupplierDTO> {
|
||||
const res = await client.put<BackendSupplier>(`/api/suppliers/${id}`, request);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
async activate(id: string): Promise<SupplierDTO> {
|
||||
const res = await client.post<BackendSupplier>(`/api/suppliers/${id}/activate`);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
async deactivate(id: string): Promise<SupplierDTO> {
|
||||
const res = await client.post<BackendSupplier>(`/api/suppliers/${id}/deactivate`);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
async rate(id: string, request: RateSupplierRequest): Promise<SupplierDTO> {
|
||||
const res = await client.post<BackendSupplier>(`/api/suppliers/${id}/rating`, request);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
async addCertificate(id: string, request: AddCertificateRequest): Promise<SupplierDTO> {
|
||||
const res = await client.post<BackendSupplier>(`/api/suppliers/${id}/certificates`, request);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
|
||||
// Returns 204 No Content → re-fetch supplier
|
||||
async removeCertificate(id: string, request: RemoveCertificateRequest): Promise<SupplierDTO> {
|
||||
await client.delete(`/api/suppliers/${id}/certificates`, { data: request });
|
||||
const res = await client.get<BackendSupplier>(`/api/suppliers/${id}`);
|
||||
return mapSupplier(res.data);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type SuppliersResource = ReturnType<typeof createSuppliersResource>;
|
||||
Loading…
Add table
Add a link
Reference in a new issue