1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 15:29:34 +01:00

feat: TUI-Screens für Inventar und Produktion + API-Client Typ-Migration

Neue TUI-Features:
- Inventar: Lageorte auflisten, anlegen, bearbeiten, (de-)aktivieren
- Produktion: Rezepte auflisten, anlegen, Detail-Ansicht
- Navigation erweitert (Hauptmenü, Routing)

API-Client auf generierte OpenAPI-Typen umgestellt:
- 6 neue Alias-Dateien in @effigenix/types (supplier, category, article,
  customer, inventory, production)
- api-client Re-Exports direkt von @effigenix/types statt via Resources
- Backend: @Schema(requiredProperties) auf 16 Response-Records
- Backend: OpenApiCustomizer für application-layer DTOs (UserDTO, RoleDTO)

Hinweis: Backend-Endpoints für GET /api/recipes und
GET /api/inventory/storage-locations/{id} fehlen noch (separate Issues).
This commit is contained in:
Sebastian Frick 2026-02-19 13:45:35 +01:00
parent bee3f28b5f
commit c26d72fbe7
48 changed files with 2090 additions and 474 deletions

View file

@ -1,14 +1,16 @@
package de.effigenix.infrastructure.inventory.web.dto; package de.effigenix.infrastructure.inventory.web.dto;
import de.effigenix.domain.inventory.StorageLocation; import de.effigenix.domain.inventory.StorageLocation;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal; import java.math.BigDecimal;
@Schema(requiredProperties = {"id", "name", "storageType", "active"})
public record StorageLocationResponse( public record StorageLocationResponse(
String id, String id,
String name, String name,
String storageType, String storageType,
TemperatureRangeResponse temperatureRange, @Schema(nullable = true) TemperatureRangeResponse temperatureRange,
boolean active boolean active
) { ) {
@ -30,5 +32,6 @@ public record StorageLocationResponse(
); );
} }
@Schema(requiredProperties = {"minTemperature", "maxTemperature"})
public record TemperatureRangeResponse(BigDecimal minTemperature, BigDecimal maxTemperature) {} public record TemperatureRangeResponse(BigDecimal minTemperature, BigDecimal maxTemperature) {}
} }

View file

