Abgabe
This commit is contained in:
commit
fefed588b4
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
data/*.db
|
||||||
|
data/*.db-journal
|
||||||
|
|
||||||
|
backend/node_modules
|
||||||
|
backend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/dist
|
||||||
|
frontend/dist-ssr
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
157
README.md
Normal file
157
README.md
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
# Order Management System (OMS)
|
||||||
|
|
||||||
|
Ein modernes Order Management System für konfigurierbare Produkte, entwickelt als White-Label-Lösung für On-Premise-Hosting.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
Dieses System wurde für eine Coding Challenge entwickelt und zeigt:
|
||||||
|
|
||||||
|
- RESTful API mit Express.js und TypeScript
|
||||||
|
- Eingabevalidierung mit Zod
|
||||||
|
- SQLite3 Datenbank mit automatischer Initialisierung
|
||||||
|
- Event-Driven Architecture (simuliert für Demo)
|
||||||
|
- React Frontend mit Material UI
|
||||||
|
- Docker-Container für einfaches Deployment
|
||||||
|
- Unit-Tests mit Jest
|
||||||
|
|
||||||
|
## Architektur
|
||||||
|
|
||||||
|
Siehe:
|
||||||
|
- [ARCHITEKTUR.md](./ARCHITEKTUR.md) - Technische Übersicht mit Diagrammen
|
||||||
|
- [docs/aufgabe1-architektur.md](./docs/aufgabe1-architektur.md) - Ausführliche Antwort zu Aufgabe 1 mit Begründungen
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm
|
||||||
|
- Docker (optional)
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Alle Dependencies installieren
|
||||||
|
npm run install:all
|
||||||
|
|
||||||
|
# Datenbank-Ordner wird automatisch erstellt beim ersten Start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Entwicklung
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal 1: Backend
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Terminal 2: Frontend
|
||||||
|
npm run dev:frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
- Backend: http://localhost:3990
|
||||||
|
- Frontend: http://localhost:3980
|
||||||
|
|
||||||
|
### Mit Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## API Dokumentation
|
||||||
|
|
||||||
|
### GET /orders
|
||||||
|
|
||||||
|
Ruft alle Bestellungen ab.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": [...],
|
||||||
|
"count": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /orders
|
||||||
|
|
||||||
|
Erstellt eine neue Bestellung.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"customerId": "CUST1",
|
||||||
|
"customerName": "Lui Denkwerk",
|
||||||
|
"customerEmail": "lui@test.com",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"productId": "TISCH1",
|
||||||
|
"quantity": 1,
|
||||||
|
"price": 29.99
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shippingAddress": {
|
||||||
|
"street": "yxcstraße 1",
|
||||||
|
"city": "Grevenbroich",
|
||||||
|
"postalCode": "41515",
|
||||||
|
"country": "DE"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"id": 1,
|
||||||
|
"orderNumber": "Order-1234567890-123",
|
||||||
|
"totalAmount": 299.99,
|
||||||
|
"status": "pending",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- Express.js mit TypeScript
|
||||||
|
- Zod für Runtime-Validierung
|
||||||
|
- SQLite3 (zero-config, ideal für On-Premise)
|
||||||
|
- Event-System (simuliert, in Production würde ich RabbitMQ oder Kafka nutzen)
|
||||||
|
- Jest Tests
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- React 18 mit TypeScript
|
||||||
|
- Material UI 5 für schnelle UI-Entwicklung
|
||||||
|
- Vite als Build-Tool
|
||||||
|
- Client-Side Validierung
|
||||||
|
- Error-Handling
|
||||||
|
- Zwei Views: Bestellung erstellen + Bestellübersicht
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
- Docker & Docker Compose
|
||||||
|
- Nginx als Reverse Proxy für Frontend
|
||||||
|
- Isolierte Container für Backend/Frontend
|
||||||
|
|
||||||
|
## Technische Entscheidungen
|
||||||
|
|
||||||
|
**SQLite statt PostgreSQL**: Für die Challenge ausreichend, leicht zu deployen, keine externe DB nötig. In Production würde ich auf PostgreSQL wechseln.
|
||||||
|
|
||||||
|
**Event-Simulation**: Events werden mit Console-Logs simuliert.
|
||||||
|
|
||||||
|
**Keine Auth**: Nicht Teil der Challenge. In Production JWT oder OAuth implementieren.
|
||||||
|
|
||||||
|
**Material UI**: Schnelle UI-Entwicklung mit professionellem Look ohne viel CSS schreiben zu müssen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
14
backend/Dockerfile
Normal file
14
backend/Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3990
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
|
|
||||||
0
backend/data/.gitkeep
Normal file
0
backend/data/.gitkeep
Normal file
11
backend/jest.config.js
Normal file
11
backend/jest.config.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/*.test.ts'],
|
||||||
|
collectCoverageFrom: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
'!src/**/*.test.ts',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
6845
backend/package-lock.json
generated
Normal file
6845
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
backend/package.json
Normal file
35
backend/package.json
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "oms-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "OMS Backend",
|
||||||
|
"main": "dist/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/server.js",
|
||||||
|
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "LBerardi",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"sqlite3": "^5.1.7",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/node": "^20.10.7",
|
||||||
|
"@types/jest": "^29.5.11",
|
||||||
|
"@types/sqlite3": "^3.1.11",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"ts-node-dev": "^2.0.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"ts-jest": "^29.1.1",
|
||||||
|
"supertest": "^6.3.3",
|
||||||
|
"@types/supertest": "^6.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
133
backend/src/database.ts
Normal file
133
backend/src/database.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import sqlite3 from 'sqlite3';
|
||||||
|
import { Order } from './types';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const DB_DIR = './data';
|
||||||
|
const DB_PATH = path.join(DB_DIR, 'orders.db');
|
||||||
|
|
||||||
|
// Verzeichnis erstellen falls nicht vorhanden
|
||||||
|
if (!fs.existsSync(DB_DIR)) {
|
||||||
|
fs.mkdirSync(DB_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(DB_PATH, (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('DB Fehler:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function initDatabase(): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
orderNumber TEXT UNIQUE NOT NULL,
|
||||||
|
customerId TEXT NOT NULL,
|
||||||
|
customerName TEXT NOT NULL,
|
||||||
|
customerEmail TEXT NOT NULL,
|
||||||
|
items TEXT NOT NULL,
|
||||||
|
shippingAddress TEXT NOT NULL,
|
||||||
|
totalAmount REAL NOT NULL,
|
||||||
|
status TEXT DEFAULT 'pending',
|
||||||
|
createdAt TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrder(order: Order): Promise<Order> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO orders (orderNumber, customerId, customerName, customerEmail, items, shippingAddress, totalAmount, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Bestellnummer generieren
|
||||||
|
const orderNumber = `Order-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
query,
|
||||||
|
[
|
||||||
|
orderNumber,
|
||||||
|
order.customerId,
|
||||||
|
order.customerName,
|
||||||
|
order.customerEmail,
|
||||||
|
JSON.stringify(order.items),
|
||||||
|
JSON.stringify(order.shippingAddress),
|
||||||
|
order.totalAmount,
|
||||||
|
'pending'
|
||||||
|
],
|
||||||
|
function(err) {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
...order,
|
||||||
|
id: this.lastID,
|
||||||
|
orderNumber: orderNumber,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrder(id: number): Promise<Order | null> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.get('SELECT * FROM orders WHERE id = ?', [id], (err, row: any) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else if (!row) {
|
||||||
|
resolve(null);
|
||||||
|
} else {
|
||||||
|
resolve({
|
||||||
|
id: row.id,
|
||||||
|
orderNumber: row.orderNumber,
|
||||||
|
customerId: row.customerId,
|
||||||
|
customerName: row.customerName,
|
||||||
|
customerEmail: row.customerEmail,
|
||||||
|
items: JSON.parse(row.items),
|
||||||
|
shippingAddress: JSON.parse(row.shippingAddress),
|
||||||
|
totalAmount: row.totalAmount,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.createdAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAllOrders(): Promise<Order[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
db.all('SELECT * FROM orders ORDER BY createdAt DESC', [], (err, rows: any[]) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
const orders = rows.map(row => ({
|
||||||
|
id: row.id,
|
||||||
|
orderNumber: row.orderNumber,
|
||||||
|
customerId: row.customerId,
|
||||||
|
customerName: row.customerName,
|
||||||
|
customerEmail: row.customerEmail,
|
||||||
|
items: JSON.parse(row.items),
|
||||||
|
shippingAddress: JSON.parse(row.shippingAddress),
|
||||||
|
totalAmount: row.totalAmount,
|
||||||
|
status: row.status,
|
||||||
|
createdAt: row.createdAt
|
||||||
|
}));
|
||||||
|
resolve(orders);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { db };
|
||||||
105
backend/src/server.ts
Normal file
105
backend/src/server.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import path from 'path';
|
||||||
|
import { orderSchema } from './validation';
|
||||||
|
import { createOrder } from './database';
|
||||||
|
import { emitOrderCreated } from './simulate/events';
|
||||||
|
import { initDatabase } from './database';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3990;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// GET /orders - Alle Bestellungen abrufen
|
||||||
|
app.get('/orders', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { getAllOrders } = require('./database');
|
||||||
|
const orders = await getAllOrders();
|
||||||
|
|
||||||
|
res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: orders,
|
||||||
|
count: orders.length
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Fehler beim Abrufen der Bestellungen:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Interner Serverfehler'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /orders - Erstellt eine neue Bestellung
|
||||||
|
app.post('/orders', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const validatedData = orderSchema.parse(req.body);
|
||||||
|
|
||||||
|
// Gesamtbetrag berechnen
|
||||||
|
const totalAmount = validatedData.items.reduce(
|
||||||
|
(sum, item) => sum + (item.price * item.quantity),
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
const order = await createOrder({
|
||||||
|
...validatedData,
|
||||||
|
totalAmount,
|
||||||
|
status: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Events auslösen
|
||||||
|
emitOrderCreated(order);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
data: order,
|
||||||
|
message: 'Bestellung erfolgreich erstellt'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.name === 'ZodError') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Validierungsfehler',
|
||||||
|
details: error.errors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Fehler beim Erstellen der Bestellung:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Interner Serverfehler'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
|
||||||
|
console.error(err.stack);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Etwas ist schiefgelaufen!'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Datenbank initialisieren und Server starten
|
||||||
|
async function startServer() {
|
||||||
|
try {
|
||||||
|
await initDatabase();
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Server läuft auf http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DB Init fehlgeschlagen:', err);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server nur starten, wenn nicht im Test-Modus
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
23
backend/src/simulate/events.ts
Normal file
23
backend/src/simulate/events.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Order } from '../types';
|
||||||
|
|
||||||
|
// Event system simulation
|
||||||
|
export function emitOrderCreated(order: Order): void {
|
||||||
|
console.log('Event: Order Created', order.orderNumber);
|
||||||
|
|
||||||
|
// Simulate different system
|
||||||
|
simulateShipping(order);
|
||||||
|
simulateWarehouse(order);
|
||||||
|
simulateEmail(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateShipping(order: Order): void {
|
||||||
|
console.log(`Shipping: New order ${order.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateWarehouse(order: Order): void {
|
||||||
|
console.log(`Warehouse: Prepare items for order ${order.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function simulateEmail(order: Order): void {
|
||||||
|
console.log(`Email: Sending confirmation to ${order.customerEmail}`);
|
||||||
|
}
|
||||||
90
backend/src/test/server.test.ts
Normal file
90
backend/src/test/server.test.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import request from 'supertest';
|
||||||
|
import app from '../server';
|
||||||
|
import { initDatabase } from '../database';
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await initDatabase();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /orders', () => {
|
||||||
|
it('should create a new order', async () => {
|
||||||
|
const orderData = {
|
||||||
|
customerId: 'CUST1',
|
||||||
|
customerName: 'Lui Denkwerk',
|
||||||
|
customerEmail: 'lui@test.com',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productId: 'TEST1',
|
||||||
|
quantity: 2,
|
||||||
|
price: 29.99
|
||||||
|
}
|
||||||
|
],
|
||||||
|
shippingAddress: {
|
||||||
|
street: 'teststraße 123',
|
||||||
|
city: 'Grevenbroich',
|
||||||
|
postalCode: '41515',
|
||||||
|
country: 'DE'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/orders')
|
||||||
|
.send(orderData)
|
||||||
|
.expect(201);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(true);
|
||||||
|
expect(response.body.data).toHaveProperty('id');
|
||||||
|
expect(response.body.data.totalAmount).toBe(59.98);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid email', async () => {
|
||||||
|
const invalidData = {
|
||||||
|
customerId: 'CUST1',
|
||||||
|
customerName: 'Lui Denkwerk',
|
||||||
|
customerEmail: 'nomailstring',
|
||||||
|
items: [{ productId: 'TEST1', quantity: 1, price: 29.99 }],
|
||||||
|
shippingAddress: {
|
||||||
|
street: 'teststraße 123',
|
||||||
|
city: 'Grevenbroich',
|
||||||
|
postalCode: '41515',
|
||||||
|
country: 'DE'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/orders')
|
||||||
|
.send(invalidData)
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should require all fields', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/orders')
|
||||||
|
.send({})
|
||||||
|
.expect(400);
|
||||||
|
|
||||||
|
expect(response.body.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject negative quantity', async () => {
|
||||||
|
const invalidData = {
|
||||||
|
customerId: 'CUST1',
|
||||||
|
customerName: 'Lui Denkwerk',
|
||||||
|
customerEmail: 'lui@test.com',
|
||||||
|
items: [{ productId: 'NOTISCH1', quantity: -1, price: 29.99 }],
|
||||||
|
shippingAddress: {
|
||||||
|
street: 'teststraße 123',
|
||||||
|
city: 'Grevenbroich',
|
||||||
|
postalCode: '41515',
|
||||||
|
country: 'DE'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.post('/orders')
|
||||||
|
.send(invalidData)
|
||||||
|
.expect(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
backend/src/types.ts
Normal file
25
backend/src/types.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
export interface OrderItem {
|
||||||
|
productId: string;
|
||||||
|
quantity: number;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShippingAddress {
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Order {
|
||||||
|
id?: number;
|
||||||
|
orderNumber?: string;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
items: OrderItem[];
|
||||||
|
shippingAddress: ShippingAddress;
|
||||||
|
totalAmount: number;
|
||||||
|
status?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
23
backend/src/validation.ts
Normal file
23
backend/src/validation.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Validation schemas mit Zod
|
||||||
|
export const orderSchema = z.object({
|
||||||
|
customerId: z.string().min(1, 'Kunden-ID ist erforderlich'),
|
||||||
|
customerName: z.string().min(2, 'Name zu kurz'),
|
||||||
|
customerEmail: z.string().email('Ungültige E-Mail'),
|
||||||
|
items: z.array(
|
||||||
|
z.object({
|
||||||
|
productId: z.string().min(1),
|
||||||
|
quantity: z.number().int().positive(),
|
||||||
|
price: z.number().positive(),
|
||||||
|
})
|
||||||
|
).min(1, 'Mindestens ein Artikel erforderlich'),
|
||||||
|
shippingAddress: z.object({
|
||||||
|
street: z.string().min(1),
|
||||||
|
city: z.string().min(1),
|
||||||
|
postalCode: z.string().min(1),
|
||||||
|
country: z.string().min(2),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OrderInput = z.infer<typeof orderSchema>;
|
||||||
18
backend/tsconfig.json
Normal file
18
backend/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2020"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
1
data/.gitkeep
Normal file
1
data/.gitkeep
Normal file
@ -0,0 +1 @@
|
|||||||
|
# SQLite database will be created here
|
||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3990:3990"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3990
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "3980:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
115
docs/ANLEITUNG.md
Normal file
115
docs/ANLEITUNG.md
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# Setup Anleitung
|
||||||
|
|
||||||
|
Quick Start Guide für das OMS
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm
|
||||||
|
- Docker (optional)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Variante 1: Docker (empfohlen)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
Das war's!
|
||||||
|
- Backend läuft auf http://localhost:3990
|
||||||
|
- Frontend läuft auf http://localhost:3980
|
||||||
|
|
||||||
|
### Variante 2: Lokal
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dependencies installieren
|
||||||
|
npm run install:all
|
||||||
|
|
||||||
|
# In zwei separaten Terminals:
|
||||||
|
npm run dev # Backend (Terminal 1)
|
||||||
|
npm run dev:frontend # Frontend (Terminal 2)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Testen
|
||||||
|
|
||||||
|
### Mit Browser
|
||||||
|
1. Frontend öffnen: http://localhost:3980
|
||||||
|
2. Formular ausfüllen
|
||||||
|
3. Bestellung abschicken
|
||||||
|
|
||||||
|
### Mit curl
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:3990/orders \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"customerId": "DW001",
|
||||||
|
"customerName": "Lui Denkwerk",
|
||||||
|
"customerEmail": "lui@test.de",
|
||||||
|
"items": [{
|
||||||
|
"productId": "TISCH1",
|
||||||
|
"quantity": 1,
|
||||||
|
"price": 200.80
|
||||||
|
}],
|
||||||
|
"shippingAddress": {
|
||||||
|
"street": "Hauptstr. 1",
|
||||||
|
"city": "Grevenbroich",
|
||||||
|
"postalCode": "41515",
|
||||||
|
"country": "DE"
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests ausführen
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Port schon belegt
|
||||||
|
Backend läuft auf Port 3990
|
||||||
|
Frontend auf 3980
|
||||||
|
|
||||||
|
Ändern in:
|
||||||
|
- `backend/src/server.ts` -> PORT Konstante
|
||||||
|
- `frontend/vite.config.ts` -> proxy config
|
||||||
|
|
||||||
|
### DB Fehler
|
||||||
|
Die SQLite DB wird automatisch unter `./data/orders.db` erstellt.
|
||||||
|
Falls Probleme: `rm -rf data/` und neu starten
|
||||||
|
|
||||||
|
### Docker Probleme
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker compose build --no-cache
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wichtige Hinweise
|
||||||
|
|
||||||
|
- SQLite DB wird beim ersten Start automatisch angelegt
|
||||||
|
- Events werden in der Console geloggt (Simulation)
|
||||||
|
- Backend Port ist 3990
|
||||||
|
- Frontend Proxy leitet `/orders` requests an Backend weiter
|
||||||
|
- In Production PostgreSQL statt SQLite verwenden
|
||||||
|
|
||||||
|
## Nächste Schritte
|
||||||
|
|
||||||
|
Für Production:
|
||||||
|
1. PostgreSQL Setup
|
||||||
|
2. Environment Variables (`.env`)
|
||||||
|
3. JWT Auth implementieren
|
||||||
|
4. Message Queue für Events
|
||||||
|
5. Monitoring Setup
|
||||||
|
6. CI/CD Pipeline
|
||||||
|
|
||||||
|
---
|
||||||
141
docs/ARCHITEKTUR.md
Normal file
141
docs/ARCHITEKTUR.md
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
# Architektur-Übersicht
|
||||||
|
|
||||||
|
Order Management System - Systemdesign und Entscheidungen
|
||||||
|
|
||||||
|
## Komponenten
|
||||||
|
|
||||||
|
### 1. API Layer (Backend)
|
||||||
|
- **Framework**: Express.js mit TypeScript
|
||||||
|
- **Port**: 3990
|
||||||
|
- **Validierung**: Zod für Runtime-Checks
|
||||||
|
- **API-Endpunkte**:
|
||||||
|
- `POST /orders` - Neue Bestellung erstellen
|
||||||
|
- `GET /health` - Health Check
|
||||||
|
|
||||||
|
### 2. Datenhaltung
|
||||||
|
- **Datenbank**: SQLite3
|
||||||
|
- **Pfad**: `./data/orders.db`
|
||||||
|
- **Schema**:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE orders (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
orderNumber TEXT UNIQUE,
|
||||||
|
customerId TEXT,
|
||||||
|
customerName TEXT,
|
||||||
|
customerEmail TEXT,
|
||||||
|
items TEXT, -- JSON to Convert
|
||||||
|
shippingAddress TEXT, -- JSON to Convert
|
||||||
|
totalAmount REAL,
|
||||||
|
status TEXT,
|
||||||
|
createdAt TEXT
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warum SQLite?**
|
||||||
|
- Zero-config, einfach zu deployen
|
||||||
|
- Keine externe DB nötig für On-Premise
|
||||||
|
- Für Production würde ich auf PostgreSQL wechseln
|
||||||
|
|
||||||
|
### 3. Event-System
|
||||||
|
Simuliert mit Console-Logs. Events werden an folgende Systeme geschickt:
|
||||||
|
- Versand-Service
|
||||||
|
- Lager-System
|
||||||
|
- E-Mail-Service
|
||||||
|
|
||||||
|
### 4. Frontend
|
||||||
|
- **Framework**: React 18 + TypeScript
|
||||||
|
- **UI Library**: Material UI 5
|
||||||
|
- **Build**: Vite
|
||||||
|
|
||||||
|
|
||||||
|
## Deployment-Strategie
|
||||||
|
|
||||||
|
### Docker Compose
|
||||||
|
Zwei Container:
|
||||||
|
1. **Backend** (Node.js)
|
||||||
|
2. **Frontend** (Nginx)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### On-Premise Deployment
|
||||||
|
1. `docker compose up` auf Kundenserver
|
||||||
|
2. SQLite DB im Volume gemounted
|
||||||
|
3. Kein externes Setup nötig
|
||||||
|
|
||||||
|
### Skalierung (für später)
|
||||||
|
- **Horizontal**: Mehrere Backend-Instanzen mit Load Balancer
|
||||||
|
- **DB**: PostgreSQL
|
||||||
|
- **Cache**: Redis für Sessions/Performance
|
||||||
|
|
||||||
|
## Sicherheit
|
||||||
|
|
||||||
|
### Aktuell implementiert:
|
||||||
|
- Input-Validierung mit Zod
|
||||||
|
- CORS aktiviert
|
||||||
|
- SQL Injection durch Prepared Statements verhindert
|
||||||
|
|
||||||
|
### Für Production:
|
||||||
|
- [ ] JWT Authentication
|
||||||
|
- [ ] Rate Limiting (express-rate-limit)
|
||||||
|
- [ ] API Keys für Service-to-Service (X-API-Key)
|
||||||
|
|
||||||
|
## Monitoring & Logging
|
||||||
|
|
||||||
|
### Aktuell:
|
||||||
|
- Console Logs
|
||||||
|
- Error Stack Traces
|
||||||
|
|
||||||
|
## Fehlerhandling
|
||||||
|
|
||||||
|
### API-Level
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// validation
|
||||||
|
// business logic
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'ZodError') {
|
||||||
|
return 400 // Bad Request
|
||||||
|
}
|
||||||
|
return 500 // Internal Error
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### DB-Level
|
||||||
|
- Promises mit try/catch
|
||||||
|
- Graceful degradation
|
||||||
|
- Transaction Support (für später)
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- Axios error handling
|
||||||
|
- User feedback mit Material UI Alerts
|
||||||
|
- Form validation
|
||||||
|
|
||||||
|
## Design-Entscheidungen
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
**Pro**: Type Safety, Enteprise Version von Javascript :P
|
||||||
|
**Contra**: Etwas mehr Setup
|
||||||
|
|
||||||
|
### SQLite vs PostgreSQL
|
||||||
|
**Gewählt**: SQLite für einfaches Setup
|
||||||
|
**Grund**: Challenge-Kontext, On-Premise ohne Komplexität
|
||||||
|
**Migration**: In Production auf PostgreSQL umstellen
|
||||||
|
|
||||||
|
### Material UI vs Custom CSS
|
||||||
|
**Gewählt**: Material UI
|
||||||
|
**Grund**: Schnelle Entwicklung, professionelles Aussehen
|
||||||
|
|
||||||
|
### Event-Simulation
|
||||||
|
**Aktuell**: Console Logs
|
||||||
|
**Grund**: ausreichend für aufgabe
|
||||||
|
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
- Unit Tests mit Jest
|
||||||
|
- Integration Tests mit Supertest
|
||||||
|
- Mocked DB für schnelle Tests
|
||||||
BIN
docs/aufgabe1-1.drawio.pdf
Normal file
BIN
docs/aufgabe1-1.drawio.pdf
Normal file
Binary file not shown.
235
docs/aufgabe1-architektur.md
Normal file
235
docs/aufgabe1-architektur.md
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
# Aufgabe 1 - Architekturentwurf
|
||||||
|
|
||||||
|
## Überblick
|
||||||
|
|
||||||
|
Ich hab mich für eine relativ klassische Architektur entschieden. Nichts fancy, aber sollte funktionieren. Der Gedanke war ein System zu bauen das:
|
||||||
|
- Beim Kunden einfach deployed werden kann (wer hat schon Lust auf kompliziertes Setup)
|
||||||
|
- Nicht overengineered ist (hab nur 4-6 Stunden)
|
||||||
|
- Aber trotzdem solid genug aussieht um damit zu arbeiten
|
||||||
|
|
||||||
|
## 1. Komponenten
|
||||||
|
|
||||||
|
### API Layer
|
||||||
|
**Was:** Express.js Backend mit TypeScript
|
||||||
|
**Warum Express?** Ehrlich gesagt weil ich's gut kenne. Hätte auch NestJS nehmen können aber das wäre für die Zeit zu viel gewesen. Express ist straightforward, es gibt Lösungen für alles auf StackOverflow, und ich kann schnell produktiv sein.
|
||||||
|
|
||||||
|
**Port:** 3990
|
||||||
|
|
||||||
|
Die API ist simpel:
|
||||||
|
- `POST /orders` - Hauptfunktion
|
||||||
|
- `GET /orders` - Hab ich als Bonus dazugemacht weil man ja die Orders auch sehen will
|
||||||
|
|
||||||
|
### Datenbank
|
||||||
|
**Was:** SQLite3
|
||||||
|
**Warum?** Ok, das war eine bewusste Entscheidung.
|
||||||
|
- Keine DB-Installation nötig
|
||||||
|
- Eine Datei, fertig
|
||||||
|
- Backup ist ein simple file copy
|
||||||
|
|
||||||
|
|
||||||
|
**Schema:**
|
||||||
|
Relativ simpel gehalten. Order hat die basics, Items und Address hab ich als JSON gespeichert. Ich weiß, manche Leute mögen kein JSON in der DB, aber für sowas ist's meiner Meinung nach ok - wir machen keine komplexen Joins darauf und es bleibt flexibel.
|
||||||
|
|
||||||
|
### Event-System
|
||||||
|
**Was:** Console Logs (Simulation)
|
||||||
|
**Warum so basic?** Weil ich zeigen wollte dass ich verstehe wie's funktionieren sollte, ohne jetzt ne ganze Message Queue aufzusetzen. In einem echten Projekt würde ich wahrscheinlich RabbitMQ nehmen (hab damit schon gearbeitet) oder wenn's viel Traffic ist Kafka, aber das ist dann halt ein anderes Level.
|
||||||
|
|
||||||
|
Das Prinzip ist klar: Order wird erstellt → Event wird gefeuert → andere Services können reagieren. Momentan logge ich nur:
|
||||||
|
- "Shipping System: neue Order"
|
||||||
|
- "Warehouse: Artikel vorbereiten"
|
||||||
|
- "Email: Bestätigung schicken"
|
||||||
|
|
||||||
|
Reicht glaub ich um die Idee rüberzubringen.
|
||||||
|
|
||||||
|
### Validierung
|
||||||
|
**Was:** Zod
|
||||||
|
**Warum?** Hab's in einem Tutorial gesehen und fand's gut. Du kriegst Runtime-Validation und die TypeScript-Types automatisch. Besser als alles manuell zu checken. Die Syntax ist auch relativ clean.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
**Was:** React + Material UI + Vite
|
||||||
|
**Warum dieser Stack?**
|
||||||
|
- React weil ich's kann und's standard ist
|
||||||
|
- Material UI weil ich nicht alles selbst stylen wollte - in 30 min hab ich damit ein ok aussehendes UI
|
||||||
|
- Vite weil's schneller startet als Create React App und ich nicht ewig auf den dev server warten will
|
||||||
|
|
||||||
|
Nicht die perfekte Wahl für jedes Projekt aber für die Challenge vollkommen ok.
|
||||||
|
|
||||||
|
### Authentifizierung
|
||||||
|
**Was:** Nicht implementiert
|
||||||
|
**Warum?** Stand nicht in den Requirements und hätte echt Zeit gefressen. Wenn ich's machen müsste würde ich wahrscheinlich JWT nehmen, hab damit in anderen Projekten schon gearbeitet. Access token + refresh token pattern, standard halt.
|
||||||
|
|
||||||
|
## 2. Datenfluss beim Erstellen einer Bestellung
|
||||||
|
|
||||||
|
So läuft's ab (hoffe ich hab nichts vergessen):
|
||||||
|
|
||||||
|
```
|
||||||
|
1. User füllt Form aus
|
||||||
|
- React validiert schonmal das Basics (ist Email format ok, etc.)
|
||||||
|
|
||||||
|
2. Submit → POST /orders ans Backend
|
||||||
|
- CORS ist allowed (hab ich configured)
|
||||||
|
|
||||||
|
3. Backend: Zod Validation
|
||||||
|
- Checkt alle Felder
|
||||||
|
- Items array nicht leer?
|
||||||
|
- Prices sind numbers?
|
||||||
|
- etc.
|
||||||
|
- Wenn was fehlt → 400 zurück
|
||||||
|
|
||||||
|
4. Business Logic
|
||||||
|
- Ich rechne den total amount aus (reduce über items)
|
||||||
|
- Generiere eine Order Number (hab einfach timestamp + random number genommen)
|
||||||
|
- Status = 'pending'
|
||||||
|
|
||||||
|
5. Database Insert
|
||||||
|
- SQLite INSERT
|
||||||
|
- Items/Address werden zu JSON gestringified
|
||||||
|
- Wenn das fehlschlägt → 500 error
|
||||||
|
|
||||||
|
6. Events triggern
|
||||||
|
- Console.logs für die verschiedenen Services
|
||||||
|
- In echt würden hier Messages in Queues gehen
|
||||||
|
|
||||||
|
7. Response
|
||||||
|
- 201 Created
|
||||||
|
- Komplettes Order Object zurück
|
||||||
|
- Frontend zeigt Success Message
|
||||||
|
|
||||||
|
8. Frontend reagiert
|
||||||
|
- Form wird resettet
|
||||||
|
- Success Alert erscheint
|
||||||
|
- (Optional könnte man zu Orders List redirecten)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dauert normalerweise unter 100ms weil alles lokal ist.
|
||||||
|
|
||||||
|
## 3. Deployment-Strategie
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
Ich hab Docker Compose genommen weil:
|
||||||
|
- Ich's kenne und schnell damit bin
|
||||||
|
- Funktioniert überall gleich (Mac, Linux, Windows)
|
||||||
|
- Kunde muss nur `docker compose up` machen
|
||||||
|
- Keine Abhängigkeits-Konflikte
|
||||||
|
|
||||||
|
**Setup:**
|
||||||
|
2 Container - Backend und Frontend. Backend ist Node, Frontend wird mit nginx ausgeliefert. Hab multi-stage build verwendet um die Images kleiner zu halten.
|
||||||
|
|
||||||
|
### On-Premise beim Kunden
|
||||||
|
Da's beim Kunden on-premise laufen soll hab ich's simpel gehalten:
|
||||||
|
|
||||||
|
**Easy way:**
|
||||||
|
```bash
|
||||||
|
git clone
|
||||||
|
docker compose up -d
|
||||||
|
# done
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Kunde braucht nur Docker installiert zu haben.
|
||||||
|
|
||||||
|
**Wenn's professional sein soll:**
|
||||||
|
- Images in private Registry
|
||||||
|
- Kunde zieht nur die Images (kein source code)
|
||||||
|
- .env file für Config
|
||||||
|
- Backup-Script für die DB
|
||||||
|
|
||||||
|
### CI/CD
|
||||||
|
Hab ich nicht implementiert (war ja nicht gefordert) aber wenn ich's machen würde:
|
||||||
|
- GitHub Actions (hab damit schon gearbeitet), workflow (gitea)
|
||||||
|
- Tests laufen lassen
|
||||||
|
- Images bauen
|
||||||
|
- Zu Registry pushen
|
||||||
|
- Optional auto-deploy zu staging
|
||||||
|
|
||||||
|
## 4. Skalierbarkeit, Sicherheit, Monitoring & Fehlerhandling
|
||||||
|
|
||||||
|
### Skalierbarkeit
|
||||||
|
|
||||||
|
**Aktueller Stand:** Einzelne Instanz, sollte für kleinere Kunden ok sein
|
||||||
|
|
||||||
|
**Wenn's größer wird:**
|
||||||
|
|
||||||
|
Ehrlich gesagt hab ich da keine super viel Erfahrung mit high-scale Systems, aber was ich mir vorstellen würde:
|
||||||
|
|
||||||
|
1. **Mehr Backend-Instanzen**
|
||||||
|
- Load Balancer davor (nginx kann das)
|
||||||
|
- SQLite wird dann zum Problem, also PostgreSQL
|
||||||
|
- Connection pooling nicht vergessen
|
||||||
|
|
||||||
|
2. **Database Optimization**
|
||||||
|
- Indexes auf die richtigen Felder (orderNumber, customerId)
|
||||||
|
- Vielleicht Read Replicas wenn's viele GET requests gibt
|
||||||
|
- Oder gleich managed DB nehmen (weniger Stress)
|
||||||
|
|
||||||
|
3. **Caching** (wenn nötig)
|
||||||
|
- Redis für häufige Queries
|
||||||
|
- Aber erstmal messen ob's wirklich gebraucht wird
|
||||||
|
|
||||||
|
Ich würde ehrlich gesagt erstmal abwarten wo die Bottlenecks sind bevor ich zu viel optimiere. Premature optimization und so.
|
||||||
|
|
||||||
|
### Sicherheit
|
||||||
|
|
||||||
|
**Was drin ist:**
|
||||||
|
- Input Validation mit Zod
|
||||||
|
- SQL Injection kann nicht passieren (prepared statements)
|
||||||
|
- CORS ist an (aber nicht restricted - müsste man noch machen)
|
||||||
|
|
||||||
|
Ich weiß dass da noch viel fehlt, aber für einen Prototyp sollte es ok sein. In einem echten Projekt würde man da mehr Zeit reinstecken.
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
**Aktuell:** Console logs, super basic
|
||||||
|
|
||||||
|
Viel mehr kann ich dazu ehrlich gesagt nicht sagen weil ich's nicht in Production betreut hab. Aber das wären so die basics die Sinn machen würden.
|
||||||
|
|
||||||
|
### Fehlerhandling
|
||||||
|
|
||||||
|
**Mein Approach:**
|
||||||
|
|
||||||
|
Ich hab versucht die Errors sinnvoll zu kategorisieren:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
try {
|
||||||
|
// business logic
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name === 'ZodError') {
|
||||||
|
// User hat Müll geschickt
|
||||||
|
return 400
|
||||||
|
}
|
||||||
|
// Irgendwas ist bei uns kaputt
|
||||||
|
console.error(error)
|
||||||
|
return 500
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Was ich beachtet hab:**
|
||||||
|
- 400 = User-Fehler (falsche inputs)
|
||||||
|
- 500 = Server-Fehler (unser Problem)
|
||||||
|
- Nie stack traces an den User schicken (security)
|
||||||
|
- Alles loggen für debugging
|
||||||
|
|
||||||
|
**Frontend Error Handling:**
|
||||||
|
- Try/catch um API calls
|
||||||
|
- User-freundliche Messages ("Bestellung konnte nicht erstellt werden" statt "500 Internal Server Error")
|
||||||
|
- Form bleibt gefüllt wenn's fehlschlägt (user muss nicht alles nochmal eingeben)
|
||||||
|
|
||||||
|
|
||||||
|
## Zusammenfassung & Ehrliche Einschätzung
|
||||||
|
|
||||||
|
**Was ich anders machen würde mit mehr Zeit:**
|
||||||
|
- PostgreSQL von Anfang an (Migration ist nervig)
|
||||||
|
- Echte Message Queue für Events
|
||||||
|
- Auth System
|
||||||
|
- Mehr Tests (hab nur die wichtigsten)
|
||||||
|
- Admin Dashboard wäre cool
|
||||||
|
|
||||||
|
**Nächste Schritte wenn's weitergeht:**
|
||||||
|
1. PostgreSQL Migration (wichtigste)
|
||||||
|
2. Message Queue (RabbitMQ maybe)
|
||||||
|
3. Auth hinzufügen (JWT)
|
||||||
|
4. Mehr Error handling
|
||||||
|
5. Production Monitoring setup
|
||||||
|
|
||||||
|
Ich denke für eine Challenge ist das ein ok Ergebnis. Nicht perfekt, aber funktioniert und ist ein guter Ausgangspunkt.
|
||||||
|
|
||||||
|
---
|
||||||
BIN
docs/aufgabe1.2.drawio.pdf
Normal file
BIN
docs/aufgabe1.2.drawio.pdf
Normal file
Binary file not shown.
5
frontend/.gitignore
vendored
Normal file
5
frontend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
16
frontend/Dockerfile
Normal file
16
frontend/Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM node:20 as build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
|
|
||||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Order Management System</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /orders {
|
||||||
|
proxy_pass http://backend:3990;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
1663
frontend/package-lock.json
generated
Normal file
1663
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
frontend/package.json
Normal file
26
frontend/package.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "oms-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mui/material": "^5.15.3",
|
||||||
|
"@emotion/react": "^11.11.1",
|
||||||
|
"@emotion/styled": "^11.11.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"axios": "^1.6.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.48",
|
||||||
|
"@types/react-dom": "^18.2.18",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vite": "^5.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
334
frontend/src/App.tsx
Normal file
334
frontend/src/App.tsx
Normal file
@ -0,0 +1,334 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
Box,
|
||||||
|
Alert,
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Grid,
|
||||||
|
MenuItem,
|
||||||
|
AppBar,
|
||||||
|
Toolbar,
|
||||||
|
} from '@mui/material';
|
||||||
|
import axios from 'axios';
|
||||||
|
import OrdersList from './OrdersList';
|
||||||
|
|
||||||
|
// Festes Produkt für die Challenge
|
||||||
|
const PRODUCT = {
|
||||||
|
id: 'DESK001',
|
||||||
|
name: 'Premium-Schreibtisch',
|
||||||
|
description: 'Höhenverstellbarer Schreibtisch mit Massivholzplatte',
|
||||||
|
price: 299.99,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
quantity: number;
|
||||||
|
street: string;
|
||||||
|
city: string;
|
||||||
|
postalCode: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [view, setView] = useState<'form' | 'orders'>('form');
|
||||||
|
const [formData, setFormData] = useState<FormData>({
|
||||||
|
customerId: '',
|
||||||
|
customerName: '',
|
||||||
|
customerEmail: '',
|
||||||
|
quantity: 1,
|
||||||
|
street: '',
|
||||||
|
city: '',
|
||||||
|
postalCode: '',
|
||||||
|
country: 'DE',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [success, setSuccess] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleChange = (field: keyof FormData) => (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
setFormData({ ...formData, [field]: e.target.value });
|
||||||
|
setErrors({ ...errors, [field]: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const validate = (): boolean => {
|
||||||
|
const newErrors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!formData.customerId) newErrors.customerId = 'Pflichtfeld';
|
||||||
|
if (!formData.customerName || formData.customerName.length < 2) {
|
||||||
|
newErrors.customerName = 'Mindestens 2 Zeichen';
|
||||||
|
}
|
||||||
|
if (!formData.customerEmail || !formData.customerEmail.includes('@')) {
|
||||||
|
newErrors.customerEmail = 'Ungültige E-Mail';
|
||||||
|
}
|
||||||
|
if (formData.quantity < 1) {
|
||||||
|
newErrors.quantity = 'Mindestens 1';
|
||||||
|
}
|
||||||
|
if (!formData.street) newErrors.street = 'Pflichtfeld';
|
||||||
|
if (!formData.city) newErrors.city = 'Pflichtfeld';
|
||||||
|
if (!formData.postalCode) newErrors.postalCode = 'Pflichtfeld';
|
||||||
|
|
||||||
|
setErrors(newErrors);
|
||||||
|
return Object.keys(newErrors).length === 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSuccess(null);
|
||||||
|
|
||||||
|
if (!validate()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const orderData = {
|
||||||
|
customerId: formData.customerId,
|
||||||
|
customerName: formData.customerName,
|
||||||
|
customerEmail: formData.customerEmail,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
productId: PRODUCT.id,
|
||||||
|
quantity: formData.quantity,
|
||||||
|
price: PRODUCT.price,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
shippingAddress: {
|
||||||
|
street: formData.street,
|
||||||
|
city: formData.city,
|
||||||
|
postalCode: formData.postalCode,
|
||||||
|
country: formData.country,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await axios.post('/orders', orderData);
|
||||||
|
|
||||||
|
setSuccess(`Bestellung erstellt! Nr: ${response.data.data.orderNumber}`);
|
||||||
|
|
||||||
|
// Form reset
|
||||||
|
setFormData({
|
||||||
|
customerId: '',
|
||||||
|
customerName: '',
|
||||||
|
customerEmail: '',
|
||||||
|
quantity: 1,
|
||||||
|
street: '',
|
||||||
|
city: '',
|
||||||
|
postalCode: '',
|
||||||
|
country: 'DE',
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.response?.data?.details) {
|
||||||
|
const apiErrors: Record<string, string> = {};
|
||||||
|
error.response.data.details.forEach((err: any) => {
|
||||||
|
const field = err.path?.[0];
|
||||||
|
if (field) apiErrors[field] = err.message;
|
||||||
|
});
|
||||||
|
setErrors(apiErrors);
|
||||||
|
} else {
|
||||||
|
setErrors({ general: 'Fehler beim Erstellen der Bestellung' });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = PRODUCT.price * formData.quantity;
|
||||||
|
|
||||||
|
if (view === 'orders') {
|
||||||
|
return <OrdersList onBack={() => setView('form')} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AppBar position="static">
|
||||||
|
<Toolbar>
|
||||||
|
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||||
|
Order Management System
|
||||||
|
</Typography>
|
||||||
|
<Button color="inherit" onClick={() => setView('orders')}>
|
||||||
|
Bestellungen
|
||||||
|
</Button>
|
||||||
|
</Toolbar>
|
||||||
|
</AppBar>
|
||||||
|
|
||||||
|
<Container maxWidth="md" sx={{ py: 4 }}>
|
||||||
|
<Paper elevation={3} sx={{ p: 4 }}>
|
||||||
|
<Typography variant="h4" component="h1" gutterBottom>
|
||||||
|
Neue Bestellung erstellen
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
Füllen Sie das Formular aus, um eine Bestellung aufzugeben
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{success && (
|
||||||
|
<Alert severity="success" sx={{ mb: 3 }}>
|
||||||
|
{success}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{errors.general && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{errors.general}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Kundendaten */}
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 3 }}>
|
||||||
|
Kundendaten
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Kunden-ID"
|
||||||
|
value={formData.customerId}
|
||||||
|
onChange={handleChange('customerId')}
|
||||||
|
error={!!errors.customerId}
|
||||||
|
helperText={errors.customerId}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={6}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Vollständiger Name"
|
||||||
|
value={formData.customerName}
|
||||||
|
onChange={handleChange('customerName')}
|
||||||
|
error={!!errors.customerName}
|
||||||
|
helperText={errors.customerName}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
type="email"
|
||||||
|
label="E-Mail"
|
||||||
|
value={formData.customerEmail}
|
||||||
|
onChange={handleChange('customerEmail')}
|
||||||
|
error={!!errors.customerEmail}
|
||||||
|
helperText={errors.customerEmail}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{/* Produkt */}
|
||||||
|
<Typography variant="h6" gutterBottom sx={{ mt: 4 }}>
|
||||||
|
Produkt
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Card variant="outlined" sx={{ mb: 2 }}>
|
||||||
|
<CardContent>
|
||||||
|
<Typography variant="h6">{PRODUCT.name}</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" paragraph>
|
||||||
|
{PRODUCT.description}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="h5" color="primary">
|
||||||
|
€ {PRODUCT.price.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
type="number"
|
||||||
|
label="Menge"
|
||||||
|
value={formData.quantity}
|
||||||
|
onChange={handleChange('quantity')}
|
||||||
|
error={!!errors.quantity}
|
||||||
|
helperText={errors.quantity}
|
||||||
|
inputProps={{ min: 1 }}
|
||||||
|
sx={{ mb: 2 }}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Box sx={{ bgcolor: 'grey.100', p: 2, borderRadius: 1, mb: 3 }}>
|
||||||
|
<Typography variant="h6" align="right">
|
||||||
|
Gesamt: € {total.toFixed(2)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Lieferadresse */}
|
||||||
|
<Typography variant="h6" gutterBottom>
|
||||||
|
Lieferadresse
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Straße und Hausnummer"
|
||||||
|
value={formData.street}
|
||||||
|
onChange={handleChange('street')}
|
||||||
|
error={!!errors.street}
|
||||||
|
helperText={errors.street}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={4}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Postleitzahl"
|
||||||
|
value={formData.postalCode}
|
||||||
|
onChange={handleChange('postalCode')}
|
||||||
|
error={!!errors.postalCode}
|
||||||
|
helperText={errors.postalCode}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12} sm={8}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Stadt"
|
||||||
|
value={formData.city}
|
||||||
|
onChange={handleChange('city')}
|
||||||
|
error={!!errors.city}
|
||||||
|
helperText={errors.city}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
select
|
||||||
|
label="Land"
|
||||||
|
value={formData.country}
|
||||||
|
onChange={handleChange('country')}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<MenuItem value="DE">Deutschland</MenuItem>
|
||||||
|
<MenuItem value="AT">Österreich</MenuItem>
|
||||||
|
<MenuItem value="CH">Schweiz</MenuItem>
|
||||||
|
<MenuItem value="IT">Italien</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Box sx={{ mt: 4, textAlign: 'right' }}>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="large"
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? 'Wird bearbeitet...' : 'Bestellung bestätigen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
153
frontend/src/OrdersList.tsx
Normal file
153
frontend/src/OrdersList.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
Typography,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Chip,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Alert,
|
||||||
|
} from '@mui/material';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface Order {
|
||||||
|
id: number;
|
||||||
|
orderNumber: string;
|
||||||
|
customerId: string;
|
||||||
|
customerName: string;
|
||||||
|
customerEmail: string;
|
||||||
|
totalAmount: number;
|
||||||
|
status: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrdersListProps {
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function OrdersList({ onBack }: OrdersListProps) {
|
||||||
|
const [orders, setOrders] = useState<Order[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOrders();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadOrders = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await axios.get('/orders');
|
||||||
|
setOrders(response.data.data || []);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Fehler beim Laden der Bestellungen');
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'pending':
|
||||||
|
return 'warning';
|
||||||
|
case 'completed':
|
||||||
|
return 'success';
|
||||||
|
case 'cancelled':
|
||||||
|
return 'error';
|
||||||
|
default:
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Typography>Lädt...</Typography>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
||||||
|
<Box sx={{ mb: 3, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<Typography variant="h4" component="h1">
|
||||||
|
Bestellübersicht
|
||||||
|
</Typography>
|
||||||
|
<Button variant="outlined" onClick={onBack}>
|
||||||
|
Zurück
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<Paper sx={{ p: 4, textAlign: 'center' }}>
|
||||||
|
<Typography color="text.secondary">
|
||||||
|
Noch keine Bestellungen vorhanden
|
||||||
|
</Typography>
|
||||||
|
</Paper>
|
||||||
|
) : (
|
||||||
|
<TableContainer component={Paper}>
|
||||||
|
<Table>
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Bestellnummer</TableCell>
|
||||||
|
<TableCell>Kunde</TableCell>
|
||||||
|
<TableCell>E-Mail</TableCell>
|
||||||
|
<TableCell align="right">Betrag</TableCell>
|
||||||
|
<TableCell>Status</TableCell>
|
||||||
|
<TableCell>Datum</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<TableRow key={order.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Typography variant="body2" fontWeight="medium">
|
||||||
|
{order.orderNumber}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{order.customerName}</TableCell>
|
||||||
|
<TableCell>{order.customerEmail}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
€ {order.totalAmount.toFixed(2)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Chip
|
||||||
|
label={order.status}
|
||||||
|
color={getStatusColor(order.status)}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{new Date(order.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ mt: 2, textAlign: 'right' }}>
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Gesamt: {orders.length} Bestellung{orders.length !== 1 ? 'en' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrdersList;
|
||||||
|
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
22
frontend/tsconfig.json
Normal file
22
frontend/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
|
|
||||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowSyntheticDefaultImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
13
frontend/vite.config.ts
Normal file
13
frontend/vite.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3980,
|
||||||
|
proxy: {
|
||||||
|
'/orders': 'http://localhost:3990'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "aufgabe-29102025-main",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"install:all": "cd backend && npm install && cd ../frontend && npm install",
|
||||||
|
"dev": "cd backend && npm run dev",
|
||||||
|
"dev:frontend": "cd frontend && npm run dev",
|
||||||
|
"build": "cd backend && npm run build && cd ../frontend && npm run build",
|
||||||
|
"start": "cd backend && npm start",
|
||||||
|
"test": "cd backend && npm test",
|
||||||
|
"test:watch": "cd backend && npm run test:watch"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC"
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user