feat: complete tOS project with HR, LEAN, Dashboard and Integrations modules

Full enterprise web operating system including:
- Next.js 14 frontend with App Router, i18n (DE/EN), shadcn/ui
- NestJS 10 backend with Prisma, JWT auth, Swagger docs
- Keycloak 24 SSO with role-based access control
- HR module (employees, time tracking, absences, org chart)
- LEAN module (3S planning, morning meeting SQCDM, skill matrix)
- Integrations module (PlentyONE, Zulip, Todoist, FreeScout, Nextcloud, ecoDMS, GembaDocs)
- Dashboard with customizable drag & drop widget grid
- Docker Compose infrastructure (PostgreSQL 16, Redis 7, Keycloak 24)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 19:37:55 +01:00
parent a2b6612e9e
commit fe305f6fc8
509 changed files with 81111 additions and 1 deletions

View File

@@ -0,0 +1,656 @@
// Prisma Schema for tOS - Team Operating System
// Database: PostgreSQL
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// =============================================================================
// ENUMS
// =============================================================================
enum ContractType {
FULL_TIME
PART_TIME
MINI_JOB
INTERN
WORKING_STUDENT
FREELANCE
TEMPORARY
}
enum AbsenceType {
VACATION
SICK
SICK_CHILD
SPECIAL_LEAVE
UNPAID_LEAVE
PARENTAL_LEAVE
HOME_OFFICE
BUSINESS_TRIP
TRAINING
COMPENSATION
}
enum ApprovalStatus {
PENDING
APPROVED
REJECTED
CANCELLED
}
enum TimeEntryType {
REGULAR
OVERTIME
HOLIDAY
SICK
VACATION
CORRECTION
}
enum ReviewType {
PROBATION
ANNUAL
PROMOTION
PROJECT
FEEDBACK
EXIT
}
enum S3StatusType {
GREEN
YELLOW
RED
NOT_APPLICABLE
}
enum S3Type {
SEIRI // Sort - Aussortieren
SEITON // Set in Order - Aufräumen
SEISO // Shine - Sauberkeit
}
enum SkillLevel {
NONE
BEGINNER
INTERMEDIATE
ADVANCED
EXPERT
TRAINER
}
enum IntegrationType {
DATEV
PERSONIO
SAP
CALENDAR
EMAIL
CUSTOM
PLENTYONE
ZULIP
TODOIST
FREESCOUT
NEXTCLOUD
ECODMS
GEMBADOCS
}
enum SyncStatus {
PENDING
RUNNING
SUCCESS
ERROR
}
// SQCDM Categories for Morning Meeting
enum SQCDMCategory {
SAFETY // S - Sicherheit
QUALITY // Q - Qualitaet
COST // C - Kosten
DELIVERY // D - Lieferung
MORALE // M - Moral/Mitarbeiter
}
// Meeting Status
enum MeetingStatus {
SCHEDULED
IN_PROGRESS
COMPLETED
CANCELLED
}
// KPI Status (Traffic Light)
enum KPIStatus {
GREEN
YELLOW
RED
NEUTRAL
}
// Trend Direction
enum Trend {
UP
DOWN
STABLE
}
// Action Status
enum ActionStatus {
OPEN
IN_PROGRESS
COMPLETED
CANCELLED
}
// Priority Level
enum Priority {
LOW
MEDIUM
HIGH
CRITICAL
}
// =============================================================================
// CORE MODELS
// =============================================================================
model User {
id String @id @default(cuid())
email String @unique
firstName String
lastName String
keycloakId String? @unique
avatar String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String?
department Department? @relation("UserDepartment", fields: [departmentId], references: [id])
roles UserRole[]
employee Employee?
preferences UserPreference?
auditLogs AuditLog[]
// Manager relations
managedDepartments Department[] @relation("DepartmentManager")
correctedTimeEntries TimeEntry[] @relation("TimeEntryCorrectedBy")
approvedAbsences Absence[] @relation("AbsenceApprovedBy")
conductedReviews EmployeeReview[] @relation("ReviewConductedBy")
skillAssessments SkillMatrixEntry[] @relation("SkillAssessedBy")
integrationCredentials IntegrationCredential[]
// LEAN 3S relations
s3PlansCreated S3Plan[] @relation("S3PlanCreatedBy")
s3StatusCompleted S3Status[] @relation("S3StatusCompletedBy")
// Morning Meeting relations
conductedMeetings MorningMeeting[] @relation("MeetingConductor")
assignedMeetingActions MorningMeetingAction[] @relation("ActionAssignee")
@@index([email])
@@index([keycloakId])
@@index([departmentId])
}
model Department {
id String @id @default(cuid())
name String
code String? @unique
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Self-relation for hierarchy
parentId String?
parent Department? @relation("DepartmentHierarchy", fields: [parentId], references: [id])
children Department[] @relation("DepartmentHierarchy")
// Manager relation
managerId String?
manager User? @relation("DepartmentManager", fields: [managerId], references: [id])
// Relations
users User[] @relation("UserDepartment")
onboardingTasks OnboardingTask[]
s3Plans S3Plan[]
skills Skill[]
morningMeetings MorningMeeting[]
@@index([parentId])
@@index([managerId])
}
model Role {
id String @id @default(cuid())
name String @unique
description String?
permissions Json @default("[]")
isSystem Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
users UserRole[]
@@index([name])
}
model UserRole {
id String @id @default(cuid())
userId String
roleId String
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
role Role @relation(fields: [roleId], references: [id], onDelete: Cascade)
@@unique([userId, roleId])
@@index([userId])
@@index([roleId])
}
// =============================================================================
// HR MODELS
// =============================================================================
model Employee {
id String @id @default(cuid())
employeeNumber String @unique
position String
entryDate DateTime
exitDate DateTime?
contractType ContractType
workingHours Float @default(40)
salary String? // Encrypted salary (AES-256-GCM)
taxClass Int?
bankAccount Json? // Encrypted bank account (AES-256-GCM)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
timeEntries TimeEntry[]
absences Absence[]
reviews EmployeeReview[]
skillMatrixEntries SkillMatrixEntry[]
@@index([employeeNumber])
@@index([userId])
@@index([entryDate])
}
model TimeEntry {
id String @id @default(cuid())
date DateTime @db.Date
clockIn DateTime?
clockOut DateTime?
breakMinutes Int @default(0)
type TimeEntryType @default(REGULAR)
note String?
isLocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
correctedById String?
correctedBy User? @relation("TimeEntryCorrectedBy", fields: [correctedById], references: [id])
@@unique([employeeId, date])
@@index([employeeId])
@@index([date])
@@index([correctedById])
}
model Absence {
id String @id @default(cuid())
type AbsenceType
startDate DateTime @db.Date
endDate DateTime @db.Date
days Float
status ApprovalStatus @default(PENDING)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
approvedById String?
approvedBy User? @relation("AbsenceApprovedBy", fields: [approvedById], references: [id])
@@index([employeeId])
@@index([startDate, endDate])
@@index([status])
@@index([approvedById])
}
model EmployeeReview {
id String @id @default(cuid())
date DateTime
type ReviewType
notes String? @db.Text
goals Json?
rating Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
reviewerId String
reviewer User @relation("ReviewConductedBy", fields: [reviewerId], references: [id])
@@index([employeeId])
@@index([reviewerId])
@@index([date])
@@index([type])
}
model OnboardingTask {
id String @id @default(cuid())
title String
description String? @db.Text
dueOffset Int @default(0) // Days from entry date
assignedTo String? // Role or specific user type
sortOrder Int @default(0)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
@@index([departmentId])
@@index([sortOrder])
}
// =============================================================================
// INTEGRATION MODELS
// =============================================================================
model IntegrationCredential {
id String @id @default(cuid())
type IntegrationType
name String
credentials String // Encrypted JSON (AES-256-GCM)
isActive Boolean @default(true)
lastUsed DateTime?
lastSync DateTime?
syncStatus SyncStatus @default(PENDING)
syncError String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdById String
createdBy User @relation(fields: [createdById], references: [id])
// Relations
syncHistory IntegrationSyncHistory[]
@@unique([type, name])
@@index([type])
@@index([isActive])
@@index([createdById])
}
model IntegrationSyncHistory {
id String @id @default(cuid())
credentialId String
credential IntegrationCredential @relation(fields: [credentialId], references: [id], onDelete: Cascade)
status SyncStatus
startedAt DateTime @default(now())
completedAt DateTime?
duration Int? // Duration in milliseconds
recordsProcessed Int?
error String?
metadata Json? // Additional sync metadata
createdAt DateTime @default(now())
@@index([credentialId])
@@index([status])
@@index([startedAt])
}
// =============================================================================
// LEAN MODELS - 3S / 5S
// =============================================================================
model S3Plan {
id String @id @default(cuid())
year Int
month Int
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String
department Department @relation(fields: [departmentId], references: [id])
createdById String
createdBy User @relation("S3PlanCreatedBy", fields: [createdById], references: [id])
categories S3Category[]
@@unique([departmentId, year, month])
@@index([departmentId])
@@index([year, month])
@@index([createdById])
}
model S3Category {
id String @id @default(cuid())
name String
s3Type S3Type // SEIRI, SEITON, SEISO
sortOrder Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
planId String
plan S3Plan @relation(fields: [planId], references: [id], onDelete: Cascade)
statuses S3Status[]
@@index([planId])
@@index([sortOrder])
@@index([s3Type])
}
model S3Status {
id String @id @default(cuid())
week Int // 1-53
status S3StatusType
photo String? // URL to uploaded image
note String?
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
categoryId String
category S3Category @relation(fields: [categoryId], references: [id], onDelete: Cascade)
completedById String?
completedBy User? @relation("S3StatusCompletedBy", fields: [completedById], references: [id])
@@unique([categoryId, week])
@@index([categoryId])
@@index([week])
@@index([status])
@@index([completedById])
}
// =============================================================================
// LEAN MODELS - Morning Meeting (Shopfloor Management)
// =============================================================================
model MorningMeeting {
id String @id @default(cuid())
date DateTime @db.Date
startTime DateTime? // Actual start time
endTime DateTime? // Actual end time
duration Int? // Duration in minutes (calculated)
participants Json? // Array of participant user IDs
notes String? @db.Text
status MeetingStatus @default(SCHEDULED)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String
department Department @relation(fields: [departmentId], references: [id])
conductorId String?
conductor User? @relation("MeetingConductor", fields: [conductorId], references: [id])
topics MorningMeetingTopic[]
actions MorningMeetingAction[]
@@unique([departmentId, date])
@@index([departmentId])
@@index([date])
@@index([status])
@@index([conductorId])
}
model MorningMeetingTopic {
id String @id @default(cuid())
category SQCDMCategory // SAFETY, QUALITY, COST, DELIVERY, MORALE
title String
value String? // Current value
target String? // Target value
unit String? // Unit (%, pieces, EUR, etc.)
status KPIStatus @default(NEUTRAL)
trend Trend? // UP, DOWN, STABLE
sortOrder Int @default(0)
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
meetingId String
meeting MorningMeeting @relation(fields: [meetingId], references: [id], onDelete: Cascade)
@@index([meetingId])
@@index([category])
@@index([sortOrder])
@@index([status])
}
model MorningMeetingAction {
id String @id @default(cuid())
title String
description String? @db.Text
dueDate DateTime? @db.Date
status ActionStatus @default(OPEN)
priority Priority @default(MEDIUM)
completedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
meetingId String
meeting MorningMeeting @relation(fields: [meetingId], references: [id], onDelete: Cascade)
assigneeId String?
assignee User? @relation("ActionAssignee", fields: [assigneeId], references: [id])
@@index([meetingId])
@@index([assigneeId])
@@index([status])
@@index([priority])
@@index([dueDate])
}
// =============================================================================
// LEAN MODELS - Skill Matrix
// =============================================================================
model Skill {
id String @id @default(cuid())
name String
description String?
category String?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
departmentId String?
department Department? @relation(fields: [departmentId], references: [id])
entries SkillMatrixEntry[]
@@unique([name, departmentId])
@@index([departmentId])
@@index([category])
}
model SkillMatrixEntry {
id String @id @default(cuid())
level SkillLevel @default(NONE)
assessedAt DateTime @default(now())
note String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
employeeId String
employee Employee @relation(fields: [employeeId], references: [id], onDelete: Cascade)
skillId String
skill Skill @relation(fields: [skillId], references: [id], onDelete: Cascade)
assessedById String?
assessedBy User? @relation("SkillAssessedBy", fields: [assessedById], references: [id])
@@unique([employeeId, skillId])
@@index([employeeId])
@@index([skillId])
@@index([assessedById])
@@index([level])
}
// =============================================================================
// USER PREFERENCES
// =============================================================================
model UserPreference {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
theme String @default("system") // "light", "dark", "system"
language String @default("de") // "de", "en"
dashboardLayout Json? // Array of widget configurations
notifications Json? // Notification preferences
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
// =============================================================================
// AUDIT LOGGING
// =============================================================================
model AuditLog {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id])
action String // CREATE, UPDATE, DELETE, LOGIN, LOGOUT, etc.
entity String // User, Department, Employee, etc.
entityId String?
oldData Json?
newData Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([userId])
@@index([entity, entityId])
@@index([action])
@@index([createdAt])
}