@ -1,7 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.shared.common.Address; import de.effigenix.shared.common.Address;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"street", "houseNumber", "postalCode", "city", "country"})
public record AddressResponse( public record AddressResponse(
String street, String street,
String houseNumber, String houseNumber,

View file

@ -2,10 +2,12 @@ package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.Article; import de.effigenix.domain.masterdata.Article;
import de.effigenix.domain.masterdata.SupplierId; import de.effigenix.domain.masterdata.SupplierId;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "name", "articleNumber", "categoryId", "salesUnits", "status", "supplierIds", "createdAt", "updatedAt"})
public record ArticleResponse( public record ArticleResponse(
String id, String id,
String name, String name,

View file

@ -1,7 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.shared.common.ContactInfo; import de.effigenix.shared.common.ContactInfo;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"phone", "email", "contactPerson"})
public record ContactInfoResponse( public record ContactInfoResponse(
String phone, String phone,
String email, String email,

View file

@ -1,9 +1,11 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.ContractLineItem; import de.effigenix.domain.masterdata.ContractLineItem;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal; import java.math.BigDecimal;
@Schema(requiredProperties = {"articleId", "agreedPrice", "agreedQuantity", "unit"})
public record ContractLineItemResponse( public record ContractLineItemResponse(
String articleId, String articleId,
BigDecimal agreedPrice, BigDecimal agreedPrice,

View file

@ -2,19 +2,21 @@ package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.Customer; import de.effigenix.domain.masterdata.Customer;
import de.effigenix.domain.masterdata.CustomerPreference; import de.effigenix.domain.masterdata.CustomerPreference;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "name", "type", "billingAddress", "contactInfo", "deliveryAddresses", "preferences", "status", "createdAt", "updatedAt"})
public record CustomerResponse( public record CustomerResponse(
String id, String id,
String name, String name,
String type, String type,
AddressResponse billingAddress, AddressResponse billingAddress,
ContactInfoResponse contactInfo, ContactInfoResponse contactInfo,
PaymentTermsResponse paymentTerms, @Schema(nullable = true) PaymentTermsResponse paymentTerms,
List<DeliveryAddressResponse> deliveryAddresses, List<DeliveryAddressResponse> deliveryAddresses,
FrameContractResponse frameContract, @Schema(nullable = true) FrameContractResponse frameContract,
List<String> preferences, List<String> preferences,
String status, String status,
LocalDateTime createdAt, LocalDateTime createdAt,

View file

@ -1,7 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.DeliveryAddress; import de.effigenix.domain.masterdata.DeliveryAddress;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"label", "address", "contactPerson", "deliveryNotes"})
public record DeliveryAddressResponse( public record DeliveryAddressResponse(
String label, String label,
AddressResponse address, AddressResponse address,

View file

@ -1,10 +1,12 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.FrameContract; import de.effigenix.domain.masterdata.FrameContract;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate; import java.time.LocalDate;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "validFrom", "validUntil", "deliveryRhythm", "lineItems"})
public record FrameContractResponse( public record FrameContractResponse(
String id, String id,
LocalDate validFrom, LocalDate validFrom,

View file

@ -1,7 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.shared.common.PaymentTerms; import de.effigenix.shared.common.PaymentTerms;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"paymentDueDays", "paymentDescription"})
public record PaymentTermsResponse( public record PaymentTermsResponse(
int paymentDueDays, int paymentDueDays,
String paymentDescription String paymentDescription

View file

@ -1,7 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.ProductCategory; import de.effigenix.domain.masterdata.ProductCategory;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"id", "name", "description"})
public record ProductCategoryResponse( public record ProductCategoryResponse(
String id, String id,
String name, String name,

View file

@ -1,9 +1,11 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.QualityCertificate; import de.effigenix.domain.masterdata.QualityCertificate;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDate; import java.time.LocalDate;
@Schema(requiredProperties = {"certificateType", "issuer", "validFrom", "validUntil"})
public record QualityCertificateResponse( public record QualityCertificateResponse(
String certificateType, String certificateType,
String issuer, String issuer,

View file

@ -1,9 +1,11 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.SalesUnit; import de.effigenix.domain.masterdata.SalesUnit;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal; import java.math.BigDecimal;
@Schema(requiredProperties = {"id", "unit", "priceModel", "price"})
public record SalesUnitResponse( public record SalesUnitResponse(
String id, String id,
String unit, String unit,

View file

@ -1,7 +1,9 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.SupplierRating; import de.effigenix.domain.masterdata.SupplierRating;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"qualityScore", "deliveryScore", "priceScore"})
public record SupplierRatingResponse( public record SupplierRatingResponse(
int qualityScore, int qualityScore,
int deliveryScore, int deliveryScore,

View file

@ -1,18 +1,20 @@
package de.effigenix.infrastructure.masterdata.web.dto; package de.effigenix.infrastructure.masterdata.web.dto;
import de.effigenix.domain.masterdata.Supplier; import de.effigenix.domain.masterdata.Supplier;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "name", "contactInfo", "certificates", "status", "createdAt", "updatedAt"})
public record SupplierResponse( public record SupplierResponse(
String id, String id,
String name, String name,
AddressResponse address, @Schema(nullable = true) AddressResponse address,
ContactInfoResponse contactInfo, ContactInfoResponse contactInfo,
PaymentTermsResponse paymentTerms, @Schema(nullable = true) PaymentTermsResponse paymentTerms,
List<QualityCertificateResponse> certificates, List<QualityCertificateResponse> certificates,
SupplierRatingResponse rating, @Schema(nullable = true) SupplierRatingResponse rating,
String status, String status,
LocalDateTime createdAt, LocalDateTime createdAt,
LocalDateTime updatedAt LocalDateTime updatedAt

View file

@ -1,14 +1,16 @@
package de.effigenix.infrastructure.production.web.dto; package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Ingredient; import de.effigenix.domain.production.Ingredient;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(requiredProperties = {"id", "position", "articleId", "quantity", "uom", "substitutable"})
public record IngredientResponse( public record IngredientResponse(
String id, String id,
int position, int position,
String articleId, String articleId,
String quantity, String quantity,
String uom, String uom,
String subRecipeId, @Schema(nullable = true) String subRecipeId,
boolean substitutable boolean substitutable
) { ) {
public static IngredientResponse from(Ingredient ingredient) { public static IngredientResponse from(Ingredient ingredient) {

View file

@ -1,10 +1,12 @@
package de.effigenix.infrastructure.production.web.dto; package de.effigenix.infrastructure.production.web.dto;
import de.effigenix.domain.production.Recipe; import de.effigenix.domain.production.Recipe;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@Schema(requiredProperties = {"id", "name", "version", "type", "description", "yieldPercentage", "outputQuantity", "outputUom", "status", "ingredients", "createdAt", "updatedAt"})
public record RecipeResponse( public record RecipeResponse(
String id, String id,
String name, String name,
@ -12,7 +14,7 @@ public record RecipeResponse(
String type, String type,
String description, String description,
int yieldPercentage, int yieldPercentage,
Integer shelfLifeDays, @Schema(nullable = true) Integer shelfLifeDays,
String outputQuantity, String outputQuantity,
String outputUom, String outputUom,
String status, String status,

View file

@ -12,7 +12,8 @@ import java.time.LocalDateTime;
* Client should store the access token and send it in Authorization header * Client should store the access token and send it in Authorization header
* for subsequent requests. * for subsequent requests.
*/ */
@Schema(description = "Login response with JWT tokens") @Schema(description = "Login response with JWT tokens",
requiredProperties = {"accessToken", "tokenType", "expiresIn", "expiresAt", "refreshToken"})
public record LoginResponse( public record LoginResponse(
@Schema(description = "JWT access token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") @Schema(description = "JWT access token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
String accessToken, String accessToken,

View file

@ -7,8 +7,15 @@ import io.swagger.v3.oas.annotations.info.Info;
import io.swagger.v3.oas.annotations.info.License; import io.swagger.v3.oas.annotations.info.License;
import io.swagger.v3.oas.annotations.security.SecurityScheme; import io.swagger.v3.oas.annotations.security.SecurityScheme;
import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.annotations.servers.Server;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* OpenAPI/Swagger Configuration. * OpenAPI/Swagger Configuration.
* *
@ -111,6 +118,35 @@ import org.springframework.context.annotation.Configuration;
""" """
) )
public class OpenApiConfig { public class OpenApiConfig {
// Configuration is done via annotations
// No additional beans needed /**
* Marks fields as required for application-layer DTOs that cannot carry
* {@code @Schema} annotations (to preserve the clean architecture boundary).
*/
@Bean
public OpenApiCustomizer applicationDtoRequiredFieldsCustomizer() {
// Application-layer DTOs all required except explicitly nullable
Map<String, Set<String>> nullableFields = Map.of(
"UserDTO", Set.of("branchId", "lastLogin"),
"RoleDTO", Set.of()
);
return openApi -> {
var schemas = openApi.getComponents().getSchemas();
if (schemas == null) return;
nullableFields.forEach((schemaName, nullable) -> {
var schema = schemas.get(schemaName);
if (schema == null || schema.getProperties() == null) return;
List<String> required = new ArrayList<>();
schema.getProperties().keySet().forEach(prop -> {
if (!nullable.contains(prop)) {
required.add((String) prop);
}
});
schema.setRequired(required);
});
};
}
} }

View file

@ -31,6 +31,16 @@ import { CustomerDetailScreen } from './components/masterdata/customers/Customer
import { CustomerCreateScreen } from './components/masterdata/customers/CustomerCreateScreen.js'; import { CustomerCreateScreen } from './components/masterdata/customers/CustomerCreateScreen.js';
import { AddDeliveryAddressScreen } from './components/masterdata/customers/AddDeliveryAddressScreen.js'; import { AddDeliveryAddressScreen } from './components/masterdata/customers/AddDeliveryAddressScreen.js';
import { SetPreferencesScreen } from './components/masterdata/customers/SetPreferencesScreen.js'; import { SetPreferencesScreen } from './components/masterdata/customers/SetPreferencesScreen.js';
// Lagerverwaltung
import { InventoryMenu } from './components/inventory/InventoryMenu.js';
import { StorageLocationListScreen } from './components/inventory/StorageLocationListScreen.js';
import { StorageLocationCreateScreen } from './components/inventory/StorageLocationCreateScreen.js';
import { StorageLocationDetailScreen } from './components/inventory/StorageLocationDetailScreen.js';
// Produktion
import { ProductionMenu } from './components/production/ProductionMenu.js';
import { RecipeListScreen } from './components/production/RecipeListScreen.js';
import { RecipeCreateScreen } from './components/production/RecipeCreateScreen.js';
import { RecipeDetailScreen } from './components/production/RecipeDetailScreen.js';
function ScreenRouter() { function ScreenRouter() {
const { isAuthenticated, loading } = useAuth(); const { isAuthenticated, loading } = useAuth();
@ -87,6 +97,16 @@ function ScreenRouter() {
{current === 'customer-create' && <CustomerCreateScreen />} {current === 'customer-create' && <CustomerCreateScreen />}
{current === 'customer-add-delivery-address' && <AddDeliveryAddressScreen />} {current === 'customer-add-delivery-address' && <AddDeliveryAddressScreen />}
{current === 'customer-set-preferences' && <SetPreferencesScreen />} {current === 'customer-set-preferences' && <SetPreferencesScreen />}
{/* Lagerverwaltung */}
{current === 'inventory-menu' && <InventoryMenu />}
{current === 'storage-location-list' && <StorageLocationListScreen />}
{current === 'storage-location-create' && <StorageLocationCreateScreen />}
{current === 'storage-location-detail' && <StorageLocationDetailScreen />}
{/* Produktion */}
{current === 'production-menu' && <ProductionMenu />}
{current === 'recipe-list' && <RecipeListScreen />}
{current === 'recipe-create' && <RecipeCreateScreen />}
{current === 'recipe-detail' && <RecipeDetailScreen />}
</MainLayout> </MainLayout>
); );
} }

View file

@ -17,6 +17,8 @@ export function MainMenu() {
const items: MenuItem[] = [ const items: MenuItem[] = [
{ label: 'Stammdaten', screen: 'masterdata-menu' }, { label: 'Stammdaten', screen: 'masterdata-menu' },
{ label: 'Lagerverwaltung', screen: 'inventory-menu' },
{ label: 'Produktion', screen: 'production-menu' },
{ label: 'Benutzer verwalten', screen: 'user-list' }, { label: 'Benutzer verwalten', screen: 'user-list' },
{ label: 'Rollen anzeigen', screen: 'role-list' }, { label: 'Rollen anzeigen', screen: 'role-list' },
{ label: 'Abmelden', action: () => void logout() }, { label: 'Abmelden', action: () => void logout() },

View file

@ -0,0 +1,64 @@
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: 'Lagerorte', screen: 'storage-location-list', description: 'Lagerorte verwalten (Kühlräume, Trockenlager, …)' },
];
export function InventoryMenu() {
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>Lagerverwaltung</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">
<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>
);
}

View file

@ -0,0 +1,141 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStorageLocations } from '../../hooks/useStorageLocations.js';
import { FormInput } from '../shared/FormInput.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { STORAGE_TYPE_LABELS } from '@effigenix/api-client';
import type { StorageType } from '@effigenix/api-client';
type Field = 'name' | 'storageType' | 'minTemperature' | 'maxTemperature';
const FIELDS: Field[] = ['name', 'storageType', 'minTemperature', 'maxTemperature'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
storageType: 'Lagertyp * (←→ wechseln)',
minTemperature: 'Min. Temperatur (°C)',
maxTemperature: 'Max. Temperatur (°C)',
};
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
export function StorageLocationCreateScreen() {
const { navigate, back } = useNavigation();
const { createStorageLocation, loading, error, clearError } = useStorageLocations();
const [values, setValues] = useState<Record<Field, string>>({
name: '',
storageType: 'DRY_STORAGE',
minTemperature: '',
maxTemperature: '',
});
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 (activeField === 'storageType') {
if (key.leftArrow || key.rightArrow) {
const idx = STORAGE_TYPES.indexOf(values.storageType as StorageType);
const dir = key.rightArrow ? 1 : -1;
const next = STORAGE_TYPES[(idx + dir + STORAGE_TYPES.length) % STORAGE_TYPES.length];
if (next) setValues((v) => ({ ...v, storageType: next }));
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.storageType) errors.storageType = 'Lagertyp ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const result = await createStorageLocation({
name: values.name.trim(),
storageType: values.storageType,
...(values.minTemperature.trim() ? { minTemperature: values.minTemperature.trim() } : {}),
...(values.maxTemperature.trim() ? { maxTemperature: values.maxTemperature.trim() } : {}),
});
if (result) navigate('storage-location-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="Lagerort wird angelegt..." />
</Box>
);
}
const storageTypeLabel = STORAGE_TYPE_LABELS[values.storageType as StorageType] ?? values.storageType;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Neuer Lagerort</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => {
if (field === 'storageType') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{storageTypeLabel}</Text>
</Text>
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</Text>}
</Box>
);
}
return (
<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 · Lagertyp · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,144 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { StorageLocationDTO, StorageType } from '@effigenix/api-client';
import { STORAGE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js';
import { useStorageLocations } from '../../hooks/useStorageLocations.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' | 'back';
type Mode = 'menu' | 'confirm-status';
const MENU_ITEMS: { id: MenuAction; label: (loc: StorageLocationDTO) => string }[] = [
{ id: 'toggle-status', label: (loc) => loc.active ? '[Deaktivieren]' : '[Aktivieren]' },
{ id: 'back', label: () => '[Zurück]' },
];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function StorageLocationDetailScreen() {
const { params, back } = useNavigation();
const storageLocationId = params['storageLocationId'] ?? '';
const { activateStorageLocation, deactivateStorageLocation } = useStorageLocations();
const [location, setLocation] = useState<StorageLocationDTO | 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 loadLocation = useCallback(() => {
setLoading(true);
setError(null);
client.storageLocations.getById(storageLocationId)
.then((loc) => { setLocation(loc); setLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
}, [storageLocationId]);
useEffect(() => { if (storageLocationId) loadLocation(); }, [loadLocation, storageLocationId]);
useInput((_input, key) => {
if (loading || actionLoading) 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 (!location) return;
const item = MENU_ITEMS[selectedAction];
if (!item) return;
switch (item.id) {
case 'toggle-status':
setMode('confirm-status');
break;
case 'back':
back();
break;
}
};
const handleToggleStatus = useCallback(async () => {
if (!location) return;
setMode('menu');
setActionLoading(true);
const fn = location.active ? deactivateStorageLocation : activateStorageLocation;
const updated = await fn(location.id);
setActionLoading(false);
if (updated) {
setLocation(updated);
setSuccessMessage(location.active ? 'Lagerort deaktiviert.' : 'Lagerort aktiviert.');
}
}, [location, activateStorageLocation, deactivateStorageLocation]);
if (loading) return <LoadingSpinner label="Lade Lagerort..." />;
if (error && !location) return <ErrorDisplay message={error} onDismiss={back} />;
if (!location) return <Text color="red">Lagerort nicht gefunden.</Text>;
const statusColor = location.active ? 'green' : 'red';
const typeName = STORAGE_TYPE_LABELS[location.storageType as StorageType] ?? location.storageType;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Lagerort: {location.name}</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
{successMessage && <SuccessDisplay message={successMessage} onDismiss={() => setSuccessMessage(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
<Box gap={2}>
<Text color="gray">Status:</Text>
<Text color={statusColor} bold>{location.active ? 'AKTIV' : 'INAKTIV'}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Lagertyp:</Text>
<Text>{typeName}</Text>
</Box>
{location.temperatureRange && (
<Box gap={2}>
<Text color="gray">Temperatur:</Text>
<Text>{location.temperatureRange.minTemperature}°C {location.temperatureRange.maxTemperature}°C</Text>
</Box>
)}
</Box>
{mode === 'confirm-status' && (
<ConfirmDialog
message={location.active ? 'Lagerort deaktivieren?' : 'Lagerort aktivieren?'}
onConfirm={() => void handleToggleStatus()}
onCancel={() => setMode('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(location)}
</Text>
</Box>
))}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor> navigieren · Enter ausführen · Backspace Zurück</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useStorageLocations } from '../../hooks/useStorageLocations.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { STORAGE_TYPE_LABELS } from '@effigenix/api-client';
import type { StorageType, StorageLocationFilter } from '@effigenix/api-client';
type Filter = 'ALL' | 'ACTIVE' | 'INACTIVE';
const STORAGE_TYPES: StorageType[] = ['COLD_ROOM', 'FREEZER', 'DRY_STORAGE', 'DISPLAY_COUNTER', 'PRODUCTION_AREA'];
export function StorageLocationListScreen() {
const { navigate, back } = useNavigation();
const { storageLocations, loading, error, fetchStorageLocations, clearError } = useStorageLocations();
const [selectedIndex, setSelectedIndex] = useState(0);
const [statusFilter, setStatusFilter] = useState<Filter>('ALL');
const [typeFilter, setTypeFilter] = useState<StorageType | null>(null);
useEffect(() => {
const filter: StorageLocationFilter = {};
if (typeFilter) filter.storageType = typeFilter;
if (statusFilter !== 'ALL') filter.active = statusFilter === 'ACTIVE';
void fetchStorageLocations(filter);
}, [fetchStorageLocations, statusFilter, typeFilter]);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(storageLocations.length - 1, i + 1));
if (key.return && storageLocations.length > 0) {
const loc = storageLocations[selectedIndex];
if (loc) navigate('storage-location-detail', { storageLocationId: loc.id });
}
if (input === 'n') navigate('storage-location-create');
if (input === 'a') { setStatusFilter('ALL'); setSelectedIndex(0); }
if (input === 'A') { setStatusFilter('ACTIVE'); setSelectedIndex(0); }
if (input === 'I') { setStatusFilter('INACTIVE'); setSelectedIndex(0); }
if (input === 't') {
setTypeFilter((current) => {
if (!current) return STORAGE_TYPES[0] ?? null;
const idx = STORAGE_TYPES.indexOf(current);
if (idx >= STORAGE_TYPES.length - 1) return null;
return STORAGE_TYPES[idx + 1] ?? null;
});
setSelectedIndex(0);
}
if (key.backspace || key.escape) back();
});
const filterLabel: Record<Filter, string> = { ALL: 'Alle', ACTIVE: 'Aktiv', INACTIVE: 'Inaktiv' };
const typeLabel = typeFilter ? STORAGE_TYPE_LABELS[typeFilter] : 'Alle';
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Lagerorte</Text>
<Text color="gray" dimColor>
Status: <Text color="yellow">{filterLabel[statusFilter]}</Text>
{' · '}Typ: <Text color="yellow">{typeLabel}</Text>
{' '}({storageLocations.length})
</Text>
</Box>
{loading && <LoadingSpinner label="Lade Lagerorte..." />}
{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(30)}</Text>
<Text color="gray" bold>{'Typ'.padEnd(20)}</Text>
<Text color="gray" bold>Temperatur</Text>
</Box>
{storageLocations.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Lagerorte gefunden.</Text>
</Box>
)}
{storageLocations.map((loc, index) => {
const isSelected = index === selectedIndex;
const statusColor = loc.active ? 'green' : 'red';
const textColor = isSelected ? 'cyan' : 'white';
const typeName = STORAGE_TYPE_LABELS[loc.storageType as StorageType] ?? loc.storageType;
const tempRange = loc.temperatureRange
? `${loc.temperatureRange.minTemperature}°C ${loc.temperatureRange.maxTemperature}°C`
: '';
return (
<Box key={loc.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={statusColor}>{loc.active ? '● ' : '○ '}</Text>
<Text color={textColor}>{loc.name.substring(0, 24).padEnd(25)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{typeName.padEnd(20)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{tempRange}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · [a] Alle · [A] Aktiv · [I] Inaktiv · [t] Typ · Backspace Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,64 @@
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: 'Rezepte', screen: 'recipe-list', description: 'Rezepte anlegen und verwalten' },
];
export function ProductionMenu() {
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>Produktion</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">
<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>
);
}

View file

@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useRecipes } from '../../hooks/useRecipes.js';
import { FormInput } from '../shared/FormInput.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import type { RecipeType } from '@effigenix/api-client';
type Field = 'name' | 'version' | 'type' | 'description' | 'yieldPercentage' | 'shelfLifeDays' | 'outputQuantity' | 'outputUom';
const FIELDS: Field[] = ['name', 'version', 'type', 'description', 'yieldPercentage', 'shelfLifeDays', 'outputQuantity', 'outputUom'];
const FIELD_LABELS: Record<Field, string> = {
name: 'Name *',
version: 'Version *',
type: 'Rezepttyp * (←→ wechseln)',
description: 'Beschreibung',
yieldPercentage: 'Ausbeute (%) *',
shelfLifeDays: 'Haltbarkeit (Tage)',
outputQuantity: 'Ausgabemenge *',
outputUom: 'Mengeneinheit *',
};
const RECIPE_TYPES: RecipeType[] = ['RAW_MATERIAL', 'INTERMEDIATE', 'FINISHED_PRODUCT'];
export function RecipeCreateScreen() {
const { navigate, back } = useNavigation();
const { createRecipe, loading, error, clearError } = useRecipes();
const [values, setValues] = useState<Record<Field, string>>({
name: '',
version: '1',
type: 'FINISHED_PRODUCT',
description: '',
yieldPercentage: '100',
shelfLifeDays: '',
outputQuantity: '',
outputUom: '',
});
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 (activeField === 'type') {
if (key.leftArrow || key.rightArrow) {
const idx = RECIPE_TYPES.indexOf(values.type as RecipeType);
const dir = key.rightArrow ? 1 : -1;
const next = RECIPE_TYPES[(idx + dir + RECIPE_TYPES.length) % RECIPE_TYPES.length];
if (next) setValues((v) => ({ ...v, type: next }));
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.version.trim() || isNaN(parseInt(values.version, 10))) errors.version = 'Version muss eine Zahl sein.';
if (!values.type) errors.type = 'Rezepttyp ist erforderlich.';
if (!values.yieldPercentage.trim() || isNaN(parseInt(values.yieldPercentage, 10))) errors.yieldPercentage = 'Ausbeute muss eine Zahl sein.';
if (!values.outputQuantity.trim()) errors.outputQuantity = 'Ausgabemenge ist erforderlich.';
if (!values.outputUom.trim()) errors.outputUom = 'Mengeneinheit ist erforderlich.';
setFieldErrors(errors);
if (Object.keys(errors).length > 0) return;
const result = await createRecipe({
name: values.name.trim(),
version: parseInt(values.version, 10),
type: values.type as RecipeType,
...(values.description.trim() ? { description: values.description.trim() } : {}),
yieldPercentage: parseInt(values.yieldPercentage, 10),
...(values.shelfLifeDays.trim() ? { shelfLifeDays: parseInt(values.shelfLifeDays, 10) } : {}),
outputQuantity: values.outputQuantity.trim(),
outputUom: values.outputUom.trim(),
});
if (result) navigate('recipe-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="Rezept wird angelegt..." />
</Box>
);
}
const typeLabel = RECIPE_TYPE_LABELS[values.type as RecipeType] ?? values.type;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Neues Rezept</Text>
{error && <ErrorDisplay message={error} onDismiss={clearError} />}
<Box flexDirection="column" gap={1} width={60}>
{FIELDS.map((field) => {
if (field === 'type') {
return (
<Box key={field} flexDirection="column">
<Text color={activeField === field ? 'cyan' : 'gray'}>
{FIELD_LABELS[field]}: <Text bold color="white">{typeLabel}</Text>
</Text>
{fieldErrors[field] && <Text color="red">{fieldErrors[field]}</Text>}
</Box>
);
}
return (
<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 · Rezepttyp · Enter auf letztem Feld speichern · Escape Abbrechen
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,103 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import type { RecipeDTO, RecipeType } from '@effigenix/api-client';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import { useNavigation } from '../../state/navigation-context.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { client } from '../../utils/api-client.js';
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function RecipeDetailScreen() {
const { params, back } = useNavigation();
const recipeId = params['recipeId'] ?? '';
const [recipe, setRecipe] = useState<RecipeDTO | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadRecipe = useCallback(() => {
setLoading(true);
setError(null);
client.recipes.getById(recipeId)
.then((r) => { setRecipe(r); setLoading(false); })
.catch((err: unknown) => { setError(errorMessage(err)); setLoading(false); });
}, [recipeId]);
useEffect(() => { if (recipeId) loadRecipe(); }, [loadRecipe, recipeId]);
useInput((_input, key) => {
if (loading) return;
if (key.backspace || key.escape) back();
});
if (loading) return <LoadingSpinner label="Lade Rezept..." />;
if (error && !recipe) return <ErrorDisplay message={error} onDismiss={back} />;
if (!recipe) return <Text color="red">Rezept nicht gefunden.</Text>;
const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type;
return (
<Box flexDirection="column" gap={1}>
<Text color="cyan" bold>Rezept: {recipe.name}</Text>
{error && <ErrorDisplay message={error} onDismiss={() => setError(null)} />}
<Box flexDirection="column" borderStyle="round" borderColor="gray" paddingX={2} paddingY={1}>
<Box gap={2}>
<Text color="gray">Status:</Text>
<Text bold>{recipe.status}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Typ:</Text>
<Text>{typeName}</Text>
</Box>
<Box gap={2}>
<Text color="gray">Version:</Text>
<Text>{recipe.version}</Text>
</Box>
{recipe.description && (
<Box gap={2}>
<Text color="gray">Beschreibung:</Text>
<Text>{recipe.description}</Text>
</Box>
)}
<Box gap={2}>
<Text color="gray">Ausbeute:</Text>
<Text>{recipe.yieldPercentage}%</Text>
</Box>
{recipe.shelfLifeDays !== null && (
<Box gap={2}>
<Text color="gray">Haltbarkeit:</Text>
<Text>{recipe.shelfLifeDays} Tage</Text>
</Box>
)}
<Box gap={2}>
<Text color="gray">Ausgabemenge:</Text>
<Text>{recipe.outputQuantity} {recipe.outputUom}</Text>
</Box>
{recipe.ingredients.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Text color="gray">Zutaten:</Text>
{recipe.ingredients.map((ing) => (
<Box key={ing.id} paddingLeft={2} gap={1}>
<Text color="yellow">{ing.position}.</Text>
<Text>{ing.quantity} {ing.uom}</Text>
<Text color="gray">(Artikel: {ing.articleId})</Text>
{ing.substitutable && <Text color="green">[austauschbar]</Text>}
</Box>
))}
</Box>
)}
</Box>
<Box marginTop={1}>
<Text color="gray" dimColor>Backspace Zurück</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,80 @@
import React, { useEffect, useState } from 'react';
import { Box, Text, useInput } from 'ink';
import { useNavigation } from '../../state/navigation-context.js';
import { useRecipes } from '../../hooks/useRecipes.js';
import { LoadingSpinner } from '../shared/LoadingSpinner.js';
import { ErrorDisplay } from '../shared/ErrorDisplay.js';
import { RECIPE_TYPE_LABELS } from '@effigenix/api-client';
import type { RecipeType } from '@effigenix/api-client';
export function RecipeListScreen() {
const { navigate, back } = useNavigation();
const { recipes, loading, error, fetchRecipes, clearError } = useRecipes();
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
void fetchRecipes();
}, [fetchRecipes]);
useInput((input, key) => {
if (loading) return;
if (key.upArrow) setSelectedIndex((i) => Math.max(0, i - 1));
if (key.downArrow) setSelectedIndex((i) => Math.min(recipes.length - 1, i + 1));
if (key.return && recipes.length > 0) {
const recipe = recipes[selectedIndex];
if (recipe) navigate('recipe-detail', { recipeId: recipe.id });
}
if (input === 'n') navigate('recipe-create');
if (key.backspace || key.escape) back();
});
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text color="cyan" bold>Rezepte</Text>
<Text color="gray" dimColor>({recipes.length})</Text>
</Box>
{loading && <LoadingSpinner label="Lade Rezepte..." />}
{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>{' Name'.padEnd(30)}</Text>
<Text color="gray" bold>{'Typ'.padEnd(18)}</Text>
<Text color="gray" bold>{'V.'.padEnd(5)}</Text>
<Text color="gray" bold>Status</Text>
</Box>
{recipes.length === 0 && (
<Box paddingX={1} paddingY={1}>
<Text color="gray" dimColor>Keine Rezepte gefunden.</Text>
</Box>
)}
{recipes.map((recipe, index) => {
const isSelected = index === selectedIndex;
const textColor = isSelected ? 'cyan' : 'white';
const typeName = RECIPE_TYPE_LABELS[recipe.type as RecipeType] ?? recipe.type;
return (
<Box key={recipe.id} paddingX={1}>
<Text color={textColor}>{isSelected ? '▶ ' : ' '}</Text>
<Text color={textColor}>{recipe.name.substring(0, 26).padEnd(27)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{typeName.padEnd(18)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{String(recipe.version).padEnd(5)}</Text>
<Text color={isSelected ? 'cyan' : 'gray'}>{recipe.status}</Text>
</Box>
);
})}
</Box>
)}
<Box marginTop={1}>
<Text color="gray" dimColor>
nav · Enter Details · [n] Neu · Backspace Zurück
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,54 @@
import { useState, useCallback } from 'react';
import type { RecipeDTO, CreateRecipeRequest } from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface RecipesState {
recipes: RecipeDTO[];
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function useRecipes() {
const [state, setState] = useState<RecipesState>({
recipes: [],
loading: false,
error: null,
});
const fetchRecipes = useCallback(async () => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const recipes = await client.recipes.list();
setState({ recipes, loading: false, error: null });
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const createRecipe = useCallback(async (request: CreateRecipeRequest) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const recipe = await client.recipes.create(request);
setState((s) => ({ recipes: [...s.recipes, recipe], loading: false, error: null }));
return recipe;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
}, []);
const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null }));
}, []);
return {
...state,
fetchRecipes,
createRecipe,
clearError,
};
}

View file

@ -0,0 +1,104 @@
import { useState, useCallback } from 'react';
import type {
StorageLocationDTO,
CreateStorageLocationRequest,
UpdateStorageLocationRequest,
StorageLocationFilter,
} from '@effigenix/api-client';
import { client } from '../utils/api-client.js';
interface StorageLocationsState {
storageLocations: StorageLocationDTO[];
loading: boolean;
error: string | null;
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Unbekannter Fehler';
}
export function useStorageLocations() {
const [state, setState] = useState<StorageLocationsState>({
storageLocations: [],
loading: false,
error: null,
});
const fetchStorageLocations = useCallback(async (filter?: StorageLocationFilter) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const storageLocations = await client.storageLocations.list(filter);
setState({ storageLocations, loading: false, error: null });
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
}
}, []);
const createStorageLocation = useCallback(async (request: CreateStorageLocationRequest) => {
setState((s) => ({ ...s, loading: true, error: null }));
try {
const location = await client.storageLocations.create(request);
setState((s) => ({ storageLocations: [...s.storageLocations, location], loading: false, error: null }));
return location;
} catch (err) {
setState((s) => ({ ...s, loading: false, error: errorMessage(err) }));
return null;
}
}, []);
const updateStorageLocation = useCallback(async (id: string, request: UpdateStorageLocationRequest) => {
try {
const updated = await client.storageLocations.update(id, request);
setState((s) => ({
...s,
storageLocations: s.storageLocations.map((loc) => (loc.id === id ? updated : loc)),
}));
return updated;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return null;
}
}, []);
const activateStorageLocation = useCallback(async (id: string) => {
try {
const updated = await client.storageLocations.activate(id);
setState((s) => ({
...s,
storageLocations: s.storageLocations.map((loc) => (loc.id === id ? updated : loc)),
}));
return updated;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return null;
}
}, []);
const deactivateStorageLocation = useCallback(async (id: string) => {
try {
const updated = await client.storageLocations.deactivate(id);
setState((s) => ({
...s,
storageLocations: s.storageLocations.map((loc) => (loc.id === id ? updated : loc)),
}));
return updated;
} catch (err) {
setState((s) => ({ ...s, error: errorMessage(err) }));
return null;
}
}, []);
const clearError = useCallback(() => {
setState((s) => ({ ...s, error: null }));
}, []);
return {
...state,
fetchStorageLocations,
createStorageLocation,
updateStorageLocation,
activateStorageLocation,
deactivateStorageLocation,
clearError,
};
}

View file

@ -27,7 +27,17 @@ export type Screen =
| 'customer-detail' | 'customer-detail'
| 'customer-create' | 'customer-create'
| 'customer-add-delivery-address' | 'customer-add-delivery-address'
| 'customer-set-preferences'; | 'customer-set-preferences'
// Lagerverwaltung
| 'inventory-menu'
| 'storage-location-list'
| 'storage-location-create'
| 'storage-location-detail'
// Produktion
| 'production-menu'
| 'recipe-list'
| 'recipe-create'
| 'recipe-detail';
interface NavigationState { interface NavigationState {
current: Screen; current: Screen;

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,8 @@ export { createCategoriesResource } from './resources/categories.js';
export { createSuppliersResource } from './resources/suppliers.js'; export { createSuppliersResource } from './resources/suppliers.js';
export { createArticlesResource } from './resources/articles.js'; export { createArticlesResource } from './resources/articles.js';
export { createCustomersResource } from './resources/customers.js'; export { createCustomersResource } from './resources/customers.js';
export { createStorageLocationsResource } from './resources/storage-locations.js';
export { createRecipesResource } from './resources/recipes.js';
export { export {
ApiError, ApiError,
AuthenticationError, AuthenticationError,
@ -29,12 +31,15 @@ export {
RefreshTokenExpiredError, RefreshTokenExpiredError,
} from './errors.js'; } from './errors.js';
export type { ValidationErrorDetail } from './errors.js'; export type { ValidationErrorDetail } from './errors.js';
// Auth types (no generated alias, stay in resource)
export type { export type {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
RefreshTokenRequest, RefreshTokenRequest,
AuthResource, AuthResource,
} from './resources/auth.js'; } from './resources/auth.js';
// Types from @effigenix/types (generated OpenAPI aliases)
export type { export type {
UserDTO, UserDTO,
RoleDTO, RoleDTO,
@ -42,18 +47,7 @@ export type {
UpdateUserRequest, UpdateUserRequest,
ChangePasswordRequest, ChangePasswordRequest,
AssignRoleRequest, AssignRoleRequest,
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, SupplierDTO,
SupplierStatus,
AddressDTO, AddressDTO,
ContactInfoDTO, ContactInfoDTO,
PaymentTermsDTO, PaymentTermsDTO,
@ -64,36 +58,57 @@ export type {
RateSupplierRequest, RateSupplierRequest,
AddCertificateRequest, AddCertificateRequest,
RemoveCertificateRequest, RemoveCertificateRequest,
SuppliersResource, ProductCategoryDTO,
} from './resources/suppliers.js'; CreateCategoryRequest,
export type { UpdateCategoryRequest,
ArticleDTO, ArticleDTO,
ArticleStatus,
SalesUnitDTO, SalesUnitDTO,
Unit,
PriceModel,
CreateArticleRequest, CreateArticleRequest,
UpdateArticleRequest, UpdateArticleRequest,
AddSalesUnitRequest, AddSalesUnitRequest,
UpdateSalesUnitPriceRequest, UpdateSalesUnitPriceRequest,
ArticlesResource,
} from './resources/articles.js';
export { UNIT_LABELS, PRICE_MODEL_LABELS } from './resources/articles.js';
export type {
CustomerDTO, CustomerDTO,
CustomerType,
CustomerStatus,
CustomerPreference,
DeliveryRhythm,
DeliveryAddressDTO, DeliveryAddressDTO,
FrameContractDTO, FrameContractDTO,
ContractLineItemDTO, ContractLineItemDTO,
CreateCustomerRequest, CreateCustomerRequest,
UpdateCustomerRequest, UpdateCustomerRequest,
AddDeliveryAddressRequest, AddDeliveryAddressRequest,
SetFrameContractLineItem,
SetFrameContractRequest,
StorageLocationDTO,
TemperatureRangeDTO,
CreateStorageLocationRequest,
UpdateStorageLocationRequest,
RecipeDTO,
IngredientDTO,
CreateRecipeRequest,
AddRecipeIngredientRequest,
} from '@effigenix/types';
// Resource types (runtime, stay in resource files)
export type { UsersResource } from './resources/users.js';
export type { RolesResource } from './resources/roles.js';
export type { CategoriesResource } from './resources/categories.js';
export type { SuppliersResource, SupplierStatus } from './resources/suppliers.js';
export type { ArticlesResource, ArticleStatus, Unit, PriceModel } from './resources/articles.js';
export { UNIT_LABELS, PRICE_MODEL_LABELS } from './resources/articles.js';
export type {
CustomersResource, CustomersResource,
CustomerType,
CustomerStatus,
CustomerPreference,
DeliveryRhythm,
} from './resources/customers.js'; } from './resources/customers.js';
export { CUSTOMER_PREFERENCE_LABELS, DELIVERY_RHYTHM_LABELS } from './resources/customers.js'; export { CUSTOMER_PREFERENCE_LABELS, DELIVERY_RHYTHM_LABELS } from './resources/customers.js';
export type {
StorageLocationsResource,
StorageType,
StorageLocationFilter,
} from './resources/storage-locations.js';
export { STORAGE_TYPE_LABELS } from './resources/storage-locations.js';
export type { RecipesResource, RecipeType, RecipeStatus } from './resources/recipes.js';
export { RECIPE_TYPE_LABELS } from './resources/recipes.js';
import { createApiClient } from './client.js'; import { createApiClient } from './client.js';
import { createAuthResource } from './resources/auth.js'; import { createAuthResource } from './resources/auth.js';
@ -103,6 +118,8 @@ import { createCategoriesResource } from './resources/categories.js';
import { createSuppliersResource } from './resources/suppliers.js'; import { createSuppliersResource } from './resources/suppliers.js';
import { createArticlesResource } from './resources/articles.js'; import { createArticlesResource } from './resources/articles.js';
import { createCustomersResource } from './resources/customers.js'; import { createCustomersResource } from './resources/customers.js';
import { createStorageLocationsResource } from './resources/storage-locations.js';
import { createRecipesResource } from './resources/recipes.js';
import type { TokenProvider } from './token-provider.js'; import type { TokenProvider } from './token-provider.js';
import type { ApiConfig } from '@effigenix/config'; import type { ApiConfig } from '@effigenix/config';
@ -124,6 +141,8 @@ export function createEffigenixClient(
suppliers: createSuppliersResource(axiosClient), suppliers: createSuppliersResource(axiosClient),
articles: createArticlesResource(axiosClient), articles: createArticlesResource(axiosClient),
customers: createCustomersResource(axiosClient), customers: createCustomersResource(axiosClient),
storageLocations: createStorageLocationsResource(axiosClient),
recipes: createRecipesResource(axiosClient),
}; };
} }

View file

@ -10,6 +10,14 @@
*/ */
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import type {
ArticleDTO,
SalesUnitDTO,
CreateArticleRequest,
UpdateArticleRequest,
AddSalesUnitRequest,
UpdateSalesUnitPriceRequest,
} from '@effigenix/types';
export type Unit = 'PIECE_FIXED' | 'KG' | 'HUNDRED_GRAM' | 'PIECE_VARIABLE'; export type Unit = 'PIECE_FIXED' | 'KG' | 'HUNDRED_GRAM' | 'PIECE_VARIABLE';
export type PriceModel = 'FIXED' | 'WEIGHT_BASED'; export type PriceModel = 'FIXED' | 'WEIGHT_BASED';
@ -27,48 +35,14 @@ export const PRICE_MODEL_LABELS: Record<PriceModel, string> = {
WEIGHT_BASED: 'Gewichtsbasiert', WEIGHT_BASED: 'Gewichtsbasiert',
}; };
export interface SalesUnitDTO { export type {
id: string; ArticleDTO,
unit: Unit; SalesUnitDTO,
priceModel: PriceModel; CreateArticleRequest,
price: number; UpdateArticleRequest,
} AddSalesUnitRequest,
UpdateSalesUnitPriceRequest,
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;
}
// ── Resource factory ───────────────────────────────────────────────────────── // ── Resource factory ─────────────────────────────────────────────────────────

View file

@ -7,22 +7,17 @@
*/ */
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import type {
ProductCategoryDTO,
CreateCategoryRequest,
UpdateCategoryRequest,
} from '@effigenix/types';
export interface ProductCategoryDTO { export type {
id: string; ProductCategoryDTO,
name: string; CreateCategoryRequest,
description: string | null; UpdateCategoryRequest,
} };
export interface CreateCategoryRequest {
name: string;
description?: string;
}
export interface UpdateCategoryRequest {
name?: string;
description?: string | null;
}
// ── Resource factory ───────────────────────────────────────────────────────── // ── Resource factory ─────────────────────────────────────────────────────────

View file

@ -12,7 +12,16 @@
*/ */
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import type { AddressDTO, ContactInfoDTO, PaymentTermsDTO } from './suppliers.js'; import type {
CustomerDTO,
DeliveryAddressDTO,
FrameContractDTO,
ContractLineItemDTO,
CreateCustomerRequest,
UpdateCustomerRequest,
AddDeliveryAddressRequest,
SetFrameContractRequest,
} from '@effigenix/types';
export type CustomerType = 'B2B' | 'B2C'; export type CustomerType = 'B2B' | 'B2C';
export type CustomerStatus = 'ACTIVE' | 'INACTIVE'; export type CustomerStatus = 'ACTIVE' | 'INACTIVE';
@ -44,96 +53,16 @@ export const DELIVERY_RHYTHM_LABELS: Record<DeliveryRhythm, string> = {
ON_DEMAND: 'Nach Bedarf', ON_DEMAND: 'Nach Bedarf',
}; };
export interface DeliveryAddressDTO { export type {
label: string; CustomerDTO,
address: AddressDTO; DeliveryAddressDTO,
contactPerson: string | null; FrameContractDTO,
deliveryNotes: string | null; ContractLineItemDTO,
} CreateCustomerRequest,
UpdateCustomerRequest,
export interface ContractLineItemDTO { AddDeliveryAddressRequest,
articleId: string; SetFrameContractRequest,
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[];
}
// ── Resource factory ───────────────────────────────────────────────────────── // ── Resource factory ─────────────────────────────────────────────────────────

View file

@ -0,0 +1,66 @@
/**
* Recipes resource Production BC.
* Endpoints: POST /api/recipes,
* POST /api/recipes/{id}/ingredients,
* DELETE /api/recipes/{id}/ingredients/{ingredientId}
*/
import type { AxiosInstance } from 'axios';
import type {
RecipeDTO,
IngredientDTO,
CreateRecipeRequest,
AddRecipeIngredientRequest,
} from '@effigenix/types';
export type RecipeType = 'RAW_MATERIAL' | 'INTERMEDIATE' | 'FINISHED_PRODUCT';
export type RecipeStatus = 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
export const RECIPE_TYPE_LABELS: Record<RecipeType, string> = {
RAW_MATERIAL: 'Rohstoff',
INTERMEDIATE: 'Halbfabrikat',
FINISHED_PRODUCT: 'Fertigprodukt',
};
export type {
RecipeDTO,
IngredientDTO,
CreateRecipeRequest,
AddRecipeIngredientRequest,
};
// ── Resource factory ─────────────────────────────────────────────────────────
const BASE = '/api/recipes';
export function createRecipesResource(client: AxiosInstance) {
return {
async list(): Promise<RecipeDTO[]> {
const res = await client.get<RecipeDTO[]>(BASE);
return res.data;
},
async getById(id: string): Promise<RecipeDTO> {
const res = await client.get<RecipeDTO>(`${BASE}/${id}`);
return res.data;
},
async create(request: CreateRecipeRequest): Promise<RecipeDTO> {
const res = await client.post<RecipeDTO>(BASE, request);
return res.data;
},
async addIngredient(id: string, request: AddRecipeIngredientRequest): Promise<RecipeDTO> {
const res = await client.post<RecipeDTO>(`${BASE}/${id}/ingredients`, request);
return res.data;
},
async removeIngredient(recipeId: string, ingredientId: string): Promise<RecipeDTO> {
await client.delete(`${BASE}/${recipeId}/ingredients/${ingredientId}`);
const res = await client.get<RecipeDTO>(`${BASE}/${recipeId}`);
return res.data;
},
};
}
export type RecipesResource = ReturnType<typeof createRecipesResource>;

View file

@ -0,0 +1,79 @@
/**
* StorageLocations resource Inventory BC.
* Endpoints: GET/POST /api/inventory/storage-locations,
* PUT /api/inventory/storage-locations/{id},
* PATCH /api/inventory/storage-locations/{id}/activate|deactivate
*/
import type { AxiosInstance } from 'axios';
import type {
StorageLocationDTO,
TemperatureRangeDTO,
CreateStorageLocationRequest,
UpdateStorageLocationRequest,
} from '@effigenix/types';
export type StorageType = 'COLD_ROOM' | 'FREEZER' | 'DRY_STORAGE' | 'DISPLAY_COUNTER' | 'PRODUCTION_AREA';
export const STORAGE_TYPE_LABELS: Record<StorageType, string> = {
COLD_ROOM: 'Kühlraum',
FREEZER: 'Tiefkühler',
DRY_STORAGE: 'Trockenlager',
DISPLAY_COUNTER: 'Vitrine',
PRODUCTION_AREA: 'Produktionsbereich',
};
export type {
StorageLocationDTO,
TemperatureRangeDTO,
CreateStorageLocationRequest,
UpdateStorageLocationRequest,
};
export interface StorageLocationFilter {
storageType?: string;
active?: boolean;
}
// ── Resource factory ─────────────────────────────────────────────────────────
const BASE = '/api/inventory/storage-locations';
export function createStorageLocationsResource(client: AxiosInstance) {
return {
async list(filter?: StorageLocationFilter): Promise<StorageLocationDTO[]> {
const params: Record<string, string> = {};
if (filter?.storageType) params['storageType'] = filter.storageType;
if (filter?.active !== undefined) params['active'] = String(filter.active);
const res = await client.get<StorageLocationDTO[]>(BASE, { params });
return res.data;
},
async getById(id: string): Promise<StorageLocationDTO> {
const res = await client.get<StorageLocationDTO>(`${BASE}/${id}`);
return res.data;
},
async create(request: CreateStorageLocationRequest): Promise<StorageLocationDTO> {
const res = await client.post<StorageLocationDTO>(BASE, request);
return res.data;
},
async update(id: string, request: UpdateStorageLocationRequest): Promise<StorageLocationDTO> {
const res = await client.put<StorageLocationDTO>(`${BASE}/${id}`, request);
return res.data;
},
async activate(id: string): Promise<StorageLocationDTO> {
const res = await client.patch<StorageLocationDTO>(`${BASE}/${id}/activate`);
return res.data;
},
async deactivate(id: string): Promise<StorageLocationDTO> {
const res = await client.patch<StorageLocationDTO>(`${BASE}/${id}/deactivate`);
return res.data;
},
};
}
export type StorageLocationsResource = ReturnType<typeof createStorageLocationsResource>;

View file

@ -10,100 +10,35 @@
*/ */
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import type {
export interface AddressDTO { SupplierDTO,
street: string; AddressDTO,
houseNumber: string | null; ContactInfoDTO,
postalCode: string; PaymentTermsDTO,
city: string; QualityCertificateDTO,
country: string; SupplierRatingDTO,
} CreateSupplierRequest,
UpdateSupplierRequest,
export interface ContactInfoDTO { RateSupplierRequest,
phone: string; AddCertificateRequest,
email: string | null; RemoveCertificateRequest,
contactPerson: string | null; } from '@effigenix/types';
}
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 type SupplierStatus = 'ACTIVE' | 'INACTIVE';
export interface SupplierDTO { export type {
id: string; SupplierDTO,
name: string; AddressDTO,
status: SupplierStatus; ContactInfoDTO,
address: AddressDTO | null; PaymentTermsDTO,
contactInfo: ContactInfoDTO; QualityCertificateDTO,
paymentTerms: PaymentTermsDTO | null; SupplierRatingDTO,
certificates: QualityCertificateDTO[]; CreateSupplierRequest,
rating: SupplierRatingDTO | null; UpdateSupplierRequest,
createdAt: string; RateSupplierRequest,
updatedAt: string; AddCertificateRequest,
} RemoveCertificateRequest,
};
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;
}
// ── Resource factory ───────────────────────────────────────────────────────── // ── Resource factory ─────────────────────────────────────────────────────────

View file

@ -4,45 +4,23 @@
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import { API_PATHS } from '@effigenix/config'; import { API_PATHS } from '@effigenix/config';
import type {
UserDTO,
RoleDTO,
CreateUserRequest,
UpdateUserRequest,
ChangePasswordRequest,
AssignRoleRequest,
} from '@effigenix/types';
export interface UserDTO { export type {
id: string; UserDTO,
username: string; RoleDTO,
email: string; CreateUserRequest,
roles: RoleDTO[]; UpdateUserRequest,
branchId?: string; ChangePasswordRequest,
status: 'ACTIVE' | 'LOCKED'; AssignRoleRequest,
createdAt: string; };
lastLogin?: string;
}
export interface RoleDTO {
id: string;
name: string;
permissions: string[];
}
export interface CreateUserRequest {
username: string;
email: string;
password: string;
roleNames: string[];
branchId?: string;
}
export interface UpdateUserRequest {
email?: string;
branchId?: string;
}
export interface ChangePasswordRequest {
currentPassword: string;
newPassword: string;
}
export interface AssignRoleRequest {
roleName: string;
}
export function createUsersResource(client: AxiosInstance) { export function createUsersResource(client: AxiosInstance) {
return { return {

View file

@ -0,0 +1,16 @@
/**
* Article types
* Re-exports types from the auto-generated OpenAPI schema
*/
import type { components } from './generated/api';
// Response DTOs
export type ArticleDTO = components['schemas']['ArticleResponse'];
export type SalesUnitDTO = components['schemas']['SalesUnitResponse'];
// Request types
export type CreateArticleRequest = components['schemas']['CreateArticleRequest'];
export type UpdateArticleRequest = components['schemas']['UpdateArticleRequest'];
export type AddSalesUnitRequest = components['schemas']['AddSalesUnitRequest'];
export type UpdateSalesUnitPriceRequest = components['schemas']['UpdateSalesUnitPriceRequest'];

View file

@ -0,0 +1,13 @@
/**
* Product Category types
* Re-exports types from the auto-generated OpenAPI schema
*/
import type { components } from './generated/api';
// Response DTOs
export type ProductCategoryDTO = components['schemas']['ProductCategoryResponse'];
// Request types
export type CreateCategoryRequest = components['schemas']['CreateProductCategoryRequest'];
export type UpdateCategoryRequest = components['schemas']['UpdateProductCategoryRequest'];

View file

@ -0,0 +1,19 @@
/**
* Customer types
* Re-exports types from the auto-generated OpenAPI schema
*/
import type { components } from './generated/api';
// Response DTOs
export type CustomerDTO = components['schemas']['CustomerResponse'];
export type DeliveryAddressDTO = components['schemas']['DeliveryAddressResponse'];
export type FrameContractDTO = components['schemas']['FrameContractResponse'];
export type ContractLineItemDTO = components['schemas']['ContractLineItemResponse'];
// Request types
export type CreateCustomerRequest = components['schemas']['CreateCustomerRequest'];
export type UpdateCustomerRequest = components['schemas']['UpdateCustomerRequest'];
export type AddDeliveryAddressRequest = components['schemas']['AddDeliveryAddressRequest'];
export type SetFrameContractLineItem = components['schemas']['LineItem'];
export type SetFrameContractRequest = components['schemas']['SetFrameContractRequest'];

File diff suppressed because it is too large Load diff

View file

@ -11,6 +11,12 @@ export * from './user';
export * from './role'; export * from './role';
export * from './common'; export * from './common';
export * from './enums'; export * from './enums';
export * from './supplier';
export * from './category';
export * from './article';
export * from './customer';
export * from './inventory';
export * from './production';
// Re-export generated types for advanced usage // Re-export generated types for advanced usage
export type { components, paths } from './generated/api'; export type { components, paths } from './generated/api';

View file

@ -0,0 +1,14 @@
/**
* Inventory types
* Re-exports types from the auto-generated OpenAPI schema
*/
import type { components } from './generated/api';
// Response DTOs
export type StorageLocationDTO = components['schemas']['StorageLocationResponse'];
export type TemperatureRangeDTO = components['schemas']['TemperatureRangeResponse'];
// Request types
export type CreateStorageLocationRequest = components['schemas']['CreateStorageLocationRequest'];
export type UpdateStorageLocationRequest = components['schemas']['UpdateStorageLocationRequest'];

View file

@ -0,0 +1,14 @@
/**
* Production types
* Re-exports types from the auto-generated OpenAPI schema
*/
import type { components } from './generated/api';
// Response DTOs
export type RecipeDTO = components['schemas']['RecipeResponse'];
export type IngredientDTO = components['schemas']['IngredientResponse'];
// Request types
export type CreateRecipeRequest = components['schemas']['CreateRecipeRequest'];
export type AddRecipeIngredientRequest = components['schemas']['AddRecipeIngredientRequest'];

View file

@ -0,0 +1,21 @@
/**
* Supplier types
* Re-exports types from the auto-generated OpenAPI schema
*/
import type { components } from './generated/api';
// Response DTOs
export type SupplierDTO = components['schemas']['SupplierResponse'];
export type AddressDTO = components['schemas']['AddressResponse'];
export type ContactInfoDTO = components['schemas']['ContactInfoResponse'];
export type PaymentTermsDTO = components['schemas']['PaymentTermsResponse'];
export type QualityCertificateDTO = components['schemas']['QualityCertificateResponse'];
export type SupplierRatingDTO = components['schemas']['SupplierRatingResponse'];
// Request types
export type CreateSupplierRequest = components['schemas']['CreateSupplierRequest'];
export type UpdateSupplierRequest = components['schemas']['UpdateSupplierRequest'];
export type RateSupplierRequest = components['schemas']['RateSupplierRequest'];
export type AddCertificateRequest = components['schemas']['AddCertificateRequest'];
export type RemoveCertificateRequest = components['schemas']['RemoveCertificateRequest'];