feat: initial workflow portal - multi-tenant architecture
- Backend: Node.js + Express + TypeScript + Prisma + PostgreSQL - Frontend: React + TypeScript + Tailwind CSS + Vite - Multi-tenant with role-based access (SystemAdmin, Technician, Customer) - Workflow editor with drag-and-drop steps - JWT authentication with refresh tokens - German UI throughout
This commit is contained in:
commit
d58d53fa3b
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage/
|
||||||
|
.nyc_output/
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
5
backend/.env.example
Normal file
5
backend/.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
DATABASE_URL=postgresql://workflow:workflow@localhost:5432/workflow_portal
|
||||||
|
JWT_SECRET=change-me-in-production
|
||||||
|
JWT_REFRESH_SECRET=change-me-too
|
||||||
|
PORT=4000
|
||||||
|
CORS_ORIGIN=http://localhost:3000
|
||||||
4
backend/.gitignore
vendored
Normal file
4
backend/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.js.map
|
||||||
34
backend/package.json
Normal file
34
backend/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "workflow-portal-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon --exec ts-node src/index.ts",
|
||||||
|
"build": "tsc",
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"db:migrate": "npx prisma migrate deploy",
|
||||||
|
"db:seed": "ts-node prisma/seed.ts",
|
||||||
|
"db:generate": "npx prisma generate"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@prisma/client": "^6.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.0",
|
||||||
|
"express": "^4.21.0",
|
||||||
|
"express-rate-limit": "^7.4.0",
|
||||||
|
"helmet": "^8.0.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"zod": "^3.23.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
|
"@types/cors": "^2.8.17",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"nodemon": "^3.1.0",
|
||||||
|
"prisma": "^6.0.0",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
108
backend/prisma/schema.prisma
Normal file
108
backend/prisma/schema.prisma
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Tenant {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
slug String @unique
|
||||||
|
logo String?
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
users User[]
|
||||||
|
workflows Workflow[]
|
||||||
|
categories Category[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
email String @unique
|
||||||
|
passwordHash String
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
role Role
|
||||||
|
tenantId String?
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
active Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdWorkflows Workflow[] @relation("CreatedBy")
|
||||||
|
updatedWorkflows Workflow[] @relation("UpdatedBy")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum Role {
|
||||||
|
SYSTEM_ADMIN
|
||||||
|
TECHNICIAN
|
||||||
|
CUSTOMER_ADMIN
|
||||||
|
CUSTOMER_USER
|
||||||
|
}
|
||||||
|
|
||||||
|
model Category {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
name String
|
||||||
|
icon String?
|
||||||
|
tenantId String?
|
||||||
|
tenant Tenant? @relation(fields: [tenantId], references: [id])
|
||||||
|
workflows Workflow[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Workflow {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
tenantId String
|
||||||
|
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||||
|
categoryId String?
|
||||||
|
category Category? @relation(fields: [categoryId], references: [id])
|
||||||
|
status WorkflowStatus @default(DRAFT)
|
||||||
|
version Int @default(1)
|
||||||
|
isTemplate Boolean @default(false)
|
||||||
|
tags String[]
|
||||||
|
createdById String
|
||||||
|
createdBy User @relation("CreatedBy", fields: [createdById], references: [id])
|
||||||
|
updatedById String?
|
||||||
|
updatedBy User? @relation("UpdatedBy", fields: [updatedById], references: [id])
|
||||||
|
steps WorkflowStep[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
|
@@index([status])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum WorkflowStatus {
|
||||||
|
DRAFT
|
||||||
|
REVIEW
|
||||||
|
PUBLISHED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
model WorkflowStep {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
workflowId String
|
||||||
|
workflow Workflow @relation(fields: [workflowId], references: [id], onDelete: Cascade)
|
||||||
|
order Int
|
||||||
|
title String
|
||||||
|
content String
|
||||||
|
type StepType @default(INSTRUCTION)
|
||||||
|
isRequired Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([workflowId])
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StepType {
|
||||||
|
INSTRUCTION
|
||||||
|
CHECKLIST
|
||||||
|
WARNING
|
||||||
|
INFO
|
||||||
|
COMMAND
|
||||||
|
}
|
||||||
212
backend/prisma/seed.ts
Normal file
212
backend/prisma/seed.ts
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { PrismaClient, Role, WorkflowStatus, StepType } from '@prisma/client';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('Seeding database...');
|
||||||
|
|
||||||
|
// Create default tenant "Systemhaus"
|
||||||
|
const systemhausTenant = await prisma.tenant.upsert({
|
||||||
|
where: { slug: 'systemhaus' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Systemhaus',
|
||||||
|
slug: 'systemhaus',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created tenant:', systemhausTenant.name);
|
||||||
|
|
||||||
|
// Create sample tenant "Musterkunde GmbH"
|
||||||
|
const musterkundeTenant = await prisma.tenant.upsert({
|
||||||
|
where: { slug: 'musterkunde' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
name: 'Musterkunde GmbH',
|
||||||
|
slug: 'musterkunde',
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created tenant:', musterkundeTenant.name);
|
||||||
|
|
||||||
|
// Create system admin user
|
||||||
|
const adminPasswordHash = await bcrypt.hash('admin123', 12);
|
||||||
|
const systemAdmin = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@systemhaus.de' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@systemhaus.de',
|
||||||
|
passwordHash: adminPasswordHash,
|
||||||
|
firstName: 'System',
|
||||||
|
lastName: 'Administrator',
|
||||||
|
role: Role.SYSTEM_ADMIN,
|
||||||
|
tenantId: systemhausTenant.id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created user:', systemAdmin.email, '(SYSTEM_ADMIN)');
|
||||||
|
|
||||||
|
// Create technician user
|
||||||
|
const technicianPasswordHash = await bcrypt.hash('admin123', 12);
|
||||||
|
const technician = await prisma.user.upsert({
|
||||||
|
where: { email: 'techniker@systemhaus.de' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'techniker@systemhaus.de',
|
||||||
|
passwordHash: technicianPasswordHash,
|
||||||
|
firstName: 'Max',
|
||||||
|
lastName: 'Techniker',
|
||||||
|
role: Role.TECHNICIAN,
|
||||||
|
tenantId: systemhausTenant.id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created user:', technician.email, '(TECHNICIAN)');
|
||||||
|
|
||||||
|
// Create customer admin user
|
||||||
|
const customerAdminPasswordHash = await bcrypt.hash('admin123', 12);
|
||||||
|
const customerAdmin = await prisma.user.upsert({
|
||||||
|
where: { email: 'admin@musterkunde.de' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'admin@musterkunde.de',
|
||||||
|
passwordHash: customerAdminPasswordHash,
|
||||||
|
firstName: 'Maria',
|
||||||
|
lastName: 'Mustermann',
|
||||||
|
role: Role.CUSTOMER_ADMIN,
|
||||||
|
tenantId: musterkundeTenant.id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created user:', customerAdmin.email, '(CUSTOMER_ADMIN)');
|
||||||
|
|
||||||
|
// Create customer user
|
||||||
|
const customerUserPasswordHash = await bcrypt.hash('admin123', 12);
|
||||||
|
const customerUser = await prisma.user.upsert({
|
||||||
|
where: { email: 'user@musterkunde.de' },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
email: 'user@musterkunde.de',
|
||||||
|
passwordHash: customerUserPasswordHash,
|
||||||
|
firstName: 'Hans',
|
||||||
|
lastName: 'Benutzer',
|
||||||
|
role: Role.CUSTOMER_USER,
|
||||||
|
tenantId: musterkundeTenant.id,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created user:', customerUser.email, '(CUSTOMER_USER)');
|
||||||
|
|
||||||
|
// Create categories (global, no tenant)
|
||||||
|
const categories = [
|
||||||
|
{ name: 'Benutzerverwaltung', icon: 'users' },
|
||||||
|
{ name: 'Netzwerk', icon: 'network' },
|
||||||
|
{ name: 'VPN', icon: 'shield' },
|
||||||
|
{ name: 'Backup', icon: 'database' },
|
||||||
|
{ name: 'Server', icon: 'server' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const createdCategories: Record<string, string> = {};
|
||||||
|
for (const cat of categories) {
|
||||||
|
const existing = await prisma.category.findFirst({
|
||||||
|
where: { name: cat.name, tenantId: null },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
createdCategories[cat.name] = existing.id;
|
||||||
|
console.log('Category already exists:', cat.name);
|
||||||
|
} else {
|
||||||
|
const created = await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
name: cat.name,
|
||||||
|
icon: cat.icon,
|
||||||
|
tenantId: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdCategories[cat.name] = created.id;
|
||||||
|
console.log('Created category:', cat.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create sample workflow: "Neuen Benutzer anlegen"
|
||||||
|
const existingWorkflow = await prisma.workflow.findFirst({
|
||||||
|
where: {
|
||||||
|
title: 'Neuen Benutzer anlegen',
|
||||||
|
tenantId: musterkundeTenant.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existingWorkflow) {
|
||||||
|
const workflow = await prisma.workflow.create({
|
||||||
|
data: {
|
||||||
|
title: 'Neuen Benutzer anlegen',
|
||||||
|
description: 'Schritt-fuer-Schritt Anleitung zum Anlegen eines neuen Benutzers im Active Directory und allen relevanten Systemen.',
|
||||||
|
tenantId: musterkundeTenant.id,
|
||||||
|
categoryId: createdCategories['Benutzerverwaltung'],
|
||||||
|
status: WorkflowStatus.PUBLISHED,
|
||||||
|
version: 1,
|
||||||
|
isTemplate: false,
|
||||||
|
tags: ['benutzer', 'onboarding', 'active-directory'],
|
||||||
|
createdById: systemAdmin.id,
|
||||||
|
steps: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
order: 0,
|
||||||
|
title: 'Anforderung pruefen',
|
||||||
|
content: 'Pruefen Sie die Benutzeranforderung auf Vollstaendigkeit:\n- Vor- und Nachname\n- Abteilung\n- Vorgesetzter\n- Benoetigte Berechtigungen\n- Startdatum',
|
||||||
|
type: StepType.CHECKLIST,
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 1,
|
||||||
|
title: 'Active Directory Benutzer erstellen',
|
||||||
|
content: 'Oeffnen Sie die Active Directory Benutzer und Computer Konsole und erstellen Sie einen neuen Benutzer:\n\n```powershell\nNew-ADUser -Name "Vorname Nachname" -GivenName "Vorname" -Surname "Nachname" -SamAccountName "v.nachname" -UserPrincipalName "v.nachname@domain.de" -Path "OU=Users,DC=domain,DC=de" -AccountPassword (ConvertTo-SecureString "TempPasswort123!" -AsPlainText -Force) -Enabled $true -ChangePasswordAtLogon $true\n```',
|
||||||
|
type: StepType.COMMAND,
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 2,
|
||||||
|
title: 'Gruppenmitgliedschaften konfigurieren',
|
||||||
|
content: 'Fuegen Sie den Benutzer den entsprechenden Sicherheitsgruppen hinzu basierend auf Abteilung und Rolle:\n- Abteilungsgruppe\n- VPN-Zugang (falls erforderlich)\n- Drucker-Gruppen\n- Anwendungs-Gruppen',
|
||||||
|
type: StepType.INSTRUCTION,
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 3,
|
||||||
|
title: 'E-Mail Postfach einrichten',
|
||||||
|
content: 'WICHTIG: Warten Sie nach der AD-Erstellung mindestens 15 Minuten auf die Exchange-Synchronisation. Pruefen Sie anschliessend, ob das Postfach automatisch erstellt wurde. Falls nicht, erstellen Sie es manuell im Exchange Admin Center.',
|
||||||
|
type: StepType.WARNING,
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
order: 4,
|
||||||
|
title: 'Dokumentation und Uebergabe',
|
||||||
|
content: 'Dokumentieren Sie die Zugangsdaten und informieren Sie den Vorgesetzten:\n- Benutzername\n- Initiales Passwort (sicher uebermitteln!)\n- E-Mail Adresse\n- VPN-Zugangsdaten (falls zutreffend)\n\nHinweis: Verwenden Sie einen sicheren Kanal fuer die Passwort-Uebermittlung.',
|
||||||
|
type: StepType.INFO,
|
||||||
|
isRequired: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log('Created workflow:', workflow.title, 'with 5 steps');
|
||||||
|
} else {
|
||||||
|
console.log('Workflow already exists:', existingWorkflow.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSeed complete!');
|
||||||
|
console.log('\nTest Accounts:');
|
||||||
|
console.log(' System Admin: admin@systemhaus.de / admin123');
|
||||||
|
console.log(' Technician: techniker@systemhaus.de / admin123');
|
||||||
|
console.log(' Customer Admin: admin@musterkunde.de / admin123');
|
||||||
|
console.log(' Customer User: user@musterkunde.de / admin123');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('Seed error:', e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
108
backend/src/index.ts
Normal file
108
backend/src/index.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
|
import express, { Request, Response, NextFunction } from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { AppError } from './lib/errors';
|
||||||
|
|
||||||
|
import authRoutes from './routes/auth.routes';
|
||||||
|
import tenantRoutes from './routes/tenant.routes';
|
||||||
|
import userRoutes from './routes/user.routes';
|
||||||
|
import workflowRoutes from './routes/workflow.routes';
|
||||||
|
import categoryRoutes from './routes/category.routes';
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = parseInt(process.env.PORT || '4000', 10);
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet());
|
||||||
|
|
||||||
|
// CORS
|
||||||
|
app.use(cors({
|
||||||
|
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Body parsing
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many requests', message: 'Please try again later' },
|
||||||
|
});
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
|
// Stricter rate limit for auth endpoints
|
||||||
|
const authLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 20,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: 'Too many login attempts', message: 'Please try again later' },
|
||||||
|
});
|
||||||
|
app.use('/api/auth/login', authLimiter);
|
||||||
|
app.use('/api/auth/refresh', authLimiter);
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/api/health', (_req: Request, res: Response) => {
|
||||||
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
app.use('/api/auth', authRoutes);
|
||||||
|
app.use('/api/tenants', tenantRoutes);
|
||||||
|
app.use('/api/users', userRoutes);
|
||||||
|
app.use('/api/workflows', workflowRoutes);
|
||||||
|
app.use('/api/categories', categoryRoutes);
|
||||||
|
|
||||||
|
// 404 handler
|
||||||
|
app.use((_req: Request, res: Response) => {
|
||||||
|
res.status(404).json({ error: 'Not found', message: 'The requested endpoint does not exist' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global error handler
|
||||||
|
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
|
||||||
|
if (err instanceof AppError) {
|
||||||
|
res.status(err.statusCode).json({
|
||||||
|
error: err.message,
|
||||||
|
...(err.statusCode >= 500 ? {} : { message: err.message }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prisma known errors
|
||||||
|
if ((err as any).code === 'P2002') {
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'Conflict',
|
||||||
|
message: 'A record with this unique constraint already exists',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((err as any).code === 'P2025') {
|
||||||
|
res.status(404).json({
|
||||||
|
error: 'Not found',
|
||||||
|
message: 'The requested record was not found',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
error: 'Internal server error',
|
||||||
|
message: process.env.NODE_ENV === 'development' ? err.message : 'An unexpected error occurred',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Workflow Portal API running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
||||||
35
backend/src/lib/errors.ts
Normal file
35
backend/src/lib/errors.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
export class AppError extends Error {
|
||||||
|
public statusCode: number;
|
||||||
|
public isOperational: boolean;
|
||||||
|
|
||||||
|
constructor(message: string, statusCode: number) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.isOperational = true;
|
||||||
|
Object.setPrototypeOf(this, AppError.prototype);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NotFoundError extends AppError {
|
||||||
|
constructor(resource: string) {
|
||||||
|
super(`${resource} not found`, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ValidationError extends AppError {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthorizedError extends AppError {
|
||||||
|
constructor(message = 'Unauthorized') {
|
||||||
|
super(message, 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ForbiddenError extends AppError {
|
||||||
|
constructor(message = 'Forbidden') {
|
||||||
|
super(message, 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
backend/src/lib/prisma.ts
Normal file
5
backend/src/lib/prisma.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
export default prisma;
|
||||||
96
backend/src/middleware/auth.ts
Normal file
96
backend/src/middleware/auth.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
||||||
|
|
||||||
|
interface JwtPayload {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
tenantId: string | null;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authenticate(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
res.status(401).json({ error: 'Authentication required', message: 'No valid token provided' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = authHeader.substring(7);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const decoded = jwt.verify(token, JWT_SECRET) as JwtPayload;
|
||||||
|
req.user = {
|
||||||
|
id: decoded.id,
|
||||||
|
email: decoded.email,
|
||||||
|
role: decoded.role,
|
||||||
|
tenantId: decoded.tenantId,
|
||||||
|
firstName: decoded.firstName,
|
||||||
|
lastName: decoded.lastName,
|
||||||
|
};
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof jwt.TokenExpiredError) {
|
||||||
|
res.status(401).json({ error: 'Token expired', message: 'Please refresh your token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(401).json({ error: 'Invalid token', message: 'Token verification failed' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authorize(...roles: Role[]) {
|
||||||
|
return (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: 'Authentication required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roles.includes(req.user.role)) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: `Role ${req.user.role} is not authorized for this action`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tenantGuard(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (!req.user) {
|
||||||
|
res.status(401).json({ error: 'Authentication required' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SYSTEM_ADMIN and TECHNICIAN can access all tenants
|
||||||
|
if (req.user.role === 'SYSTEM_ADMIN' || req.user.role === 'TECHNICIAN') {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For tenant-scoped users, check that the requested tenantId matches their own
|
||||||
|
const requestedTenantId = req.params.tenantId || req.body?.tenantId || req.query.tenantId;
|
||||||
|
|
||||||
|
if (requestedTenantId && requestedTenantId !== req.user.tenantId) {
|
||||||
|
res.status(403).json({
|
||||||
|
error: 'Forbidden',
|
||||||
|
message: 'You can only access data within your own tenant',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenantFilter(user: JwtPayload): { tenantId: string } | {} {
|
||||||
|
if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { tenantId: user.tenantId! };
|
||||||
|
}
|
||||||
187
backend/src/routes/auth.routes.ts
Normal file
187
backend/src/routes/auth.routes.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticate } from '../middleware/auth';
|
||||||
|
import { AppError } from '../lib/errors';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'change-me-in-production';
|
||||||
|
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET || 'change-me-too';
|
||||||
|
const ACCESS_TOKEN_EXPIRY = '15m';
|
||||||
|
const REFRESH_TOKEN_EXPIRY = '7d';
|
||||||
|
|
||||||
|
// Store for invalidated refresh tokens (in production, use Redis)
|
||||||
|
const invalidatedTokens = new Set<string>();
|
||||||
|
|
||||||
|
const loginSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email format'),
|
||||||
|
password: z.string().min(1, 'Password is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshSchema = z.object({
|
||||||
|
refreshToken: z.string().min(1, 'Refresh token is required'),
|
||||||
|
});
|
||||||
|
|
||||||
|
function generateTokens(user: { id: string; email: string; role: string; tenantId: string | null; firstName: string; lastName: string }) {
|
||||||
|
const payload = {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: ACCESS_TOKEN_EXPIRY });
|
||||||
|
const refreshToken = jwt.sign({ id: user.id, type: 'refresh' }, JWT_REFRESH_SECRET, { expiresIn: REFRESH_TOKEN_EXPIRY });
|
||||||
|
|
||||||
|
return { accessToken, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/auth/login
|
||||||
|
router.post('/login', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = loginSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Validation error',
|
||||||
|
details: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { email, password } = parsed.data;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
include: { tenant: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.active) {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validPassword = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!validPassword) {
|
||||||
|
res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = generateTokens(user);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...tokens,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug } : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/refresh
|
||||||
|
router.post('/refresh', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = refreshSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({
|
||||||
|
error: 'Validation error',
|
||||||
|
details: parsed.error.flatten().fieldErrors,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { refreshToken } = parsed.data;
|
||||||
|
|
||||||
|
if (invalidatedTokens.has(refreshToken)) {
|
||||||
|
res.status(401).json({ error: 'Token has been invalidated' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded: any;
|
||||||
|
try {
|
||||||
|
decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET);
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid refresh token' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decoded.type !== 'refresh') {
|
||||||
|
res.status(401).json({ error: 'Invalid token type' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: decoded.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !user.active) {
|
||||||
|
res.status(401).json({ error: 'User not found or inactive' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate old refresh token
|
||||||
|
invalidatedTokens.add(refreshToken);
|
||||||
|
|
||||||
|
const tokens = generateTokens(user);
|
||||||
|
|
||||||
|
res.json(tokens);
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/auth/logout
|
||||||
|
router.post('/logout', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const refreshToken = req.body.refreshToken;
|
||||||
|
if (refreshToken) {
|
||||||
|
invalidatedTokens.add(refreshToken);
|
||||||
|
}
|
||||||
|
res.json({ message: 'Logged out successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/auth/me
|
||||||
|
router.get('/me', authenticate, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: req.user!.id },
|
||||||
|
include: { tenant: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
role: user.role,
|
||||||
|
tenantId: user.tenantId,
|
||||||
|
tenant: user.tenant ? { id: user.tenant.id, name: user.tenant.name, slug: user.tenant.slug, logo: user.tenant.logo } : null,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
223
backend/src/routes/category.routes.ts
Normal file
223
backend/src/routes/category.routes.ts
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const createCategorySchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(255),
|
||||||
|
icon: z.string().max(100).optional().nullable(),
|
||||||
|
tenantId: z.string().uuid().optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateCategorySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
icon: z.string().max(100).optional().nullable(),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/categories
|
||||||
|
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
let where: any = {};
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') {
|
||||||
|
// Customer users see global categories (tenantId is null) + their tenant's categories
|
||||||
|
where = {
|
||||||
|
OR: [
|
||||||
|
{ tenantId: null },
|
||||||
|
{ tenantId: user.tenantId },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow filtering by tenantId for system users
|
||||||
|
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) {
|
||||||
|
where = {
|
||||||
|
OR: [
|
||||||
|
{ tenantId: null },
|
||||||
|
{ tenantId: req.query.tenantId as string },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = await prisma.category.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
_count: { select: { workflows: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: categories });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/categories/:id
|
||||||
|
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const category = await prisma.category.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: { select: { workflows: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant isolation: customer users can only see global or their tenant's categories
|
||||||
|
if (
|
||||||
|
(user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') &&
|
||||||
|
category.tenantId !== null &&
|
||||||
|
category.tenantId !== user.tenantId
|
||||||
|
) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot access this category' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: category });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/categories
|
||||||
|
router.post('/', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const parsed = createCategorySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
// Customer admins can only create tenant-scoped categories in their own tenant
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN') {
|
||||||
|
data.tenantId = user.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only SYSTEM_ADMIN can create global categories (tenantId = null)
|
||||||
|
if (data.tenantId === null && user.role !== 'SYSTEM_ADMIN') {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'Only system admins can create global categories' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.category.create({
|
||||||
|
data: {
|
||||||
|
name: data.name,
|
||||||
|
icon: data.icon ?? null,
|
||||||
|
tenantId: data.tenantId ?? null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: category });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/categories/:id
|
||||||
|
router.put('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const parsed = updateCategorySchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const category = await prisma.category.findUnique({ where: { id } });
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant isolation
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && category.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only edit your tenant categories' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only SYSTEM_ADMIN can edit global categories
|
||||||
|
if (category.tenantId === null && user.role !== 'SYSTEM_ADMIN') {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'Only system admins can edit global categories' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.category.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
include: {
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/categories/:id
|
||||||
|
router.delete('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const category = await prisma.category.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { _count: { select: { workflows: true } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && category.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only delete your tenant categories' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category.tenantId === null && user.role !== 'SYSTEM_ADMIN') {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'Only system admins can delete global categories' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (category._count.workflows > 0) {
|
||||||
|
res.status(409).json({
|
||||||
|
error: 'Conflict',
|
||||||
|
message: `Cannot delete category with ${category._count.workflows} workflow(s). Reassign or delete them first.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.category.delete({ where: { id } });
|
||||||
|
|
||||||
|
res.json({ message: 'Category deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
168
backend/src/routes/tenant.routes.ts
Normal file
168
backend/src/routes/tenant.routes.ts
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const createTenantSchema = z.object({
|
||||||
|
name: z.string().min(1, 'Name is required').max(255),
|
||||||
|
slug: z.string().min(1, 'Slug is required').max(100).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
|
||||||
|
logo: z.string().url().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateTenantSchema = z.object({
|
||||||
|
name: z.string().min(1).max(255).optional(),
|
||||||
|
slug: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens').optional(),
|
||||||
|
logo: z.string().url().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// All tenant routes require authentication
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/tenants
|
||||||
|
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
|
||||||
|
let where: any = {};
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') {
|
||||||
|
// Customer users can only see their own tenant
|
||||||
|
where = { id: user.tenantId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenants = await prisma.tenant.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { users: true, workflows: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: tenants });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/tenants/:id
|
||||||
|
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Customer users can only view their own tenant
|
||||||
|
if ((user.role === 'CUSTOMER_ADMIN' || user.role === 'CUSTOMER_USER') && id !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only access your own tenant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { users: true, workflows: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!tenant) {
|
||||||
|
res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: tenant });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/tenants (SYSTEM_ADMIN only)
|
||||||
|
router.post('/', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const parsed = createTenantSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.tenant.findUnique({ where: { slug: parsed.data.slug } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: 'Conflict', message: 'A tenant with this slug already exists' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.create({
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: tenant });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/tenants/:id (SYSTEM_ADMIN only)
|
||||||
|
router.put('/:id', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const parsed = updateTenantSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id } });
|
||||||
|
if (!tenant) {
|
||||||
|
res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.data.slug && parsed.data.slug !== tenant.slug) {
|
||||||
|
const existing = await prisma.tenant.findUnique({ where: { slug: parsed.data.slug } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: 'Conflict', message: 'A tenant with this slug already exists' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/tenants/:id (SYSTEM_ADMIN only)
|
||||||
|
router.delete('/:id', authorize('SYSTEM_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id } });
|
||||||
|
if (!tenant) {
|
||||||
|
res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete by deactivating
|
||||||
|
await prisma.tenant.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Tenant deactivated successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
333
backend/src/routes/user.routes.ts
Normal file
333
backend/src/routes/user.routes.ts
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import bcrypt from 'bcryptjs';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const createUserSchema = z.object({
|
||||||
|
email: z.string().email('Invalid email format'),
|
||||||
|
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||||
|
firstName: z.string().min(1, 'First name is required').max(100),
|
||||||
|
lastName: z.string().min(1, 'Last name is required').max(100),
|
||||||
|
role: z.nativeEnum(Role),
|
||||||
|
tenantId: z.string().uuid().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateUserSchema = z.object({
|
||||||
|
email: z.string().email().optional(),
|
||||||
|
firstName: z.string().min(1).max(100).optional(),
|
||||||
|
lastName: z.string().min(1).max(100).optional(),
|
||||||
|
role: z.nativeEnum(Role).optional(),
|
||||||
|
tenantId: z.string().uuid().optional().nullable(),
|
||||||
|
active: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1, 'Current password is required'),
|
||||||
|
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
// GET /api/users
|
||||||
|
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
let where: any = {};
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN') {
|
||||||
|
// Customer admins see users in their tenant only
|
||||||
|
where = { tenantId: user.tenantId };
|
||||||
|
} else if (user.role === 'CUSTOMER_USER') {
|
||||||
|
// Customer users can only see themselves
|
||||||
|
where = { id: user.id };
|
||||||
|
}
|
||||||
|
// SYSTEM_ADMIN and TECHNICIAN see all users
|
||||||
|
|
||||||
|
// Optional tenant filter via query param
|
||||||
|
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && req.query.tenantId) {
|
||||||
|
where.tenantId = req.query.tenantId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
tenantId: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
tenant: {
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { lastName: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: users });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/users/:id
|
||||||
|
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const currentUser = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
tenantId: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
tenant: {
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetUser) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Access control
|
||||||
|
if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only view users in your tenant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentUser.role === 'CUSTOMER_USER' && targetUser.id !== currentUser.id) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only view your own profile' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: targetUser });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/users
|
||||||
|
router.post('/', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const currentUser = req.user!;
|
||||||
|
const parsed = createUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
// CUSTOMER_ADMIN can only create users in their own tenant with limited roles
|
||||||
|
if (currentUser.role === 'CUSTOMER_ADMIN') {
|
||||||
|
if (data.role === 'SYSTEM_ADMIN' || data.role === 'TECHNICIAN') {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot create users with this role' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
data.tenantId = currentUser.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that tenant-scoped roles have a tenantId
|
||||||
|
if ((data.role === 'CUSTOMER_ADMIN' || data.role === 'CUSTOMER_USER') && !data.tenantId) {
|
||||||
|
res.status(400).json({ error: 'Validation error', message: 'Customer roles require a tenantId' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email uniqueness
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: data.email } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: data.email,
|
||||||
|
passwordHash,
|
||||||
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
|
role: data.role,
|
||||||
|
tenantId: data.tenantId ?? null,
|
||||||
|
active: data.active ?? true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
tenantId: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: user });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/users/:id
|
||||||
|
router.put('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const currentUser = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
const parsed = updateUserSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!targetUser) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CUSTOMER_ADMIN can only edit users in their tenant
|
||||||
|
if (currentUser.role === 'CUSTOMER_ADMIN') {
|
||||||
|
if (targetUser.tenantId !== currentUser.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only edit users in your tenant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cannot change role to system-level roles
|
||||||
|
if (parsed.data.role && (parsed.data.role === 'SYSTEM_ADMIN' || parsed.data.role === 'TECHNICIAN')) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot assign this role' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Cannot change tenantId
|
||||||
|
delete parsed.data.tenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email uniqueness if changing
|
||||||
|
if (parsed.data.email && parsed.data.email !== targetUser.email) {
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email: parsed.data.email } });
|
||||||
|
if (existing) {
|
||||||
|
res.status(409).json({ error: 'Conflict', message: 'A user with this email already exists' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: parsed.data,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
role: true,
|
||||||
|
tenantId: true,
|
||||||
|
active: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/users/:id
|
||||||
|
router.delete('/:id', authorize('SYSTEM_ADMIN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const currentUser = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
if (id === currentUser.id) {
|
||||||
|
res.status(400).json({ error: 'Bad request', message: 'You cannot delete your own account' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUser = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!targetUser) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUser.role === 'CUSTOMER_ADMIN' && targetUser.tenantId !== currentUser.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only delete users in your tenant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { active: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'User deactivated successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/users/:id/password
|
||||||
|
router.patch('/:id/password', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const currentUser = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Users can only change their own password (admins can reset via update)
|
||||||
|
if (id !== currentUser.id && currentUser.role !== 'SYSTEM_ADMIN') {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only change your own password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = changePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!user) {
|
||||||
|
res.status(404).json({ error: 'User not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify current password (skip for SYSTEM_ADMIN resetting another user's password)
|
||||||
|
if (id === currentUser.id) {
|
||||||
|
const validPassword = await bcrypt.compare(parsed.data.currentPassword, user.passwordHash);
|
||||||
|
if (!validPassword) {
|
||||||
|
res.status(400).json({ error: 'Invalid current password' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.newPassword, 12);
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { passwordHash },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Password changed successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
630
backend/src/routes/workflow.routes.ts
Normal file
630
backend/src/routes/workflow.routes.ts
Normal file
@ -0,0 +1,630 @@
|
|||||||
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import prisma from '../lib/prisma';
|
||||||
|
import { authenticate, authorize } from '../middleware/auth';
|
||||||
|
import { WorkflowStatus, StepType } from '@prisma/client';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
const createWorkflowSchema = z.object({
|
||||||
|
title: z.string().min(1, 'Title is required').max(500),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
tenantId: z.string().uuid('Invalid tenant ID'),
|
||||||
|
categoryId: z.string().uuid().optional().nullable(),
|
||||||
|
isTemplate: z.boolean().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
steps: z.array(z.object({
|
||||||
|
order: z.number().int().min(0),
|
||||||
|
title: z.string().min(1),
|
||||||
|
content: z.string().min(1),
|
||||||
|
type: z.nativeEnum(StepType).optional(),
|
||||||
|
isRequired: z.boolean().optional(),
|
||||||
|
})).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateWorkflowSchema = z.object({
|
||||||
|
title: z.string().min(1).max(500).optional(),
|
||||||
|
description: z.string().optional().nullable(),
|
||||||
|
categoryId: z.string().uuid().optional().nullable(),
|
||||||
|
isTemplate: z.boolean().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusChangeSchema = z.object({
|
||||||
|
status: z.nativeEnum(WorkflowStatus),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createStepSchema = z.object({
|
||||||
|
order: z.number().int().min(0),
|
||||||
|
title: z.string().min(1, 'Step title is required'),
|
||||||
|
content: z.string().min(1, 'Step content is required'),
|
||||||
|
type: z.nativeEnum(StepType).optional(),
|
||||||
|
isRequired: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateStepSchema = z.object({
|
||||||
|
order: z.number().int().min(0).optional(),
|
||||||
|
title: z.string().min(1).optional(),
|
||||||
|
content: z.string().min(1).optional(),
|
||||||
|
type: z.nativeEnum(StepType).optional(),
|
||||||
|
isRequired: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderStepsSchema = z.object({
|
||||||
|
steps: z.array(z.object({
|
||||||
|
id: z.string().uuid(),
|
||||||
|
order: z.number().int().min(0),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.use(authenticate);
|
||||||
|
|
||||||
|
function getTenantFilter(user: Express.Request['user']): { tenantId?: string } {
|
||||||
|
if (!user) return {};
|
||||||
|
if (user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return { tenantId: user.tenantId! };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/workflows
|
||||||
|
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const tenantFilter = getTenantFilter(user);
|
||||||
|
|
||||||
|
const {
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
categoryId,
|
||||||
|
tags,
|
||||||
|
tenantId,
|
||||||
|
isTemplate,
|
||||||
|
page = '1',
|
||||||
|
limit = '20',
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const where: any = { ...tenantFilter };
|
||||||
|
|
||||||
|
// Allow SYSTEM_ADMIN/TECHNICIAN to filter by specific tenant
|
||||||
|
if ((user.role === 'SYSTEM_ADMIN' || user.role === 'TECHNICIAN') && tenantId) {
|
||||||
|
where.tenantId = tenantId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
where.OR = [
|
||||||
|
{ title: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
{ description: { contains: search as string, mode: 'insensitive' } },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
where.status = status as WorkflowStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId) {
|
||||||
|
where.categoryId = categoryId as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
const tagArray = (tags as string).split(',');
|
||||||
|
where.tags = { hasSome: tagArray };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isTemplate !== undefined) {
|
||||||
|
where.isTemplate = isTemplate === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = Math.max(1, parseInt(page as string, 10) || 1);
|
||||||
|
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 20));
|
||||||
|
const skip = (pageNum - 1) * limitNum;
|
||||||
|
|
||||||
|
const [workflows, total] = await Promise.all([
|
||||||
|
prisma.workflow.findMany({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
category: { select: { id: true, name: true, icon: true } },
|
||||||
|
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
_count: { select: { steps: true } },
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: 'desc' },
|
||||||
|
skip,
|
||||||
|
take: limitNum,
|
||||||
|
}),
|
||||||
|
prisma.workflow.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
data: workflows,
|
||||||
|
pagination: {
|
||||||
|
page: pageNum,
|
||||||
|
limit: limitNum,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limitNum),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/workflows/:id
|
||||||
|
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
category: { select: { id: true, name: true, icon: true } },
|
||||||
|
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
steps: { orderBy: { order: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant isolation check
|
||||||
|
if (user.role !== 'SYSTEM_ADMIN' && user.role !== 'TECHNICIAN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot access this workflow' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ data: workflow });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/workflows
|
||||||
|
router.post('/', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const parsed = createWorkflowSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = parsed.data;
|
||||||
|
|
||||||
|
// Customer admins can only create in their own tenant
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN') {
|
||||||
|
if (data.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only create workflows in your tenant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify tenant exists
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { id: data.tenantId } });
|
||||||
|
if (!tenant) {
|
||||||
|
res.status(404).json({ error: 'Tenant not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify category if provided
|
||||||
|
if (data.categoryId) {
|
||||||
|
const category = await prisma.category.findUnique({ where: { id: data.categoryId } });
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.create({
|
||||||
|
data: {
|
||||||
|
title: data.title,
|
||||||
|
description: data.description ?? null,
|
||||||
|
tenantId: data.tenantId,
|
||||||
|
categoryId: data.categoryId ?? null,
|
||||||
|
isTemplate: data.isTemplate ?? false,
|
||||||
|
tags: data.tags ?? [],
|
||||||
|
createdById: user.id,
|
||||||
|
steps: data.steps ? {
|
||||||
|
create: data.steps.map((step) => ({
|
||||||
|
order: step.order,
|
||||||
|
title: step.title,
|
||||||
|
content: step.content,
|
||||||
|
type: step.type ?? 'INSTRUCTION',
|
||||||
|
isRequired: step.isRequired ?? true,
|
||||||
|
})),
|
||||||
|
} : undefined,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: { select: { id: true, name: true, icon: true } },
|
||||||
|
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
steps: { orderBy: { order: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: workflow });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/workflows/:id
|
||||||
|
router.put('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const parsed = updateWorkflowSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant isolation
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot edit this workflow' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify category if changing
|
||||||
|
if (parsed.data.categoryId) {
|
||||||
|
const category = await prisma.category.findUnique({ where: { id: parsed.data.categoryId } });
|
||||||
|
if (!category) {
|
||||||
|
res.status(404).json({ error: 'Category not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.workflow.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...parsed.data,
|
||||||
|
updatedById: user.id,
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: { select: { id: true, name: true, icon: true } },
|
||||||
|
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
steps: { orderBy: { order: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/workflows/:id/status
|
||||||
|
router.patch('/:id/status', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const parsed = statusChangeSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot modify this workflow' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.workflow.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
status: parsed.data.status,
|
||||||
|
updatedById: user.id,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: { select: { id: true, name: true, icon: true } },
|
||||||
|
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
updatedBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/workflows/:id
|
||||||
|
router.delete('/:id', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot delete this workflow' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.workflow.delete({ where: { id } });
|
||||||
|
|
||||||
|
res.json({ message: 'Workflow deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/workflows/:id/duplicate
|
||||||
|
router.post('/:id/duplicate', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const targetTenantId = req.body.tenantId as string | undefined;
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { steps: { orderBy: { order: 'asc' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant isolation
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You cannot duplicate this workflow' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target tenant
|
||||||
|
const duplicateTenantId = targetTenantId || (user.role === 'CUSTOMER_ADMIN' ? user.tenantId! : workflow.tenantId);
|
||||||
|
|
||||||
|
// Customer admins can only duplicate into their own tenant
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && duplicateTenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden', message: 'You can only duplicate into your own tenant' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicated = await prisma.workflow.create({
|
||||||
|
data: {
|
||||||
|
title: `${workflow.title} (Copy)`,
|
||||||
|
description: workflow.description,
|
||||||
|
tenantId: duplicateTenantId,
|
||||||
|
categoryId: workflow.categoryId,
|
||||||
|
status: 'DRAFT',
|
||||||
|
isTemplate: false,
|
||||||
|
tags: workflow.tags,
|
||||||
|
createdById: user.id,
|
||||||
|
steps: {
|
||||||
|
create: workflow.steps.map((step) => ({
|
||||||
|
order: step.order,
|
||||||
|
title: step.title,
|
||||||
|
content: step.content,
|
||||||
|
type: step.type,
|
||||||
|
isRequired: step.isRequired,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
category: { select: { id: true, name: true, icon: true } },
|
||||||
|
createdBy: { select: { id: true, firstName: true, lastName: true, email: true } },
|
||||||
|
tenant: { select: { id: true, name: true, slug: true } },
|
||||||
|
steps: { orderBy: { order: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: duplicated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Workflow Steps ---
|
||||||
|
|
||||||
|
// POST /api/workflows/:id/steps
|
||||||
|
router.post('/:id/steps', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const parsed = createStepSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = await prisma.workflowStep.create({
|
||||||
|
data: {
|
||||||
|
workflowId: id,
|
||||||
|
order: parsed.data.order,
|
||||||
|
title: parsed.data.title,
|
||||||
|
content: parsed.data.content,
|
||||||
|
type: parsed.data.type ?? 'INSTRUCTION',
|
||||||
|
isRequired: parsed.data.isRequired ?? true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update workflow's updatedBy
|
||||||
|
await prisma.workflow.update({
|
||||||
|
where: { id },
|
||||||
|
data: { updatedById: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(201).json({ data: step });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/workflows/:id/steps/:stepId
|
||||||
|
router.put('/:id/steps/:stepId', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id, stepId } = req.params;
|
||||||
|
|
||||||
|
const parsed = updateStepSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = await prisma.workflowStep.findFirst({
|
||||||
|
where: { id: stepId, workflowId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
res.status(404).json({ error: 'Step not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.workflowStep.update({
|
||||||
|
where: { id: stepId },
|
||||||
|
data: parsed.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.workflow.update({
|
||||||
|
where: { id },
|
||||||
|
data: { updatedById: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updated });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/workflows/:id/steps/:stepId
|
||||||
|
router.delete('/:id/steps/:stepId', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id, stepId } = req.params;
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = await prisma.workflowStep.findFirst({
|
||||||
|
where: { id: stepId, workflowId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!step) {
|
||||||
|
res.status(404).json({ error: 'Step not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.workflowStep.delete({ where: { id: stepId } });
|
||||||
|
|
||||||
|
await prisma.workflow.update({
|
||||||
|
where: { id },
|
||||||
|
data: { updatedById: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ message: 'Step deleted successfully' });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PATCH /api/workflows/:id/steps/reorder
|
||||||
|
router.patch('/:id/steps/reorder', authorize('SYSTEM_ADMIN', 'TECHNICIAN', 'CUSTOMER_ADMIN'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const user = req.user!;
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const parsed = reorderStepsSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: 'Validation error', details: parsed.error.flatten().fieldErrors });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workflow = await prisma.workflow.findUnique({ where: { id } });
|
||||||
|
if (!workflow) {
|
||||||
|
res.status(404).json({ error: 'Workflow not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.role === 'CUSTOMER_ADMIN' && workflow.tenantId !== user.tenantId) {
|
||||||
|
res.status(403).json({ error: 'Forbidden' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all step orders in a transaction
|
||||||
|
await prisma.$transaction(
|
||||||
|
parsed.data.steps.map((step) =>
|
||||||
|
prisma.workflowStep.update({
|
||||||
|
where: { id: step.id },
|
||||||
|
data: { order: step.order },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await prisma.workflow.update({
|
||||||
|
where: { id },
|
||||||
|
data: { updatedById: user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatedSteps = await prisma.workflowStep.findMany({
|
||||||
|
where: { workflowId: id },
|
||||||
|
orderBy: { order: 'asc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ data: updatedSteps });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
18
backend/src/types/express.d.ts
vendored
Normal file
18
backend/src/types/express.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { Role } from '@prisma/client';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
namespace Express {
|
||||||
|
interface Request {
|
||||||
|
user?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: Role;
|
||||||
|
tenantId: string | null;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
20
backend/tsconfig.json
Normal file
20
backend/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "commonjs",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
16
ecosystem.config.js
Normal file
16
ecosystem.config.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'workflow-backend',
|
||||||
|
cwd: './backend',
|
||||||
|
script: 'dist/index.js',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: 4000
|
||||||
|
},
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
max_memory_restart: '500M'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Workflow Portal</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 text-gray-900 antialiased">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "workflow-portal-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@headlessui/react": "^2.2.0",
|
||||||
|
"@hello-pangea/dnd": "^17.0.0",
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hot-toast": "^2.4.1",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"vite": "^6.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#2563eb"/><text x="16" y="22" text-anchor="middle" font-family="system-ui" font-weight="bold" font-size="16" fill="white">W</text></svg>
|
||||||
|
After Width: | Height: | Size: 261 B |
99
frontend/src/App.tsx
Normal file
99
frontend/src/App.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
import { ProtectedRoute } from './hooks/useAuth';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import LoginPage from './pages/LoginPage';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import WorkflowList from './pages/WorkflowList';
|
||||||
|
import WorkflowView from './pages/WorkflowView';
|
||||||
|
import WorkflowEditor from './pages/WorkflowEditor';
|
||||||
|
import TenantList from './pages/TenantList';
|
||||||
|
import UserList from './pages/UserList';
|
||||||
|
|
||||||
|
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Layout>{children}</Layout>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<Dashboard />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/workflows"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<WorkflowList />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/workflows/new"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<WorkflowEditor />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/workflows/:id"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<WorkflowView />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/workflows/:id/edit"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<AppLayout>
|
||||||
|
<WorkflowEditor />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/tenants"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SYSTEM_ADMIN', 'TECHNICIAN']}>
|
||||||
|
<AppLayout>
|
||||||
|
<TenantList />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/users"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={['SYSTEM_ADMIN', 'TECHNICIAN']}>
|
||||||
|
<AppLayout>
|
||||||
|
<UserList />
|
||||||
|
</AppLayout>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
frontend/src/components/ConfirmDialog.tsx
Normal file
61
frontend/src/components/ConfirmDialog.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
variant?: 'danger' | 'warning';
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Bestätigen',
|
||||||
|
cancelLabel = 'Abbrechen',
|
||||||
|
variant = 'danger',
|
||||||
|
loading = false,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} size="sm">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div
|
||||||
|
className={`flex h-12 w-12 items-center justify-center rounded-full ${
|
||||||
|
variant === 'danger' ? 'bg-red-100' : 'bg-amber-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<AlertTriangle
|
||||||
|
className={variant === 'danger' ? 'text-red-600' : 'text-amber-600'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-lg font-semibold text-gray-900">{title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">{message}</p>
|
||||||
|
<div className="mt-6 flex w-full gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={loading}
|
||||||
|
className="btn-secondary flex-1"
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex-1 ${variant === 'danger' ? 'btn-danger' : 'btn-primary'}`}
|
||||||
|
>
|
||||||
|
{loading ? 'Bitte warten...' : confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
frontend/src/components/EmptyState.tsx
Normal file
28
frontend/src/components/EmptyState.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Inbox } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon?: React.ElementType;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({
|
||||||
|
icon: Icon = Inbox,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-gray-100">
|
||||||
|
<Icon className="text-gray-400" size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-base font-semibold text-gray-900">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{description}</p>
|
||||||
|
)}
|
||||||
|
{action && <div className="mt-6">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
212
frontend/src/components/Layout.tsx
Normal file
212
frontend/src/components/Layout.tsx
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
import { Fragment, useState } from 'react';
|
||||||
|
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Menu as HeadlessMenu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||||
|
{ name: 'Workflows', href: '/workflows', icon: FileText },
|
||||||
|
{ name: 'Vorlagen', href: '/workflows?template=true', icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
const adminNavigation = [
|
||||||
|
{ name: 'Mandanten', href: '/tenants', icon: Building2 },
|
||||||
|
{ name: 'Benutzer', href: '/users', icon: Users },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user, logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout();
|
||||||
|
navigate('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||||
|
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-primary-50 text-primary-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<>
|
||||||
|
<div className="flex h-16 items-center gap-3 border-b border-gray-200 px-6">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-600 text-sm font-bold text-white">
|
||||||
|
WP
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-sm font-semibold text-gray-900">
|
||||||
|
Workflow Portal
|
||||||
|
</h1>
|
||||||
|
{user?.tenant && (
|
||||||
|
<p className="text-xs text-gray-500">{user.tenant.name}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 space-y-1 overflow-y-auto px-3 py-4">
|
||||||
|
{navigation.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
end={item.href === '/'}
|
||||||
|
className={navLinkClass}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<item.icon size={20} />
|
||||||
|
{item.name}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isAdmin && (
|
||||||
|
<>
|
||||||
|
<div className="my-4 border-t border-gray-200" />
|
||||||
|
<p className="mb-2 px-3 text-xs font-semibold uppercase tracking-wider text-gray-400">
|
||||||
|
Administration
|
||||||
|
</p>
|
||||||
|
{adminNavigation.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.href}
|
||||||
|
to={item.href}
|
||||||
|
className={navLinkClass}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<item.icon size={20} />
|
||||||
|
{item.name}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700">
|
||||||
|
{user?.firstName?.[0]}
|
||||||
|
{user?.lastName?.[0]}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-gray-900">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-gray-500">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-gray-50">
|
||||||
|
{/* Mobile sidebar overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40 bg-black/30 lg:hidden"
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile sidebar */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-50 flex w-64 flex-col bg-white shadow-xl transition-transform duration-200 lg:hidden ${
|
||||||
|
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
|
className="absolute right-3 top-4 rounded-lg p-1 text-gray-400 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Desktop sidebar */}
|
||||||
|
<aside className="hidden w-64 flex-col border-r border-gray-200 bg-white lg:flex">
|
||||||
|
{sidebarContent}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
{/* Top bar */}
|
||||||
|
<header className="flex h-16 items-center justify-between border-b border-gray-200 bg-white px-4 lg:px-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarOpen(true)}
|
||||||
|
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 lg:hidden"
|
||||||
|
>
|
||||||
|
<Menu size={22} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<HeadlessMenu as="div" className="relative">
|
||||||
|
<MenuButton className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
|
<User size={18} />
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{user?.firstName} {user?.lastName}
|
||||||
|
</span>
|
||||||
|
</MenuButton>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition ease-out duration-100"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="transition ease-in duration-75"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<MenuItems className="absolute right-0 mt-2 w-48 origin-top-right rounded-lg border border-gray-200 bg-white py-1 shadow-lg focus:outline-none">
|
||||||
|
<MenuItem>
|
||||||
|
{({ focus }) => (
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm ${
|
||||||
|
focus ? 'bg-gray-50 text-gray-900' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<User size={16} />
|
||||||
|
Profil
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem>
|
||||||
|
{({ focus }) => (
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className={`flex w-full items-center gap-2 px-4 py-2 text-sm ${
|
||||||
|
focus ? 'bg-gray-50 text-gray-900' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<LogOut size={16} />
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuItems>
|
||||||
|
</Transition>
|
||||||
|
</HeadlessMenu>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 lg:p-8">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
frontend/src/components/Modal.tsx
Normal file
77
frontend/src/components/Modal.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
size?: 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'max-w-md',
|
||||||
|
md: 'max-w-lg',
|
||||||
|
lg: 'max-w-2xl',
|
||||||
|
xl: 'max-w-4xl',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
size = 'md',
|
||||||
|
}: ModalProps) {
|
||||||
|
return (
|
||||||
|
<Transition show={open} as={Fragment}>
|
||||||
|
<Dialog as="div" className="relative z-50" onClose={onClose}>
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
|
||||||
|
</TransitionChild>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<TransitionChild
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<DialogPanel
|
||||||
|
className={`w-full ${sizeClasses[size]} transform rounded-xl bg-white p-6 shadow-xl transition-all`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{title && (
|
||||||
|
<DialogTitle className="text-lg font-semibold text-gray-900">
|
||||||
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="ml-auto rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">{children}</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
62
frontend/src/components/Pagination.tsx
Normal file
62
frontend/src/components/Pagination.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
onPageChange,
|
||||||
|
}: PaginationProps) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const pages: (number | '...')[] = [];
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= page - 1 && i <= page + 1)) {
|
||||||
|
pages.push(i);
|
||||||
|
} else if (pages[pages.length - 1] !== '...') {
|
||||||
|
pages.push('...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="flex items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={18} />
|
||||||
|
</button>
|
||||||
|
{pages.map((p, idx) =>
|
||||||
|
p === '...' ? (
|
||||||
|
<span key={`dots-${idx}`} className="px-2 text-gray-400">
|
||||||
|
...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`min-w-[36px] rounded-lg px-3 py-1.5 text-sm font-medium ${
|
||||||
|
p === page
|
||||||
|
? 'bg-primary-600 text-white'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
frontend/src/components/SearchBar.tsx
Normal file
58
frontend/src/components/SearchBar.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { Search, X } from 'lucide-react';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface SearchBarProps {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
debounceMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Suchen...',
|
||||||
|
debounceMs = 300,
|
||||||
|
}: SearchBarProps) {
|
||||||
|
const [internal, setInternal] = useState(value);
|
||||||
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setInternal(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
setInternal(val);
|
||||||
|
clearTimeout(timerRef.current);
|
||||||
|
timerRef.current = setTimeout(() => onChange(val), debounceMs);
|
||||||
|
},
|
||||||
|
[onChange, debounceMs]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => clearTimeout(timerRef.current), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Search
|
||||||
|
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
size={18}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={internal}
|
||||||
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="input-field pl-10 pr-9"
|
||||||
|
/>
|
||||||
|
{internal && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleChange('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
frontend/src/components/StatusBadge.tsx
Normal file
38
frontend/src/components/StatusBadge.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import type { Workflow } from '../types';
|
||||||
|
|
||||||
|
const statusConfig: Record<
|
||||||
|
Workflow['status'],
|
||||||
|
{ label: string; className: string }
|
||||||
|
> = {
|
||||||
|
DRAFT: {
|
||||||
|
label: 'Entwurf',
|
||||||
|
className: 'bg-gray-100 text-gray-700 ring-gray-300',
|
||||||
|
},
|
||||||
|
REVIEW: {
|
||||||
|
label: 'In Prüfung',
|
||||||
|
className: 'bg-yellow-50 text-yellow-700 ring-yellow-300',
|
||||||
|
},
|
||||||
|
PUBLISHED: {
|
||||||
|
label: 'Veröffentlicht',
|
||||||
|
className: 'bg-green-50 text-green-700 ring-green-300',
|
||||||
|
},
|
||||||
|
ARCHIVED: {
|
||||||
|
label: 'Archiviert',
|
||||||
|
className: 'bg-red-50 text-red-700 ring-red-300',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: Workflow['status'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatusBadge({ status }: StatusBadgeProps) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ring-1 ring-inset ${config.className}`}
|
||||||
|
>
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
frontend/src/components/StepTypeIcon.tsx
Normal file
40
frontend/src/components/StepTypeIcon.tsx
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
CheckSquare,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { WorkflowStep } from '../types';
|
||||||
|
|
||||||
|
const typeConfig: Record<
|
||||||
|
WorkflowStep['type'],
|
||||||
|
{ icon: React.ElementType; label: string; color: string }
|
||||||
|
> = {
|
||||||
|
INSTRUCTION: { icon: FileText, label: 'Anleitung', color: 'text-blue-600' },
|
||||||
|
CHECKLIST: { icon: CheckSquare, label: 'Checkliste', color: 'text-green-600' },
|
||||||
|
WARNING: { icon: AlertTriangle, label: 'Warnung', color: 'text-amber-600' },
|
||||||
|
INFO: { icon: Info, label: 'Info', color: 'text-cyan-600' },
|
||||||
|
COMMAND: { icon: Terminal, label: 'Befehl', color: 'text-purple-600' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StepTypeIconProps {
|
||||||
|
type: WorkflowStep['type'];
|
||||||
|
showLabel?: boolean;
|
||||||
|
size?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StepTypeIcon({
|
||||||
|
type,
|
||||||
|
showLabel = false,
|
||||||
|
size = 18,
|
||||||
|
}: StepTypeIconProps) {
|
||||||
|
const config = typeConfig[type];
|
||||||
|
const Icon = config.icon;
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 ${config.color}`}>
|
||||||
|
<Icon size={size} />
|
||||||
|
{showLabel && <span className="text-xs font-medium">{config.label}</span>}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
frontend/src/hooks/useAuth.tsx
Normal file
111
frontend/src/hooks/useAuth.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import type { User } from '../types';
|
||||||
|
import { authApi } from '../services/api';
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
|
const loadUser = useCallback(async () => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (!token) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const userData = await authApi.me();
|
||||||
|
setUser(userData);
|
||||||
|
} catch {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
setUser(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUser();
|
||||||
|
}, [loadUser]);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const response = await authApi.login(email, password);
|
||||||
|
localStorage.setItem('accessToken', response.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', response.refreshToken);
|
||||||
|
setUser(response.user);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
setUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ user, isAuthenticated, loading, login, logout }),
|
||||||
|
[user, isAuthenticated, loading, login, logout]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextType {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
requiredRoles?: User['role'][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProtectedRoute({ children, requiredRoles }: ProtectedRouteProps) {
|
||||||
|
const { isAuthenticated, loading, user } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requiredRoles && user && !requiredRoles.includes(user.role)) {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
30
frontend/src/index.css
Normal file
30
frontend/src/index.css
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
@apply scroll-smooth;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply min-h-screen bg-gray-50 text-gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn-primary {
|
||||||
|
@apply inline-flex items-center justify-center rounded-lg bg-primary-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
@apply inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.btn-danger {
|
||||||
|
@apply inline-flex items-center justify-center rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
.input-field {
|
||||||
|
@apply block w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-500;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
@apply rounded-xl border border-gray-200 bg-white shadow-sm;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
frontend/src/main.tsx
Normal file
24
frontend/src/main.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import { AuthProvider } from './hooks/useAuth';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: { background: '#1f2937', color: '#f9fafb' },
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>
|
||||||
|
);
|
||||||
235
frontend/src/pages/Dashboard.tsx
Normal file
235
frontend/src/pages/Dashboard.tsx
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Archive,
|
||||||
|
Clock,
|
||||||
|
FileCheck,
|
||||||
|
FilePlus,
|
||||||
|
FileText,
|
||||||
|
Layers,
|
||||||
|
Plus,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
import { workflowsApi, tenantsApi } from '../services/api';
|
||||||
|
import type { Tenant, Workflow } from '../types';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
total: number;
|
||||||
|
published: number;
|
||||||
|
review: number;
|
||||||
|
draft: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [stats, setStats] = useState<Stats>({ total: 0, published: 0, review: 0, draft: 0 });
|
||||||
|
const [recent, setRecent] = useState<Workflow[]>([]);
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [selectedTenant, setSelectedTenant] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const isAdmin = user?.role === 'SYSTEM_ADMIN' || user?.role === 'TECHNICIAN';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
if (isAdmin) {
|
||||||
|
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Record<string, unknown> = { page: 1, pageSize: 5 };
|
||||||
|
if (selectedTenant) params.tenantId = selectedTenant;
|
||||||
|
|
||||||
|
const [allRes, pubRes, revRes, draftRes] = await Promise.all([
|
||||||
|
workflowsApi.list({ ...params, pageSize: 1 } as Parameters<typeof workflowsApi.list>[0]),
|
||||||
|
workflowsApi.list({ ...params, pageSize: 1, status: 'PUBLISHED' } as Parameters<typeof workflowsApi.list>[0]),
|
||||||
|
workflowsApi.list({ ...params, pageSize: 1, status: 'REVIEW' } as Parameters<typeof workflowsApi.list>[0]),
|
||||||
|
workflowsApi.list({ ...params, pageSize: 1, status: 'DRAFT' } as Parameters<typeof workflowsApi.list>[0]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setStats({
|
||||||
|
total: allRes.total,
|
||||||
|
published: pubRes.total,
|
||||||
|
review: revRes.total,
|
||||||
|
draft: draftRes.total,
|
||||||
|
});
|
||||||
|
|
||||||
|
const recentRes = await workflowsApi.list(params as Parameters<typeof workflowsApi.list>[0]);
|
||||||
|
setRecent(recentRes.data);
|
||||||
|
} catch {
|
||||||
|
toast.error('Daten konnten nicht geladen werden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [selectedTenant]);
|
||||||
|
|
||||||
|
const statCards = [
|
||||||
|
{
|
||||||
|
label: 'Gesamt',
|
||||||
|
value: stats.total,
|
||||||
|
icon: FileText,
|
||||||
|
color: 'bg-primary-50 text-primary-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Veröffentlicht',
|
||||||
|
value: stats.published,
|
||||||
|
icon: FileCheck,
|
||||||
|
color: 'bg-green-50 text-green-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'In Prüfung',
|
||||||
|
value: stats.review,
|
||||||
|
icon: Clock,
|
||||||
|
color: 'bg-yellow-50 text-yellow-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Entwurf',
|
||||||
|
value: stats.draft,
|
||||||
|
icon: Archive,
|
||||||
|
color: 'bg-gray-100 text-gray-600',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Willkommen, {user?.firstName}!
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Hier ist eine Übersicht Ihrer Workflows.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isAdmin && tenants.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={selectedTenant}
|
||||||
|
onChange={(e) => setSelectedTenant(e.target.value)}
|
||||||
|
className="input-field w-auto"
|
||||||
|
>
|
||||||
|
<option value="">Alle Mandanten</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||||
|
{statCards.map((card) => (
|
||||||
|
<div key={card.label} className="card p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div
|
||||||
|
className={`flex h-10 w-10 items-center justify-center rounded-lg ${card.color}`}
|
||||||
|
>
|
||||||
|
<card.icon size={20} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
|
{loading ? '-' : card.value}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">{card.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Link
|
||||||
|
to="/workflows/new"
|
||||||
|
className="card flex items-center gap-4 p-5 transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary-50">
|
||||||
|
<Plus className="text-primary-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Neuer Workflow</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Erstellen Sie einen neuen Workflow
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/workflows?template=true"
|
||||||
|
className="card flex items-center gap-4 p-5 transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-purple-50">
|
||||||
|
<Layers className="text-purple-600" size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold text-gray-900">Vorlagen durchsuchen</h3>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Starten Sie mit einer bestehenden Vorlage
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent workflows */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4">
|
||||||
|
<h2 className="text-base font-semibold text-gray-900">
|
||||||
|
Aktuelle Workflows
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
to="/workflows"
|
||||||
|
className="text-sm font-medium text-primary-600 hover:text-primary-700"
|
||||||
|
>
|
||||||
|
Alle anzeigen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : recent.length === 0 ? (
|
||||||
|
<div className="py-12 text-center text-sm text-gray-500">
|
||||||
|
Noch keine Workflows vorhanden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-100">
|
||||||
|
{recent.map((wf) => (
|
||||||
|
<li key={wf.id}>
|
||||||
|
<Link
|
||||||
|
to={`/workflows/${wf.id}`}
|
||||||
|
className="flex items-center gap-4 px-6 py-4 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<FilePlus className="text-gray-500" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-gray-900">
|
||||||
|
{wf.title}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-sm text-gray-500">
|
||||||
|
{wf.steps?.length || 0} Schritte · v{wf.version}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={wf.status} />
|
||||||
|
<span className="hidden text-xs text-gray-400 sm:inline">
|
||||||
|
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
frontend/src/pages/LoginPage.tsx
Normal file
157
frontend/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
|
import { Eye, EyeOff, LogIn } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname || '/';
|
||||||
|
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await login(email, password);
|
||||||
|
toast.success('Erfolgreich angemeldet');
|
||||||
|
navigate(from, { replace: true });
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message =
|
||||||
|
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||||
|
?.message || 'Anmeldung fehlgeschlagen. Bitte prüfen Sie Ihre Zugangsdaten.';
|
||||||
|
setError(message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* Left branding panel */}
|
||||||
|
<div className="hidden flex-1 flex-col justify-center bg-primary-600 px-12 lg:flex">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-white/20 text-xl font-bold text-white">
|
||||||
|
WP
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Workflow Portal</h1>
|
||||||
|
</div>
|
||||||
|
<p className="mt-6 text-lg text-primary-100">
|
||||||
|
Verwalten Sie Ihre Workflows, Dokumentationen und Anleitungen an
|
||||||
|
einem zentralen Ort. Effizient, strukturiert und teamübergreifend.
|
||||||
|
</p>
|
||||||
|
<div className="mt-10 space-y-4">
|
||||||
|
{[
|
||||||
|
'Workflows erstellen und veröffentlichen',
|
||||||
|
'Schritt-für-Schritt Anleitungen',
|
||||||
|
'Multi-Mandanten Unterstützung',
|
||||||
|
'Vorlagen für wiederkehrende Prozesse',
|
||||||
|
].map((feature) => (
|
||||||
|
<div key={feature} className="flex items-center gap-3 text-primary-100">
|
||||||
|
<div className="h-1.5 w-1.5 rounded-full bg-primary-300" />
|
||||||
|
{feature}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right login form */}
|
||||||
|
<div className="flex flex-1 items-center justify-center px-6 py-12">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-8 text-center lg:hidden">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-xl font-bold text-white">
|
||||||
|
WP
|
||||||
|
</div>
|
||||||
|
<h1 className="mt-3 text-xl font-bold text-gray-900">
|
||||||
|
Workflow Portal
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900">Anmelden</h2>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">
|
||||||
|
Melden Sie sich mit Ihren Zugangsdaten an.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="name@firma.de"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="mb-1.5 block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="Passwort eingeben"
|
||||||
|
className="input-field pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || !email || !password}
|
||||||
|
className="btn-primary w-full gap-2"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||||
|
) : (
|
||||||
|
<LogIn size={18} />
|
||||||
|
)}
|
||||||
|
{loading ? 'Anmeldung läuft...' : 'Anmelden'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
340
frontend/src/pages/TenantList.tsx
Normal file
340
frontend/src/pages/TenantList.tsx
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Building2,
|
||||||
|
Check,
|
||||||
|
Edit,
|
||||||
|
Plus,
|
||||||
|
Power,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { tenantsApi } from '../services/api';
|
||||||
|
import type { Tenant } from '../types';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
import SearchBar from '../components/SearchBar';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
|
||||||
|
export default function TenantList() {
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
|
||||||
|
const [formName, setFormName] = useState('');
|
||||||
|
const [formSlug, setFormSlug] = useState('');
|
||||||
|
const [formActive, setFormActive] = useState(true);
|
||||||
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
|
||||||
|
// Deactivate dialog
|
||||||
|
const [deactivateTarget, setDeactivateTarget] = useState<Tenant | null>(null);
|
||||||
|
const [deactivating, setDeactivating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadTenants();
|
||||||
|
}, [search, page]);
|
||||||
|
|
||||||
|
const loadTenants = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await tenantsApi.list({
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
search: search || undefined,
|
||||||
|
});
|
||||||
|
setTenants(res.data);
|
||||||
|
setTotalPages(res.totalPages);
|
||||||
|
} catch {
|
||||||
|
toast.error('Mandanten konnten nicht geladen werden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingTenant(null);
|
||||||
|
setFormName('');
|
||||||
|
setFormSlug('');
|
||||||
|
setFormActive(true);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (tenant: Tenant) => {
|
||||||
|
setEditingTenant(tenant);
|
||||||
|
setFormName(tenant.name);
|
||||||
|
setFormSlug(tenant.slug);
|
||||||
|
setFormActive(tenant.active);
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formName.trim() || !formSlug.trim()) {
|
||||||
|
toast.error('Name und Slug sind erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormSaving(true);
|
||||||
|
try {
|
||||||
|
if (editingTenant) {
|
||||||
|
await tenantsApi.update(editingTenant.id, {
|
||||||
|
name: formName,
|
||||||
|
slug: formSlug,
|
||||||
|
active: formActive,
|
||||||
|
});
|
||||||
|
toast.success('Mandant aktualisiert');
|
||||||
|
} else {
|
||||||
|
await tenantsApi.create({
|
||||||
|
name: formName,
|
||||||
|
slug: formSlug,
|
||||||
|
active: formActive,
|
||||||
|
});
|
||||||
|
toast.success('Mandant erstellt');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
loadTenants();
|
||||||
|
} catch {
|
||||||
|
toast.error('Speichern fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async () => {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
|
setDeactivating(true);
|
||||||
|
try {
|
||||||
|
await tenantsApi.update(deactivateTarget.id, {
|
||||||
|
active: !deactivateTarget.active,
|
||||||
|
});
|
||||||
|
toast.success(
|
||||||
|
deactivateTarget.active
|
||||||
|
? 'Mandant deaktiviert'
|
||||||
|
: 'Mandant aktiviert'
|
||||||
|
);
|
||||||
|
setDeactivateTarget(null);
|
||||||
|
loadTenants();
|
||||||
|
} catch {
|
||||||
|
toast.error('Aktion fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setDeactivating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Mandanten</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Verwalten Sie die Mandanten des Portals.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="btn-primary gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Neuer Mandant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="max-w-md">
|
||||||
|
<SearchBar
|
||||||
|
value={search}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSearch(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Mandanten suchen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : tenants.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Building2}
|
||||||
|
title="Keine Mandanten gefunden"
|
||||||
|
description="Erstellen Sie den ersten Mandanten."
|
||||||
|
action={
|
||||||
|
<button onClick={openCreate} className="btn-primary gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Neuer Mandant
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Slug
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Status
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
|
{tenants.map((tenant) => (
|
||||||
|
<tr key={tenant.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary-50 text-sm font-bold text-primary-700">
|
||||||
|
{tenant.name[0]}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{tenant.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{tenant.slug}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
{tenant.active ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2.5 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-300">
|
||||||
|
<Check size={12} />
|
||||||
|
Aktiv
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-600 ring-1 ring-inset ring-gray-300">
|
||||||
|
<X size={12} />
|
||||||
|
Inaktiv
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(tenant)}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeactivateTarget(tenant)}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
title={tenant.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
>
|
||||||
|
<Power size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title={editingTenant ? 'Mandant bearbeiten' : 'Neuer Mandant'}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formName}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormName(e.target.value);
|
||||||
|
if (!editingTenant) {
|
||||||
|
setFormSlug(
|
||||||
|
e.target.value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/(^-|-$)/g, '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Firmenname GmbH"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Slug *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formSlug}
|
||||||
|
onChange={(e) => setFormSlug(e.target.value)}
|
||||||
|
placeholder="firmenname-gmbh"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formActive}
|
||||||
|
onChange={(e) => setFormActive(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Aktiv</span>
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={formSaving}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{formSaving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Deactivate Confirm */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deactivateTarget}
|
||||||
|
onClose={() => setDeactivateTarget(null)}
|
||||||
|
onConfirm={handleDeactivate}
|
||||||
|
title={
|
||||||
|
deactivateTarget?.active
|
||||||
|
? 'Mandant deaktivieren?'
|
||||||
|
: 'Mandant aktivieren?'
|
||||||
|
}
|
||||||
|
message={
|
||||||
|
deactivateTarget?.active
|
||||||
|
? `Der Mandant "${deactivateTarget?.name}" wird deaktiviert. Benutzer des Mandanten können sich nicht mehr anmelden.`
|
||||||
|
: `Der Mandant "${deactivateTarget?.name}" wird wieder aktiviert.`
|
||||||
|
}
|
||||||
|
confirmLabel={deactivateTarget?.active ? 'Deaktivieren' : 'Aktivieren'}
|
||||||
|
variant={deactivateTarget?.active ? 'danger' : 'warning'}
|
||||||
|
loading={deactivating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
437
frontend/src/pages/UserList.tsx
Normal file
437
frontend/src/pages/UserList.tsx
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
Edit,
|
||||||
|
Plus,
|
||||||
|
Power,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { usersApi, tenantsApi } from '../services/api';
|
||||||
|
import type { Tenant, User } from '../types';
|
||||||
|
import Modal from '../components/Modal';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
import SearchBar from '../components/SearchBar';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
|
||||||
|
const roleLabels: Record<User['role'], string> = {
|
||||||
|
SYSTEM_ADMIN: 'System-Admin',
|
||||||
|
TECHNICIAN: 'Techniker',
|
||||||
|
CUSTOMER_ADMIN: 'Kunden-Admin',
|
||||||
|
CUSTOMER_USER: 'Kunden-Benutzer',
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleOptions: { value: User['role']; label: string }[] = [
|
||||||
|
{ value: 'SYSTEM_ADMIN', label: 'System-Admin' },
|
||||||
|
{ value: 'TECHNICIAN', label: 'Techniker' },
|
||||||
|
{ value: 'CUSTOMER_ADMIN', label: 'Kunden-Admin' },
|
||||||
|
{ value: 'CUSTOMER_USER', label: 'Kunden-Benutzer' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function UserList() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('');
|
||||||
|
const [tenantFilter, setTenantFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
const [formFirstName, setFormFirstName] = useState('');
|
||||||
|
const [formLastName, setFormLastName] = useState('');
|
||||||
|
const [formEmail, setFormEmail] = useState('');
|
||||||
|
const [formPassword, setFormPassword] = useState('');
|
||||||
|
const [formRole, setFormRole] = useState<User['role']>('CUSTOMER_USER');
|
||||||
|
const [formTenantId, setFormTenantId] = useState('');
|
||||||
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
|
|
||||||
|
// Deactivate dialog
|
||||||
|
const [deactivateTarget, setDeactivateTarget] = useState<User | null>(null);
|
||||||
|
const [deactivating, setDeactivating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tenantsApi.list({ pageSize: 100 }).then((r) => setTenants(r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadUsers();
|
||||||
|
}, [search, roleFilter, tenantFilter, page]);
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await usersApi.list({
|
||||||
|
page,
|
||||||
|
pageSize: 20,
|
||||||
|
search: search || undefined,
|
||||||
|
role: roleFilter || undefined,
|
||||||
|
tenantId: tenantFilter || undefined,
|
||||||
|
});
|
||||||
|
setUsers(res.data);
|
||||||
|
setTotalPages(res.totalPages);
|
||||||
|
} catch {
|
||||||
|
toast.error('Benutzer konnten nicht geladen werden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setFormFirstName('');
|
||||||
|
setFormLastName('');
|
||||||
|
setFormEmail('');
|
||||||
|
setFormPassword('');
|
||||||
|
setFormRole('CUSTOMER_USER');
|
||||||
|
setFormTenantId('');
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setFormFirstName(user.firstName);
|
||||||
|
setFormLastName(user.lastName);
|
||||||
|
setFormEmail(user.email);
|
||||||
|
setFormPassword('');
|
||||||
|
setFormRole(user.role);
|
||||||
|
setFormTenantId(user.tenantId || '');
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formFirstName.trim() || !formLastName.trim() || !formEmail.trim()) {
|
||||||
|
toast.error('Vorname, Nachname und E-Mail sind erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!editingUser && !formPassword) {
|
||||||
|
toast.error('Passwort ist erforderlich');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setFormSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: Partial<User> & { password?: string } = {
|
||||||
|
firstName: formFirstName,
|
||||||
|
lastName: formLastName,
|
||||||
|
email: formEmail,
|
||||||
|
role: formRole,
|
||||||
|
tenantId: formTenantId || undefined,
|
||||||
|
};
|
||||||
|
if (formPassword) {
|
||||||
|
payload.password = formPassword;
|
||||||
|
}
|
||||||
|
if (editingUser) {
|
||||||
|
await usersApi.update(editingUser.id, payload);
|
||||||
|
toast.success('Benutzer aktualisiert');
|
||||||
|
} else {
|
||||||
|
await usersApi.create(payload);
|
||||||
|
toast.success('Benutzer erstellt');
|
||||||
|
}
|
||||||
|
setModalOpen(false);
|
||||||
|
loadUsers();
|
||||||
|
} catch {
|
||||||
|
toast.error('Speichern fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setFormSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeactivate = async () => {
|
||||||
|
if (!deactivateTarget) return;
|
||||||
|
setDeactivating(true);
|
||||||
|
try {
|
||||||
|
await usersApi.delete(deactivateTarget.id);
|
||||||
|
toast.success('Benutzer deaktiviert');
|
||||||
|
setDeactivateTarget(null);
|
||||||
|
loadUsers();
|
||||||
|
} catch {
|
||||||
|
toast.error('Aktion fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setDeactivating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Benutzer</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Verwalten Sie die Benutzer und deren Rollen.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={openCreate} className="btn-primary gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Neuer Benutzer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex-1 sm:max-w-md">
|
||||||
|
<SearchBar
|
||||||
|
value={search}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSearch(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Benutzer suchen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setRoleFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input-field w-auto"
|
||||||
|
>
|
||||||
|
<option value="">Alle Rollen</option>
|
||||||
|
{roleOptions.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={tenantFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setTenantFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input-field w-auto"
|
||||||
|
>
|
||||||
|
<option value="">Alle Mandanten</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title="Keine Benutzer gefunden"
|
||||||
|
description="Erstellen Sie den ersten Benutzer."
|
||||||
|
action={
|
||||||
|
<button onClick={openCreate} className="btn-primary gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Neuer Benutzer
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="card overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
E-Mail
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Rolle
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Mandant
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-semibold uppercase tracking-wider text-gray-500">
|
||||||
|
Aktionen
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 bg-white">
|
||||||
|
{users.map((u) => (
|
||||||
|
<tr key={u.id} className="hover:bg-gray-50">
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700">
|
||||||
|
{u.firstName[0]}
|
||||||
|
{u.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{u.firstName} {u.lastName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{u.email}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4">
|
||||||
|
<span className="inline-flex rounded-full bg-primary-50 px-2.5 py-0.5 text-xs font-medium text-primary-700">
|
||||||
|
{roleLabels[u.role]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
|
||||||
|
{u.tenant?.name || '-'}
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-6 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => openEdit(u)}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||||
|
title="Bearbeiten"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeactivateTarget(u)}
|
||||||
|
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-red-500"
|
||||||
|
title="Deaktivieren"
|
||||||
|
>
|
||||||
|
<Power size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create/Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
title={editingUser ? 'Benutzer bearbeiten' : 'Neuer Benutzer'}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Vorname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formFirstName}
|
||||||
|
onChange={(e) => setFormFirstName(e.target.value)}
|
||||||
|
placeholder="Max"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Nachname *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formLastName}
|
||||||
|
onChange={(e) => setFormLastName(e.target.value)}
|
||||||
|
placeholder="Mustermann"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
E-Mail *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formEmail}
|
||||||
|
onChange={(e) => setFormEmail(e.target.value)}
|
||||||
|
placeholder="max@firma.de"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Passwort {editingUser ? '(leer lassen um beizubehalten)' : '*'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={formPassword}
|
||||||
|
onChange={(e) => setFormPassword(e.target.value)}
|
||||||
|
placeholder={editingUser ? 'Neues Passwort' : 'Passwort'}
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Rolle *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formRole}
|
||||||
|
onChange={(e) => setFormRole(e.target.value as User['role'])}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
{roleOptions.map((r) => (
|
||||||
|
<option key={r.value} value={r.value}>
|
||||||
|
{r.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Mandant
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formTenantId}
|
||||||
|
onChange={(e) => setFormTenantId(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value="">Kein Mandant</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={formSaving}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
{formSaving ? 'Speichern...' : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Deactivate Confirm */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deactivateTarget}
|
||||||
|
onClose={() => setDeactivateTarget(null)}
|
||||||
|
onConfirm={handleDeactivate}
|
||||||
|
title="Benutzer deaktivieren?"
|
||||||
|
message={`Der Benutzer "${deactivateTarget?.firstName} ${deactivateTarget?.lastName}" wird deaktiviert und kann sich nicht mehr anmelden.`}
|
||||||
|
confirmLabel="Deaktivieren"
|
||||||
|
loading={deactivating}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
489
frontend/src/pages/WorkflowEditor.tsx
Normal file
489
frontend/src/pages/WorkflowEditor.tsx
Normal file
@ -0,0 +1,489 @@
|
|||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
GripVertical,
|
||||||
|
Plus,
|
||||||
|
Save,
|
||||||
|
Send,
|
||||||
|
Trash2,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { workflowsApi, categoriesApi } from '../services/api';
|
||||||
|
import type { Category, Workflow, WorkflowStep } from '../types';
|
||||||
|
import StepTypeIcon from '../components/StepTypeIcon';
|
||||||
|
|
||||||
|
interface StepDraft {
|
||||||
|
_key: string;
|
||||||
|
id?: string;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type: WorkflowStep['type'];
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepTypes: { value: WorkflowStep['type']; label: string }[] = [
|
||||||
|
{ value: 'INSTRUCTION', label: 'Anleitung' },
|
||||||
|
{ value: 'CHECKLIST', label: 'Checkliste' },
|
||||||
|
{ value: 'WARNING', label: 'Warnung' },
|
||||||
|
{ value: 'INFO', label: 'Info' },
|
||||||
|
{ value: 'COMMAND', label: 'Befehl' },
|
||||||
|
];
|
||||||
|
|
||||||
|
let keyCounter = 0;
|
||||||
|
function nextKey() {
|
||||||
|
return `step_${++keyCounter}_${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowEditor() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const isNew = !id;
|
||||||
|
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [categoryId, setCategoryId] = useState('');
|
||||||
|
const [tagsInput, setTagsInput] = useState('');
|
||||||
|
const [isTemplate, setIsTemplate] = useState(false);
|
||||||
|
const [steps, setSteps] = useState<StepDraft[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [loading, setLoading] = useState(!isNew);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [autoSaved, setAutoSaved] = useState(false);
|
||||||
|
|
||||||
|
const autoSaveTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
categoriesApi.list().then(setCategories).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
workflowsApi
|
||||||
|
.get(id)
|
||||||
|
.then((wf) => {
|
||||||
|
setTitle(wf.title);
|
||||||
|
setDescription(wf.description || '');
|
||||||
|
setCategoryId(wf.categoryId || '');
|
||||||
|
setTagsInput(wf.tags.join(', '));
|
||||||
|
setIsTemplate(wf.isTemplate);
|
||||||
|
const sorted = [...(wf.steps || [])].sort((a, b) => a.order - b.order);
|
||||||
|
setSteps(
|
||||||
|
sorted.map((s) => ({
|
||||||
|
_key: nextKey(),
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
content: s.content,
|
||||||
|
type: s.type,
|
||||||
|
isRequired: s.isRequired,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Workflow konnte nicht geladen werden');
|
||||||
|
navigate('/workflows');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
const parseTags = (): string[] =>
|
||||||
|
tagsInput
|
||||||
|
.split(',')
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const buildPayload = (status?: Workflow['status']) => ({
|
||||||
|
title,
|
||||||
|
description: description || undefined,
|
||||||
|
categoryId: categoryId || undefined,
|
||||||
|
tags: parseTags(),
|
||||||
|
isTemplate,
|
||||||
|
status,
|
||||||
|
steps: steps.map((s, idx) => ({
|
||||||
|
id: s.id,
|
||||||
|
title: s.title,
|
||||||
|
content: s.content,
|
||||||
|
type: s.type,
|
||||||
|
isRequired: s.isRequired,
|
||||||
|
order: idx + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = useCallback(
|
||||||
|
async (status?: Workflow['status']) => {
|
||||||
|
if (!title.trim()) {
|
||||||
|
toast.error('Bitte geben Sie einen Titel ein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = buildPayload(status);
|
||||||
|
if (isNew) {
|
||||||
|
const created = await workflowsApi.create({
|
||||||
|
...payload,
|
||||||
|
status: status || 'DRAFT',
|
||||||
|
} as Partial<Workflow>);
|
||||||
|
toast.success('Workflow erstellt');
|
||||||
|
navigate(`/workflows/${created.id}/edit`, { replace: true });
|
||||||
|
} else {
|
||||||
|
await workflowsApi.update(id!, payload as Partial<Workflow>);
|
||||||
|
toast.success('Workflow gespeichert');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Speichern fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[title, description, categoryId, tagsInput, isTemplate, steps, isNew, id, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Auto-save (only for existing workflows)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNew || loading) return;
|
||||||
|
clearTimeout(autoSaveTimer.current);
|
||||||
|
autoSaveTimer.current = setTimeout(async () => {
|
||||||
|
if (!title.trim()) return;
|
||||||
|
try {
|
||||||
|
await workflowsApi.update(id!, buildPayload() as Partial<Workflow>);
|
||||||
|
setAutoSaved(true);
|
||||||
|
setTimeout(() => setAutoSaved(false), 2000);
|
||||||
|
} catch {
|
||||||
|
// silent
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
return () => clearTimeout(autoSaveTimer.current);
|
||||||
|
}, [title, description, categoryId, tagsInput, isTemplate, steps, isNew, loading, id]);
|
||||||
|
|
||||||
|
const addStep = () => {
|
||||||
|
setSteps((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
_key: nextKey(),
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
type: 'INSTRUCTION',
|
||||||
|
isRequired: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeStep = (index: number) => {
|
||||||
|
setSteps((prev) => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateStep = (index: number, field: keyof StepDraft, value: unknown) => {
|
||||||
|
setSteps((prev) =>
|
||||||
|
prev.map((s, i) => (i === index ? { ...s, [field]: value } : s))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragEnd = (result: DropResult) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
const from = result.source.index;
|
||||||
|
const to = result.destination.index;
|
||||||
|
if (from === to) return;
|
||||||
|
setSteps((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
const [moved] = next.splice(from, 1);
|
||||||
|
next.splice(to, 0, moved);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Zurück
|
||||||
|
</button>
|
||||||
|
{autoSaved && (
|
||||||
|
<span className="text-xs text-green-600">Automatisch gespeichert</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{isNew ? 'Neuer Workflow' : 'Workflow bearbeiten'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Metadata */}
|
||||||
|
<div className="card space-y-5 p-6">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Titel *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="z.B. Server-Neustart Anleitung"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Beschreibung
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="Kurze Beschreibung des Workflows..."
|
||||||
|
rows={3}
|
||||||
|
className="input-field resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Kategorie
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={categoryId}
|
||||||
|
onChange={(e) => setCategoryId(e.target.value)}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
<option value="">Keine Kategorie</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-gray-700">
|
||||||
|
Tags (kommagetrennt)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagsInput}
|
||||||
|
onChange={(e) => setTagsInput(e.target.value)}
|
||||||
|
placeholder="server, linux, wartung"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isTemplate}
|
||||||
|
onChange={(e) => setIsTemplate(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Als Vorlage speichern</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Schritte</h2>
|
||||||
|
<button onClick={addStep} className="btn-secondary gap-1.5 text-sm">
|
||||||
|
<Plus size={16} />
|
||||||
|
Schritt hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DragDropContext onDragEnd={handleDragEnd}>
|
||||||
|
<Droppable droppableId="steps">
|
||||||
|
{(provided) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.droppableProps}
|
||||||
|
className="space-y-3"
|
||||||
|
>
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<Draggable
|
||||||
|
key={step._key}
|
||||||
|
draggableId={step._key}
|
||||||
|
index={index}
|
||||||
|
>
|
||||||
|
{(provided, snapshot) => (
|
||||||
|
<div
|
||||||
|
ref={provided.innerRef}
|
||||||
|
{...provided.draggableProps}
|
||||||
|
className={`card overflow-hidden ${
|
||||||
|
snapshot.isDragging ? 'shadow-lg ring-2 ring-primary-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 border-b border-gray-100 bg-gray-50 px-4 py-2">
|
||||||
|
<div
|
||||||
|
{...provided.dragHandleProps}
|
||||||
|
className="cursor-grab text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<GripVertical size={18} />
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-medium text-gray-500">
|
||||||
|
Schritt {index + 1}
|
||||||
|
</span>
|
||||||
|
<StepTypeIcon type={step.type} />
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => removeStep(index)}
|
||||||
|
className="rounded p-1 text-gray-400 hover:bg-red-50 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 p-4">
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||||
|
Titel
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={step.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateStep(index, 'title', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder="Schritt-Titel"
|
||||||
|
className="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||||
|
Typ
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={step.type}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateStep(
|
||||||
|
index,
|
||||||
|
'type',
|
||||||
|
e.target.value as WorkflowStep['type']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="input-field"
|
||||||
|
>
|
||||||
|
{stepTypes.map((t) => (
|
||||||
|
<option key={t.value} value={t.value}>
|
||||||
|
{t.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={step.isRequired}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateStep(
|
||||||
|
index,
|
||||||
|
'isRequired',
|
||||||
|
e.target.checked
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="h-4 w-4 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-600">
|
||||||
|
Pflicht
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-500">
|
||||||
|
Inhalt
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={step.content}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateStep(index, 'content', e.target.value)
|
||||||
|
}
|
||||||
|
placeholder={
|
||||||
|
step.type === 'COMMAND'
|
||||||
|
? 'Befehl eingeben...'
|
||||||
|
: 'Schritt-Inhalt beschreiben...'
|
||||||
|
}
|
||||||
|
rows={step.type === 'COMMAND' ? 3 : 4}
|
||||||
|
className={`input-field resize-none ${
|
||||||
|
step.type === 'COMMAND'
|
||||||
|
? 'font-mono text-sm'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Droppable>
|
||||||
|
</DragDropContext>
|
||||||
|
|
||||||
|
{steps.length === 0 && (
|
||||||
|
<div className="card py-12 text-center">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Noch keine Schritte vorhanden.
|
||||||
|
</p>
|
||||||
|
<button onClick={addStep} className="btn-primary mt-4 gap-1.5">
|
||||||
|
<Plus size={16} />
|
||||||
|
Ersten Schritt hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="card flex flex-wrap items-center justify-end gap-3 p-4">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(-1)}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSave('DRAFT')}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-secondary gap-1.5"
|
||||||
|
>
|
||||||
|
<Save size={16} />
|
||||||
|
Als Entwurf speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSave('REVIEW')}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-secondary gap-1.5"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
Zur Prüfung einreichen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSave('PUBLISHED')}
|
||||||
|
disabled={saving}
|
||||||
|
className="btn-primary gap-1.5"
|
||||||
|
>
|
||||||
|
<Send size={16} />
|
||||||
|
Veröffentlichen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
274
frontend/src/pages/WorkflowList.tsx
Normal file
274
frontend/src/pages/WorkflowList.tsx
Normal file
@ -0,0 +1,274 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
FileText,
|
||||||
|
Grid3X3,
|
||||||
|
List,
|
||||||
|
Plus,
|
||||||
|
Tag,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { workflowsApi, categoriesApi } from '../services/api';
|
||||||
|
import type { Category, Workflow } from '../types';
|
||||||
|
import SearchBar from '../components/SearchBar';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import Pagination from '../components/Pagination';
|
||||||
|
import EmptyState from '../components/EmptyState';
|
||||||
|
|
||||||
|
type ViewMode = 'grid' | 'list';
|
||||||
|
|
||||||
|
const statusOptions: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: 'Alle Status' },
|
||||||
|
{ value: 'DRAFT', label: 'Entwurf' },
|
||||||
|
{ value: 'REVIEW', label: 'In Prüfung' },
|
||||||
|
{ value: 'PUBLISHED', label: 'Veröffentlicht' },
|
||||||
|
{ value: 'ARCHIVED', label: 'Archiviert' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function WorkflowList() {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const isTemplate = searchParams.get('template') === 'true';
|
||||||
|
|
||||||
|
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||||
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>('grid');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
categoriesApi.list().then(setCategories).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkflows();
|
||||||
|
}, [search, statusFilter, categoryFilter, page, isTemplate]);
|
||||||
|
|
||||||
|
const loadWorkflows = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params: Parameters<typeof workflowsApi.list>[0] = {
|
||||||
|
page,
|
||||||
|
pageSize: 12,
|
||||||
|
search: search || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
categoryId: categoryFilter || undefined,
|
||||||
|
isTemplate: isTemplate || undefined,
|
||||||
|
};
|
||||||
|
const res = await workflowsApi.list(params);
|
||||||
|
setWorkflows(res.data);
|
||||||
|
setTotalPages(res.totalPages);
|
||||||
|
} catch {
|
||||||
|
toast.error('Workflows konnten nicht geladen werden');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{isTemplate ? 'Vorlagen' : 'Workflows'}
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
{isTemplate
|
||||||
|
? 'Wiederverwendbare Workflow-Vorlagen'
|
||||||
|
: 'Alle Workflows Ihres Mandanten'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link to="/workflows/new" className="btn-primary gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Neuer Workflow
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="card p-4">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<div className="flex-1">
|
||||||
|
<SearchBar
|
||||||
|
value={search}
|
||||||
|
onChange={(val) => {
|
||||||
|
setSearch(val);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
placeholder="Workflows suchen..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input-field w-auto"
|
||||||
|
>
|
||||||
|
{statusOptions.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={categoryFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCategoryFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
className="input-field w-auto"
|
||||||
|
>
|
||||||
|
<option value="">Alle Kategorien</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex rounded-lg border border-gray-300 bg-white">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`rounded-l-lg p-2 ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-primary-50 text-primary-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Grid3X3 size={18} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('list')}
|
||||||
|
className={`rounded-r-lg p-2 ${
|
||||||
|
viewMode === 'list'
|
||||||
|
? 'bg-primary-50 text-primary-600'
|
||||||
|
: 'text-gray-400 hover:text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<List size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : workflows.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="Keine Workflows gefunden"
|
||||||
|
description={
|
||||||
|
search || statusFilter || categoryFilter
|
||||||
|
? 'Versuchen Sie andere Suchkriterien.'
|
||||||
|
: 'Erstellen Sie Ihren ersten Workflow.'
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<Link to="/workflows/new" className="btn-primary gap-2">
|
||||||
|
<Plus size={18} />
|
||||||
|
Neuer Workflow
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{workflows.map((wf) => (
|
||||||
|
<Link
|
||||||
|
key={wf.id}
|
||||||
|
to={`/workflows/${wf.id}`}
|
||||||
|
className="card p-5 transition-shadow hover:shadow-md"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h3 className="line-clamp-1 font-semibold text-gray-900">
|
||||||
|
{wf.title}
|
||||||
|
</h3>
|
||||||
|
<StatusBadge status={wf.status} />
|
||||||
|
</div>
|
||||||
|
{wf.description && (
|
||||||
|
<p className="mt-2 line-clamp-2 text-sm text-gray-500">
|
||||||
|
{wf.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex items-center justify-between text-xs text-gray-400">
|
||||||
|
<span>
|
||||||
|
{wf.steps?.length || 0} Schritte · v{wf.version}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{wf.category && (
|
||||||
|
<div className="mt-3 flex items-center gap-1.5 text-xs text-gray-500">
|
||||||
|
<Tag size={12} />
|
||||||
|
{wf.category.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{wf.tags.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{wf.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{wf.tags.length > 3 && (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
+{wf.tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="card divide-y divide-gray-100">
|
||||||
|
{workflows.map((wf) => (
|
||||||
|
<Link
|
||||||
|
key={wf.id}
|
||||||
|
to={`/workflows/${wf.id}`}
|
||||||
|
className="flex items-center gap-4 px-6 py-4 transition-colors hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gray-100">
|
||||||
|
<FileText className="text-gray-500" size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate font-medium text-gray-900">{wf.title}</p>
|
||||||
|
{wf.description && (
|
||||||
|
<p className="truncate text-sm text-gray-500">
|
||||||
|
{wf.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{wf.category && (
|
||||||
|
<span className="hidden rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 md:inline">
|
||||||
|
{wf.category.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="hidden text-sm text-gray-400 sm:inline">
|
||||||
|
{wf.steps?.length || 0} Schritte
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={wf.status} />
|
||||||
|
<span className="hidden text-xs text-gray-400 lg:inline">
|
||||||
|
{new Date(wf.updatedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<Pagination page={page} totalPages={totalPages} onPageChange={setPage} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
265
frontend/src/pages/WorkflowView.tsx
Normal file
265
frontend/src/pages/WorkflowView.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Archive,
|
||||||
|
ArrowLeft,
|
||||||
|
CheckCircle2,
|
||||||
|
CheckSquare,
|
||||||
|
Copy,
|
||||||
|
Edit,
|
||||||
|
FileText,
|
||||||
|
Info,
|
||||||
|
Send,
|
||||||
|
Terminal,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { workflowsApi } from '../services/api';
|
||||||
|
import type { Workflow, WorkflowStep } from '../types';
|
||||||
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import StepTypeIcon from '../components/StepTypeIcon';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
|
||||||
|
function getStepBgClass(type: WorkflowStep['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'WARNING':
|
||||||
|
return 'border-l-4 border-l-amber-400 bg-amber-50';
|
||||||
|
case 'COMMAND':
|
||||||
|
return 'border-l-4 border-l-purple-400 bg-gray-900';
|
||||||
|
case 'INFO':
|
||||||
|
return 'border-l-4 border-l-cyan-400 bg-cyan-50';
|
||||||
|
case 'CHECKLIST':
|
||||||
|
return 'border-l-4 border-l-green-400 bg-green-50';
|
||||||
|
default:
|
||||||
|
return 'border-l-4 border-l-primary-400 bg-white';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WorkflowView() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [workflow, setWorkflow] = useState<Workflow | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [archiveOpen, setArchiveOpen] = useState(false);
|
||||||
|
const [archiving, setArchiving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!id) return;
|
||||||
|
setLoading(true);
|
||||||
|
workflowsApi
|
||||||
|
.get(id)
|
||||||
|
.then(setWorkflow)
|
||||||
|
.catch(() => {
|
||||||
|
toast.error('Workflow konnte nicht geladen werden');
|
||||||
|
navigate('/workflows');
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, navigate]);
|
||||||
|
|
||||||
|
const handleDuplicate = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const dup = await workflowsApi.duplicate(id);
|
||||||
|
toast.success('Workflow dupliziert');
|
||||||
|
navigate(`/workflows/${dup.id}/edit`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Duplizieren fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
setArchiving(true);
|
||||||
|
try {
|
||||||
|
await workflowsApi.archive(id);
|
||||||
|
toast.success('Workflow archiviert');
|
||||||
|
setArchiveOpen(false);
|
||||||
|
navigate('/workflows');
|
||||||
|
} catch {
|
||||||
|
toast.error('Archivieren fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
setArchiving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const updated = await workflowsApi.publish(id);
|
||||||
|
setWorkflow(updated);
|
||||||
|
toast.success('Workflow veröffentlicht');
|
||||||
|
} catch {
|
||||||
|
toast.error('Veröffentlichen fehlgeschlagen');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<div className="h-10 w-10 animate-spin rounded-full border-4 border-primary-600 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!workflow) return null;
|
||||||
|
|
||||||
|
const sortedSteps = [...(workflow.steps || [])].sort(
|
||||||
|
(a, b) => a.order - b.order
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6">
|
||||||
|
{/* Back */}
|
||||||
|
<Link
|
||||||
|
to="/workflows"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={16} />
|
||||||
|
Zurück zu Workflows
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="card p-6">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{workflow.title}
|
||||||
|
</h1>
|
||||||
|
<StatusBadge status={workflow.status} />
|
||||||
|
</div>
|
||||||
|
{workflow.description && (
|
||||||
|
<p className="mt-2 text-gray-600">{workflow.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-4 text-sm text-gray-500">
|
||||||
|
<span>Version {workflow.version}</span>
|
||||||
|
<span>{sortedSteps.length} Schritte</span>
|
||||||
|
{workflow.category && <span>{workflow.category.name}</span>}
|
||||||
|
<span>
|
||||||
|
Erstellt am{' '}
|
||||||
|
{new Date(workflow.createdAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
Aktualisiert am{' '}
|
||||||
|
{new Date(workflow.updatedAt).toLocaleDateString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{workflow.tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{workflow.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full bg-primary-50 px-3 py-0.5 text-xs font-medium text-primary-700"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
to={`/workflows/${workflow.id}/edit`}
|
||||||
|
className="btn-secondary gap-1.5"
|
||||||
|
>
|
||||||
|
<Edit size={16} />
|
||||||
|
Bearbeiten
|
||||||
|
</Link>
|
||||||
|
<button onClick={handleDuplicate} className="btn-secondary gap-1.5">
|
||||||
|
<Copy size={16} />
|
||||||
|
Duplizieren
|
||||||
|
</button>
|
||||||
|
{(workflow.status === 'DRAFT' || workflow.status === 'REVIEW') && (
|
||||||
|
<button onClick={handlePublish} className="btn-primary gap-1.5">
|
||||||
|
<Send size={16} />
|
||||||
|
Veröffentlichen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{workflow.status !== 'ARCHIVED' && (
|
||||||
|
<button
|
||||||
|
onClick={() => setArchiveOpen(true)}
|
||||||
|
className="btn-secondary gap-1.5 text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Archive size={16} />
|
||||||
|
Archivieren
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Steps */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">Schritte</h2>
|
||||||
|
{sortedSteps.length === 0 ? (
|
||||||
|
<div className="card py-12 text-center text-sm text-gray-500">
|
||||||
|
Keine Schritte vorhanden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedSteps.map((step, idx) => (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className={`card overflow-hidden ${getStepBgClass(step.type)}`}
|
||||||
|
>
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* Step number */}
|
||||||
|
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-white text-sm font-bold text-gray-700 shadow-sm ring-1 ring-gray-200">
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StepTypeIcon type={step.type} showLabel />
|
||||||
|
{step.isRequired && (
|
||||||
|
<span className="rounded bg-red-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-red-600">
|
||||||
|
Pflicht
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3
|
||||||
|
className={`mt-1 font-semibold ${
|
||||||
|
step.type === 'COMMAND' ? 'text-gray-100' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.title}
|
||||||
|
</h3>
|
||||||
|
{step.type === 'COMMAND' ? (
|
||||||
|
<pre className="mt-3 overflow-x-auto rounded-lg bg-gray-800 p-4 font-mono text-sm text-green-400">
|
||||||
|
<code>{step.content}</code>
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`mt-2 whitespace-pre-wrap text-sm ${
|
||||||
|
step.type === 'WARNING'
|
||||||
|
? 'text-amber-800'
|
||||||
|
: step.type === 'INFO'
|
||||||
|
? 'text-cyan-800'
|
||||||
|
: 'text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.content}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={archiveOpen}
|
||||||
|
onClose={() => setArchiveOpen(false)}
|
||||||
|
onConfirm={handleArchive}
|
||||||
|
title="Workflow archivieren?"
|
||||||
|
message="Der Workflow wird archiviert und ist nicht mehr aktiv verfügbar. Diese Aktion kann rückgängig gemacht werden."
|
||||||
|
confirmLabel="Archivieren"
|
||||||
|
loading={archiving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
221
frontend/src/services/api.ts
Normal file
221
frontend/src/services/api.ts
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
Category,
|
||||||
|
PaginatedResponse,
|
||||||
|
Tenant,
|
||||||
|
User,
|
||||||
|
Workflow,
|
||||||
|
WorkflowStep,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let failedQueue: Array<{
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (err: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
function processQueue(error: unknown, token: string | null = null) {
|
||||||
|
failedQueue.forEach((p) => {
|
||||||
|
if (error) {
|
||||||
|
p.reject(error);
|
||||||
|
} else {
|
||||||
|
p.resolve(token!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
failedQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||||
|
_retry?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
failedQueue.push({
|
||||||
|
resolve: (token: string) => {
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
resolve(api(originalRequest));
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
originalRequest._retry = true;
|
||||||
|
isRefreshing = true;
|
||||||
|
|
||||||
|
const refreshToken = localStorage.getItem('refreshToken');
|
||||||
|
if (!refreshToken) {
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<AuthResponse>('/api/auth/refresh', {
|
||||||
|
refreshToken,
|
||||||
|
});
|
||||||
|
localStorage.setItem('accessToken', data.accessToken);
|
||||||
|
localStorage.setItem('refreshToken', data.refreshToken);
|
||||||
|
processQueue(null, data.accessToken);
|
||||||
|
if (originalRequest.headers) {
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||||
|
}
|
||||||
|
return api(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(refreshError, null);
|
||||||
|
localStorage.removeItem('accessToken');
|
||||||
|
localStorage.removeItem('refreshToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
export const authApi = {
|
||||||
|
login: (email: string, password: string) =>
|
||||||
|
api.post<AuthResponse>('/auth/login', { email, password }).then((r) => r.data),
|
||||||
|
|
||||||
|
refresh: (refreshToken: string) =>
|
||||||
|
api.post<AuthResponse>('/auth/refresh', { refreshToken }).then((r) => r.data),
|
||||||
|
|
||||||
|
me: () => api.get<User>('/auth/me').then((r) => r.data),
|
||||||
|
|
||||||
|
logout: () => api.post('/auth/logout').then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tenants ---
|
||||||
|
export const tenantsApi = {
|
||||||
|
list: (params?: { page?: number; pageSize?: number; search?: string }) =>
|
||||||
|
api.get<PaginatedResponse<Tenant>>('/tenants', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<Tenant>(`/tenants/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Tenant>) =>
|
||||||
|
api.post<Tenant>('/tenants', data).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Tenant>) =>
|
||||||
|
api.put<Tenant>(`/tenants/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) => api.delete(`/tenants/${id}`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Users ---
|
||||||
|
export const usersApi = {
|
||||||
|
list: (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
role?: string;
|
||||||
|
}) => api.get<PaginatedResponse<User>>('/users', { params }).then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<User>(`/users/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<User> & { password?: string }) =>
|
||||||
|
api.post<User>('/users', data).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<User>) =>
|
||||||
|
api.put<User>(`/users/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) => api.delete(`/users/${id}`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Workflows ---
|
||||||
|
export const workflowsApi = {
|
||||||
|
list: (params?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
categoryId?: string;
|
||||||
|
tenantId?: string;
|
||||||
|
isTemplate?: boolean;
|
||||||
|
tags?: string;
|
||||||
|
}) =>
|
||||||
|
api
|
||||||
|
.get<PaginatedResponse<Workflow>>('/workflows', { params })
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
get: (id: string) => api.get<Workflow>(`/workflows/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Workflow>) =>
|
||||||
|
api.post<Workflow>('/workflows', data).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Workflow>) =>
|
||||||
|
api.put<Workflow>(`/workflows/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) => api.delete(`/workflows/${id}`).then((r) => r.data),
|
||||||
|
|
||||||
|
duplicate: (id: string) =>
|
||||||
|
api.post<Workflow>(`/workflows/${id}/duplicate`).then((r) => r.data),
|
||||||
|
|
||||||
|
publish: (id: string) =>
|
||||||
|
api.post<Workflow>(`/workflows/${id}/publish`).then((r) => r.data),
|
||||||
|
|
||||||
|
archive: (id: string) =>
|
||||||
|
api.post<Workflow>(`/workflows/${id}/archive`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Workflow Steps ---
|
||||||
|
export const stepsApi = {
|
||||||
|
create: (workflowId: string, data: Partial<WorkflowStep>) =>
|
||||||
|
api
|
||||||
|
.post<WorkflowStep>(`/workflows/${workflowId}/steps`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
update: (workflowId: string, stepId: string, data: Partial<WorkflowStep>) =>
|
||||||
|
api
|
||||||
|
.put<WorkflowStep>(`/workflows/${workflowId}/steps/${stepId}`, data)
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (workflowId: string, stepId: string) =>
|
||||||
|
api.delete(`/workflows/${workflowId}/steps/${stepId}`).then((r) => r.data),
|
||||||
|
|
||||||
|
reorder: (workflowId: string, stepIds: string[]) =>
|
||||||
|
api
|
||||||
|
.put(`/workflows/${workflowId}/steps/reorder`, { stepIds })
|
||||||
|
.then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Categories ---
|
||||||
|
export const categoriesApi = {
|
||||||
|
list: () => api.get<Category[]>('/categories').then((r) => r.data),
|
||||||
|
|
||||||
|
create: (data: Partial<Category>) =>
|
||||||
|
api.post<Category>('/categories', data).then((r) => r.data),
|
||||||
|
|
||||||
|
update: (id: string, data: Partial<Category>) =>
|
||||||
|
api.put<Category>(`/categories/${id}`, data).then((r) => r.data),
|
||||||
|
|
||||||
|
delete: (id: string) => api.delete(`/categories/${id}`).then((r) => r.data),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default api;
|
||||||
64
frontend/src/types/index.ts
Normal file
64
frontend/src/types/index.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
export interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
logo?: string;
|
||||||
|
active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
role: 'SYSTEM_ADMIN' | 'TECHNICIAN' | 'CUSTOMER_ADMIN' | 'CUSTOMER_USER';
|
||||||
|
tenantId?: string;
|
||||||
|
tenant?: Tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Workflow {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
tenantId: string;
|
||||||
|
categoryId?: string;
|
||||||
|
category?: Category;
|
||||||
|
status: 'DRAFT' | 'REVIEW' | 'PUBLISHED' | 'ARCHIVED';
|
||||||
|
version: number;
|
||||||
|
isTemplate: boolean;
|
||||||
|
tags: string[];
|
||||||
|
steps: WorkflowStep[];
|
||||||
|
createdBy: User;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowStep {
|
||||||
|
id: string;
|
||||||
|
workflowId: string;
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
type: 'INSTRUCTION' | 'CHECKLIST' | 'WARNING' | 'INFO' | 'COMMAND';
|
||||||
|
isRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Category {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
29
frontend/tailwind.config.js
Normal file
29
frontend/tailwind.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
100: '#dbeafe',
|
||||||
|
200: '#bfdbfe',
|
||||||
|
300: '#93c5fd',
|
||||||
|
400: '#60a5fa',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
800: '#1e40af',
|
||||||
|
900: '#1e3a8a',
|
||||||
|
950: '#172554',
|
||||||
|
},
|
||||||
|
brand: {
|
||||||
|
DEFAULT: '#2563eb',
|
||||||
|
dark: '#1d4ed8',
|
||||||
|
light: '#3b82f6',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
28
frontend/tsconfig.json
Normal file
28
frontend/tsconfig.json
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
15
frontend/tsconfig.node.json
Normal file
15
frontend/tsconfig.node.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
frontend/vite.config.ts
Normal file
15
frontend/vite.config.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:4000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
16
package.json
Normal file
16
package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "workflow-portal",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"install:all": "cd backend && npm install && cd ../frontend && npm install",
|
||||||
|
"dev:backend": "cd backend && npm run dev",
|
||||||
|
"dev:frontend": "cd frontend && npm run dev",
|
||||||
|
"build:backend": "cd backend && npm run build",
|
||||||
|
"build:frontend": "cd frontend && npm run build",
|
||||||
|
"build": "npm run build:backend && npm run build:frontend",
|
||||||
|
"db:migrate": "cd backend && npx prisma migrate deploy",
|
||||||
|
"db:seed": "cd backend && npm run db:seed",
|
||||||
|
"start": "cd backend && npm start"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user