215
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,215 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// Create default roles
const roles = await Promise.all([
prisma.role.upsert({
where: { name: 'admin' },
update: {},
create: {
name: 'admin',
description: 'Administrator with full system access',
permissions: JSON.stringify([
'users:read',
'users:write',
'users:delete',
'employees:read',
'employees:write',
'employees:delete',
'departments:read',
'departments:write',
'departments:delete',
'roles:read',
'roles:write',
'roles:delete',
'settings:read',
'settings:write',
]),
isSystem: true,
},
}),
prisma.role.upsert({
where: { name: 'hr-manager' },
update: {},
create: {
name: 'hr-manager',
description: 'HR Manager with access to employee data',
permissions: JSON.stringify([
'users:read',
'users:write',
'employees:read',
'employees:write',
'departments:read',
'absences:read',
'absences:write',
'time-entries:read',
'time-entries:write',
'reviews:read',
'reviews:write',
]),
isSystem: true,
},
}),
prisma.role.upsert({
where: { name: 'team-lead' },
update: {},
create: {
name: 'team-lead',
description: 'Team leader with access to team data',
permissions: JSON.stringify([
'users:read',
'employees:read',
'departments:read',
'absences:read',
'absences:approve',
'time-entries:read',
'reviews:read',
'reviews:write',
'skills:read',
'skills:write',
]),
isSystem: true,
},
}),
prisma.role.upsert({
where: { name: 'employee' },
update: {},
create: {
name: 'employee',
description: 'Regular employee with basic access',
permissions: JSON.stringify([
'profile:read',
'profile:write',
'absences:read',
'absences:request',
'time-entries:read',
'time-entries:write',
'skills:read',
]),
isSystem: true,
},
}),
]);
console.log(`Created ${roles.length} roles`);
// Create default departments
const departments = await Promise.all([
prisma.department.upsert({
where: { code: 'MGMT' },
update: {},
create: {
name: 'Management',
code: 'MGMT',
},
}),
prisma.department.upsert({
where: { code: 'HR' },
update: {},
create: {
name: 'Human Resources',
code: 'HR',
},
}),
prisma.department.upsert({
where: { code: 'ENG' },
update: {},
create: {
name: 'Engineering',
code: 'ENG',
},
}),
prisma.department.upsert({
where: { code: 'PROD' },
update: {},
create: {
name: 'Production',
code: 'PROD',
},
}),
prisma.department.upsert({
where: { code: 'QA' },
update: {},
create: {
name: 'Quality Assurance',
code: 'QA',
},
}),
]);
console.log(`Created ${departments.length} departments`);
// Create admin user (for development)
if (process.env.NODE_ENV !== 'production') {
const adminRole = roles.find((r) => r.name === 'admin');
const mgmtDept = departments.find((d) => d.code === 'MGMT');
if (adminRole && mgmtDept) {
const adminUser = await prisma.user.upsert({
where: { email: 'admin@tos.local' },
update: {},
create: {
email: 'admin@tos.local',
firstName: 'System',
lastName: 'Administrator',
departmentId: mgmtDept.id,
roles: {
create: {
roleId: adminRole.id,
},
},
},
});
console.log(`Created admin user: ${adminUser.email}`);
}
}
// Create default skills
const skills = await Promise.all([
prisma.skill.upsert({
where: { name_departmentId: { name: 'Communication', departmentId: null as unknown as string } },
update: {},
create: {
name: 'Communication',
description: 'Effective verbal and written communication',
category: 'Soft Skills',
},
}),
prisma.skill.upsert({
where: { name_departmentId: { name: 'Problem Solving', departmentId: null as unknown as string } },
update: {},
create: {
name: 'Problem Solving',
description: 'Analytical thinking and problem resolution',
category: 'Soft Skills',
},
}),
prisma.skill.upsert({
where: { name_departmentId: { name: 'Team Collaboration', departmentId: null as unknown as string } },
update: {},
create: {
name: 'Team Collaboration',
description: 'Working effectively in a team environment',
category: 'Soft Skills',
},
}),
]);
console.log(`Created ${skills.length} skills`);
console.log('Database seeding completed!');
}
main()
.catch((e) => {
console.error('Error seeding database:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});