mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
coaches are saved in DB and shown
This commit is contained in:
parent
99615d4fe6
commit
bdd831903a
9 changed files with 978 additions and 21 deletions
|
|
@ -39,17 +39,24 @@ model User {
|
|||
|
||||
model Employee {
|
||||
id String @id @default(uuid())
|
||||
firstName String
|
||||
lastName String
|
||||
firstName String?
|
||||
lastName String?
|
||||
email String @unique
|
||||
is2FA Boolean @default(false)
|
||||
isSendingMail Boolean @default(false)
|
||||
pbSrc String?
|
||||
status String?
|
||||
roles String[] @default([])
|
||||
locations String[] @default([])
|
||||
permissions Json?
|
||||
affiliation String?
|
||||
label String?
|
||||
qualification String?
|
||||
authorization Json?
|
||||
onboardingToken String? @unique
|
||||
onboardingTokenExpiry DateTime?
|
||||
isOnboarded Boolean @default(false)
|
||||
createdBy String? // Manager ID who created the employee
|
||||
trainings UserTraining[]
|
||||
createdAt DateTime? @default(now())
|
||||
updatedAt DateTime? @updatedAt
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { requireManager } from "@/src/lib/auth/getUser";
|
||||
import EmployeeCreateView from "@/src/modules/employee/ui/views/employee-create-view";
|
||||
import { getMyEmployeeRoles } from "@/src/modules/employee/actions/employee-actions";
|
||||
|
||||
const EmployeeCreate = async () => {
|
||||
await requireManager();
|
||||
return <EmployeeCreateView />;
|
||||
|
||||
// Lade alle bereits verwendeten custom Rollen
|
||||
const rolesResult = await getMyEmployeeRoles();
|
||||
const customRoles = rolesResult.success ? rolesResult.roles : [];
|
||||
|
||||
return <EmployeeCreateView customRoles={customRoles} />;
|
||||
};
|
||||
|
||||
export default EmployeeCreate;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
const Onboarding = () => {
|
||||
return <div>Onboarding Employee</div>;
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
469
src/modules/employee/actions/employee-actions.ts
Normal file
469
src/modules/employee/actions/employee-actions.ts
Normal file
|
|
@ -0,0 +1,469 @@
|
|||
/**
|
||||
* Employee Actions - Server-seitige Aktionen für Mitarbeiterverwaltung
|
||||
*
|
||||
* Diese Datei enthält alle Server Actions für das Mitarbeiter-Management-System.
|
||||
* Funktionen umfassen: Mitarbeiter erstellen, abrufen, aktualisieren und löschen.
|
||||
* Alle Funktionen sind mit Authentifizierung und Autorisierung geschützt.
|
||||
*/
|
||||
|
||||
"use server";
|
||||
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { getUserData, isManager } from "@/src/lib/auth/getUser";
|
||||
import { employeeSchema, type EmployeeFormData } from "../schemas/employee-schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { randomBytes } from "crypto";
|
||||
|
||||
/**
|
||||
* Generiert einen sicheren Onboarding-Token
|
||||
*
|
||||
* @returns Ein zufälliger 32-Byte Token als Hex-String
|
||||
*/
|
||||
function generateOnboardingToken(): string {
|
||||
return randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt einen neuen Mitarbeiter in der Datenbank
|
||||
*
|
||||
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
|
||||
* und erstellt einen neuen Mitarbeiter. Der Mitarbeiter erhält einen Onboarding-Token,
|
||||
* der später für den Onboarding-Prozess verwendet wird.
|
||||
* Nur Manager können Mitarbeiter erstellen.
|
||||
*
|
||||
* @param data - Die Mitarbeiterdaten aus dem Formular
|
||||
* @param status - Der Status des Mitarbeiters ("draft" für Entwurf oder "published" für veröffentlicht)
|
||||
* @returns Ein Objekt mit success-Flag, optionaler employeeId, onboardingToken und Nachricht oder Fehler
|
||||
*/
|
||||
export async function createEmployee(data: EmployeeFormData, status: "draft" | "published") {
|
||||
try {
|
||||
// Schritt 1: Aktuellen Benutzer abrufen
|
||||
const userData = await getUserData();
|
||||
|
||||
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
|
||||
if (!userData) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Sie müssen angemeldet sein, um einen Mitarbeiter zu erstellen",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 3: Überprüfen, ob Benutzer ein Manager ist (nur Manager dürfen Mitarbeiter erstellen)
|
||||
if (!isManager(userData)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Nur Manager können Mitarbeiter erstellen",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 4: Formulardaten mit Zod-Schema validieren
|
||||
const validation = employeeSchema.safeParse(data);
|
||||
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Validierungsfehler",
|
||||
fieldErrors: validation.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const validatedData = validation.data;
|
||||
|
||||
// Schritt 5: Überprüfen, ob E-Mail bereits existiert
|
||||
const existingEmployee = await prisma.employee.findUnique({
|
||||
where: { email: validatedData.email },
|
||||
});
|
||||
|
||||
if (existingEmployee) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 6: Onboarding-Token generieren (nur wenn veröffentlicht)
|
||||
let onboardingToken: string | null = null;
|
||||
let onboardingTokenExpiry: Date | null = null;
|
||||
|
||||
if (status === "published") {
|
||||
onboardingToken = generateOnboardingToken();
|
||||
// Token ist 7 Tage gültig
|
||||
onboardingTokenExpiry = new Date();
|
||||
onboardingTokenExpiry.setDate(onboardingTokenExpiry.getDate() + 7);
|
||||
}
|
||||
|
||||
// Schritt 7: Mitarbeiter in der Datenbank erstellen
|
||||
const employee = await prisma.employee.create({
|
||||
data: {
|
||||
email: validatedData.email,
|
||||
roles: validatedData.roles,
|
||||
locations: validatedData.locations || [],
|
||||
permissions: validatedData.permissions,
|
||||
status: status,
|
||||
isOnboarded: false,
|
||||
onboardingToken,
|
||||
onboardingTokenExpiry,
|
||||
createdBy: userData.id, // Manager-ID als Ersteller
|
||||
},
|
||||
});
|
||||
|
||||
// Schritt 8: Next.js Cache für /employee Seite invalidieren, damit neue Daten angezeigt werden
|
||||
revalidatePath("/employee");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employeeId: employee.id,
|
||||
onboardingToken: onboardingToken || undefined,
|
||||
message:
|
||||
status === "published"
|
||||
? "Mitarbeiter erfolgreich veröffentlicht. Onboarding-Link wurde generiert."
|
||||
: "Entwurf erfolgreich gespeichert",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error creating employee:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Ein Fehler ist beim Erstellen des Mitarbeiters aufgetreten",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft alle Mitarbeiter ab, die vom eingeloggten Manager erstellt wurden
|
||||
*
|
||||
* Diese Funktion gibt alle Mitarbeiter zurück, die vom aktuellen Manager erstellt wurden,
|
||||
* unabhängig vom Status (draft oder published).
|
||||
*
|
||||
* @returns Ein Objekt mit success-Flag und einem Array von Mitarbeitern
|
||||
*/
|
||||
export async function getMyEmployees() {
|
||||
try {
|
||||
// Schritt 1: Aktuellen Benutzer abrufen
|
||||
const userData = await getUserData();
|
||||
|
||||
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
|
||||
if (!userData || !isManager(userData)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
employees: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
createdBy: userData.id, // Filter nach Manager ID
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc", // Neueste zuerst
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employees,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching employees:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Fehler beim Laden der Mitarbeiter",
|
||||
employees: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft einen einzelnen Mitarbeiter nach ID ab
|
||||
*
|
||||
* Diese Funktion gibt einen Mitarbeiter zurück, wenn der eingeloggte Manager
|
||||
* der Ersteller des Mitarbeiters ist.
|
||||
*
|
||||
* @param employeeId - Die eindeutige ID des Mitarbeiters
|
||||
* @returns Ein Objekt mit success-Flag und dem Mitarbeiter oder Fehler
|
||||
*/
|
||||
export async function getEmployeeById(employeeId: string) {
|
||||
try {
|
||||
// Schritt 1: Aktuellen Benutzer abrufen
|
||||
const userData = await getUserData();
|
||||
|
||||
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
|
||||
if (!userData || !isManager(userData)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Nur Manager können Mitarbeiter bearbeiten",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
});
|
||||
|
||||
// Schritt 4: Überprüfen, ob Mitarbeiter existiert
|
||||
if (!employee) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Mitarbeiter nicht gefunden",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 5: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
|
||||
if (employee.createdBy !== userData.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Sie können nur Ihre eigenen Mitarbeiter bearbeiten",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
employee,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching employee:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Fehler beim Laden des Mitarbeiters",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert einen bestehenden Mitarbeiter
|
||||
*
|
||||
* Diese Funktion validiert die Eingabedaten, überprüft die Berechtigungen des Benutzers
|
||||
* und aktualisiert einen bestehenden Mitarbeiter. Nur der Manager, der den Mitarbeiter erstellt hat,
|
||||
* kann ihn bearbeiten.
|
||||
*
|
||||
* @param employeeId - Die eindeutige ID des zu aktualisierenden Mitarbeiters
|
||||
* @param data - Die aktualisierten Mitarbeiterdaten aus dem Formular
|
||||
* @param status - Der Status des Mitarbeiters ("draft" für Entwurf oder "published" für veröffentlicht)
|
||||
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
|
||||
*/
|
||||
export async function updateEmployee(
|
||||
employeeId: string,
|
||||
data: EmployeeFormData,
|
||||
status: "draft" | "published"
|
||||
) {
|
||||
try {
|
||||
// Schritt 1: Aktuellen Benutzer abrufen
|
||||
const userData = await getUserData();
|
||||
|
||||
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
|
||||
if (!userData) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Sie müssen angemeldet sein, um einen Mitarbeiter zu bearbeiten",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 3: Überprüfen, ob Benutzer ein Manager ist
|
||||
if (!isManager(userData)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Nur Manager können Mitarbeiter bearbeiten",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 4: Mitarbeiter aus der Datenbank abrufen
|
||||
const existingEmployee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
});
|
||||
|
||||
// Schritt 5: Überprüfen, ob Mitarbeiter existiert
|
||||
if (!existingEmployee) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Mitarbeiter nicht gefunden",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 6: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
|
||||
if (existingEmployee.createdBy !== userData.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Sie können nur Ihre eigenen Mitarbeiter bearbeiten",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 7: Formulardaten mit Zod-Schema validieren
|
||||
const validation = employeeSchema.safeParse(data);
|
||||
|
||||
if (!validation.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Validierungsfehler",
|
||||
fieldErrors: validation.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const validatedData = validation.data;
|
||||
|
||||
// Schritt 8: Onboarding-Token generieren, falls noch nicht vorhanden und veröffentlicht
|
||||
let onboardingToken = existingEmployee.onboardingToken;
|
||||
let onboardingTokenExpiry = existingEmployee.onboardingTokenExpiry;
|
||||
|
||||
if (status === "published" && !existingEmployee.onboardingToken) {
|
||||
onboardingToken = generateOnboardingToken();
|
||||
onboardingTokenExpiry = new Date();
|
||||
onboardingTokenExpiry.setDate(onboardingTokenExpiry.getDate() + 7);
|
||||
}
|
||||
|
||||
// Schritt 9: Mitarbeiter in der Datenbank aktualisieren
|
||||
await prisma.employee.update({
|
||||
where: { id: employeeId },
|
||||
data: {
|
||||
email: validatedData.email,
|
||||
roles: validatedData.roles,
|
||||
locations: validatedData.locations || [],
|
||||
permissions: validatedData.permissions,
|
||||
status,
|
||||
onboardingToken,
|
||||
onboardingTokenExpiry,
|
||||
},
|
||||
});
|
||||
|
||||
// Schritt 10: Next.js Cache invalidieren
|
||||
revalidatePath("/employee");
|
||||
revalidatePath(`/employee/edit/${employeeId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
status === "published"
|
||||
? "Mitarbeiter erfolgreich aktualisiert und veröffentlicht"
|
||||
: "Entwurf erfolgreich aktualisiert",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating employee:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Ein Fehler ist beim Aktualisieren des Mitarbeiters aufgetreten",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ruft alle verwendeten Rollen des Managers ab
|
||||
*
|
||||
* Diese Funktion extrahiert alle Rollen aus den Mitarbeitern des aktuellen Managers,
|
||||
* dedupliziert sie und gibt sie zurück. Dies ermöglicht es, custom Rollen
|
||||
* wiederzuverwenden ohne eine separate Rollen-Tabelle zu benötigen.
|
||||
*
|
||||
* @returns Ein Objekt mit success-Flag und einem Array von Rollen im Format {value, label}
|
||||
*/
|
||||
export async function getMyEmployeeRoles() {
|
||||
try {
|
||||
// Schritt 1: Aktuellen Benutzer abrufen
|
||||
const userData = await getUserData();
|
||||
|
||||
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
|
||||
if (!userData || !isManager(userData)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
roles: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 3: Alle Mitarbeiter des Managers abrufen (nur roles Feld)
|
||||
const employees = await prisma.employee.findMany({
|
||||
where: {
|
||||
createdBy: userData.id,
|
||||
},
|
||||
select: {
|
||||
roles: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Schritt 4: Alle Rollen extrahieren und deduplizieren
|
||||
const allRoles = employees.flatMap((employee) => employee.roles);
|
||||
const uniqueRoles = [...new Set(allRoles)];
|
||||
|
||||
// Schritt 5: In das Format {value, label} konvertieren
|
||||
const formattedRoles = uniqueRoles.map((role) => ({
|
||||
value: role,
|
||||
label: role.charAt(0).toUpperCase() + role.slice(1), // Capitalize first letter
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
roles: formattedRoles,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error fetching employee roles:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Fehler beim Laden der Rollen",
|
||||
roles: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht einen Mitarbeiter aus der Datenbank
|
||||
*
|
||||
* Diese Funktion löscht einen Mitarbeiter, überprüft aber vorher:
|
||||
* - Ob der Benutzer ein Manager ist
|
||||
* - Ob der Mitarbeiter existiert
|
||||
* - Ob der Manager der Besitzer des Mitarbeiters ist (Manager können nur eigene Mitarbeiter löschen)
|
||||
*
|
||||
* @param employeeId - Die eindeutige ID des zu löschenden Mitarbeiters
|
||||
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
|
||||
*/
|
||||
export async function deleteEmployee(employeeId: string) {
|
||||
try {
|
||||
// Schritt 1: Aktuellen Benutzer abrufen
|
||||
const userData = await getUserData();
|
||||
|
||||
// Schritt 2: Überprüfen, ob Benutzer ein Manager ist
|
||||
if (!userData || !isManager(userData)) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Nur Manager können Mitarbeiter löschen",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 3: Mitarbeiter aus der Datenbank abrufen
|
||||
const employee = await prisma.employee.findUnique({
|
||||
where: { id: employeeId },
|
||||
});
|
||||
|
||||
// Schritt 4: Überprüfen, ob Mitarbeiter existiert
|
||||
if (!employee) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Mitarbeiter nicht gefunden",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 5: Überprüfen, ob der Manager der Besitzer des Mitarbeiters ist
|
||||
if (employee.createdBy !== userData.id) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Sie können nur Ihre eigenen Mitarbeiter löschen",
|
||||
};
|
||||
}
|
||||
|
||||
// Schritt 6: Mitarbeiter aus der Datenbank löschen
|
||||
await prisma.employee.delete({
|
||||
where: { id: employeeId },
|
||||
});
|
||||
|
||||
// Schritt 7: Next.js Cache invalidieren
|
||||
revalidatePath("/employee");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Mitarbeiter erfolgreich gelöscht",
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error deleting employee:", error);
|
||||
return {
|
||||
success: false,
|
||||
error: "Fehler beim Löschen des Mitarbeiters",
|
||||
};
|
||||
}
|
||||
}
|
||||
59
src/modules/employee/schemas/employee-schema.ts
Normal file
59
src/modules/employee/schemas/employee-schema.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
/**
|
||||
* Employee Schema - Validierungsschema für Mitarbeiter-Formulare
|
||||
*
|
||||
* Diese Datei definiert das Zod-Schema zur Validierung von Mitarbeiterdaten.
|
||||
* Es stellt sicher, dass alle erforderlichen Felder korrekt ausgefüllt werden.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Haupt-Validierungsschema für Mitarbeiter
|
||||
*
|
||||
* Dieses Schema validiert:
|
||||
* - E-Mail-Adresse des Mitarbeiters
|
||||
* - Rollen/Status des Mitarbeiters
|
||||
* - Standort-Zugriffe (einzeln oder mehrere)
|
||||
* - Administrationsrechte
|
||||
*/
|
||||
export const employeeSchema = z
|
||||
.object({
|
||||
// E-Mail (Pflichtfeld, muss gültige E-Mail sein)
|
||||
email: z.string().email("Gültige E-Mail-Adresse erforderlich"),
|
||||
|
||||
// Array von Rollen (mindestens eine Rolle erforderlich)
|
||||
roles: z.array(z.string()).min(1, "Mindestens eine Rolle muss ausgewählt werden"),
|
||||
|
||||
// Gibt an, ob der Mitarbeiter für mehrere Standorte zuständig ist
|
||||
isMultipleLocation: z.boolean(),
|
||||
|
||||
// Array von Standort-IDs - optional
|
||||
locations: z.array(z.string()).optional(),
|
||||
|
||||
// Administrationsrechte
|
||||
permissions: z.object({
|
||||
// Berechtigung zum Anlegen von Kursen
|
||||
canCreateCourses: z.boolean(),
|
||||
// Berechtigung zum Anlegen von Mitarbeitern
|
||||
canCreateEmployees: z.boolean(),
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
// Validierungsregel: Wenn mehrere Standorte aktiviert sind, muss mindestens ein Standort ausgewählt werden
|
||||
if (data.isMultipleLocation && (!data.locations || data.locations.length === 0)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "Mindestens ein Standort muss ausgewählt werden",
|
||||
path: ["locations"],
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* TypeScript-Typ für Mitarbeiterdaten
|
||||
* Wird automatisch aus dem Zod-Schema generiert
|
||||
*/
|
||||
export type EmployeeFormData = z.infer<typeof employeeSchema>;
|
||||
156
src/modules/employee/ui/components/EmployeeListItem.tsx
Normal file
156
src/modules/employee/ui/components/EmployeeListItem.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* EmployeeListItem - Mitarbeiter-Karten-Komponente
|
||||
*
|
||||
* Diese Komponente zeigt einen einzelnen Mitarbeiter als Karte an.
|
||||
* Sie wird in der Mitarbeiterliste verwendet und zeigt alle wichtigen Mitarbeiterinformationen
|
||||
* inklusive Rollen, Standorte, Onboarding-Status und Bearbeitungsoption.
|
||||
*/
|
||||
|
||||
import { Pen, Trash2, Mail, MapPin, Shield, Lock } from "lucide-react";
|
||||
|
||||
/**
|
||||
* Props für die EmployeeListItem Komponente
|
||||
*/
|
||||
interface EmployeeListItemProps {
|
||||
employeeId: string; // ID des Mitarbeiters
|
||||
employeeName: string; // Name des Mitarbeiters (oder E-Mail wenn Name nicht vorhanden)
|
||||
email: string; // E-Mail-Adresse
|
||||
roles: string[]; // Rollen des Mitarbeiters
|
||||
locations: string[]; // Standorte
|
||||
permissions: {
|
||||
canCreateCourses: boolean;
|
||||
canCreateEmployees: boolean;
|
||||
} | null; // Administrationsrechte
|
||||
status: string; // Status ("draft" oder "published")
|
||||
isOnboarded: boolean; // Ob der Mitarbeiter das Onboarding abgeschlossen hat
|
||||
onEdit?: () => void; // Callback-Funktion beim Klick auf Bearbeiten-Button
|
||||
onDelete?: () => void; // Callback-Funktion beim Klick auf Löschen-Button
|
||||
showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll
|
||||
}
|
||||
|
||||
/**
|
||||
* Mitarbeiter-Karten-Komponente
|
||||
* Zeigt Mitarbeiterinformationen in einem visuell ansprechenden Karten-Layout
|
||||
*/
|
||||
const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
|
||||
employeeName,
|
||||
email,
|
||||
roles,
|
||||
locations,
|
||||
permissions,
|
||||
status,
|
||||
isOnboarded,
|
||||
onEdit,
|
||||
onDelete,
|
||||
showDeleteIcon,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-xl p-5 shadow-sm hover:shadow-md transition flex flex-col gap-4 relative">
|
||||
{/* Status Badge */}
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex gap-2">
|
||||
<span
|
||||
className={`w-fit text-xs font-semibold px-2 py-1 rounded ${
|
||||
status === "draft" ? "bg-yellow-100 text-yellow-700" : "bg-green-100 text-green-700"
|
||||
}`}
|
||||
>
|
||||
{status === "draft" ? "Entwurf" : "Veröffentlicht"}
|
||||
</span>
|
||||
<span
|
||||
className={`w-fit text-xs font-semibold px-2 py-1 rounded ${
|
||||
isOnboarded ? "bg-blue-100 text-blue-700" : "bg-gray-100 text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{isOnboarded ? "Onboarded" : "Ausstehend"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name und E-Mail */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">{employeeName}</h2>
|
||||
<div className="flex items-center gap-2 text-gray-500 mt-1">
|
||||
<Mail size={16} />
|
||||
<p className="text-sm">{email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rollen */}
|
||||
{roles.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-600 mb-2">
|
||||
<Shield size={16} />
|
||||
<span className="text-sm font-medium">Rollen</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{roles.map((role, index) => (
|
||||
<span key={index} className="px-3 py-1 bg-blue-50 text-blue-700 text-sm rounded-full">
|
||||
{role}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standorte */}
|
||||
{locations.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-600 mb-2">
|
||||
<MapPin size={16} />
|
||||
<span className="text-sm font-medium">Standorte</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{locations.map((location, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-3 py-1 bg-gray-100 text-gray-700 text-sm rounded-full"
|
||||
>
|
||||
{location}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Administrationsrechte */}
|
||||
{permissions && (permissions.canCreateCourses || permissions.canCreateEmployees) && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-gray-600 mb-2">
|
||||
<Lock size={16} />
|
||||
<span className="text-sm font-medium">Administrationsrechte</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{permissions.canCreateCourses && (
|
||||
<span className="px-3 py-1 bg-purple-50 text-purple-700 text-sm rounded-full">
|
||||
Kurse anlegen
|
||||
</span>
|
||||
)}
|
||||
{permissions.canCreateEmployees && (
|
||||
<span className="px-3 py-1 bg-purple-50 text-purple-700 text-sm rounded-full">
|
||||
Trainer anlegen
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end items-center pt-3 border-t border-gray-200">
|
||||
<div className="flex gap-2">
|
||||
{showDeleteIcon && onDelete && (
|
||||
<button onClick={onDelete} className="p-2 rounded-md hover:bg-red-100 transition">
|
||||
<Trash2 size={20} className="text-red-600" />
|
||||
</button>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button onClick={onEdit} className="p-2 rounded-md hover:bg-gray-100 transition">
|
||||
<Pen size={20} className="text-gray-700" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeListItem;
|
||||
|
|
@ -7,6 +7,8 @@ import { Checkbox } from "@/src/components/ui/checkbox";
|
|||
import { Label } from "@/src/components/ui/label";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { createEmployee } from "../../actions/employee-actions";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const LOCATION = [
|
||||
{ value: "hainburg", label: "Hainburg" },
|
||||
|
|
@ -21,20 +23,105 @@ const INITIAL_ROLES = [
|
|||
{ value: "manager", label: "Manager" },
|
||||
];
|
||||
|
||||
const EmployeeCreateView = () => {
|
||||
interface EmployeeCreateViewProps {
|
||||
customRoles: Array<{ value: string; label: string }>;
|
||||
}
|
||||
|
||||
const EmployeeCreateView = ({ customRoles }: EmployeeCreateViewProps) => {
|
||||
const router = useRouter();
|
||||
const [employeeMail, setEmplyoeeMail] = useState("");
|
||||
const [isMultipleLocation, setIsMultipleLocation] = useState(false);
|
||||
const [selectedLocations, setSelectedLocations] = useState<string[]>([]);
|
||||
const [selectedRoles, setSelectedRoles] = useState<string[]>([]);
|
||||
const [roles, setRoles] = useState(INITIAL_ROLES);
|
||||
|
||||
// Kombiniere Standard-Rollen mit custom Rollen (ohne Duplikate)
|
||||
const allRoleValues = new Set([
|
||||
...INITIAL_ROLES.map((r) => r.value),
|
||||
...customRoles.map((r) => r.value),
|
||||
]);
|
||||
const combinedRoles = Array.from(allRoleValues).map((value) => {
|
||||
const customRole = customRoles.find((r) => r.value === value);
|
||||
const initialRole = INITIAL_ROLES.find((r) => r.value === value);
|
||||
return customRole || initialRole || { value, label: value };
|
||||
});
|
||||
|
||||
const [roles, setRoles] = useState(combinedRoles);
|
||||
const [isPermissionCourseCreation, setIsPermissionCourseCreation] = useState(false);
|
||||
const [isPermissionEmplyoeeCreation, setIsPermissionEmplyoeeCreation] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string[]>>({});
|
||||
|
||||
const handleCreateRole = (newRole: { value: string; label: string }) => {
|
||||
setRoles([...roles, newRole]);
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
setIsSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
const data = {
|
||||
email: employeeMail,
|
||||
roles: selectedRoles,
|
||||
isMultipleLocation,
|
||||
locations: isMultipleLocation ? selectedLocations : [],
|
||||
permissions: {
|
||||
canCreateCourses: isPermissionCourseCreation,
|
||||
canCreateEmployees: isPermissionEmplyoeeCreation,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createEmployee(data, "published");
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
|
||||
if (result.onboardingToken) {
|
||||
const onboardingUrl = `${window.location.origin}/employee/onboarding/${result.onboardingToken}`;
|
||||
console.log("Onboarding URL:", onboardingUrl);
|
||||
// TODO: Hier später die Mail-Versendung implementieren
|
||||
}
|
||||
|
||||
router.push("/employee");
|
||||
} else {
|
||||
toast.error(result.error || "Ein Fehler ist aufgetreten");
|
||||
if (result.fieldErrors) {
|
||||
setErrors(result.fieldErrors);
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
setIsSubmitting(true);
|
||||
setErrors({});
|
||||
|
||||
const data = {
|
||||
email: employeeMail,
|
||||
roles: selectedRoles,
|
||||
isMultipleLocation,
|
||||
locations: isMultipleLocation ? selectedLocations : [],
|
||||
permissions: {
|
||||
canCreateCourses: isPermissionCourseCreation,
|
||||
canCreateEmployees: isPermissionEmplyoeeCreation,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await createEmployee(data, "draft");
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
router.push("/employee");
|
||||
} else {
|
||||
toast.error(result.error || "Ein Fehler ist aufgetreten");
|
||||
if (result.fieldErrors) {
|
||||
setErrors(result.fieldErrors);
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const permissions = [
|
||||
{
|
||||
id: "course-creation",
|
||||
|
|
@ -66,16 +153,20 @@ const EmployeeCreateView = () => {
|
|||
value={employeeMail}
|
||||
onChange={(e) => setEmplyoeeMail(e.target.value)}
|
||||
/>
|
||||
{errors.email && <p className="text-red-500 text-sm mt-1">{errors.email[0]}</p>}
|
||||
</div>
|
||||
|
||||
<MultiSelectDropdown
|
||||
label="Welche Rollen hat der Mitarbeiter?"
|
||||
selected={selectedRoles}
|
||||
onSelect={setSelectedRoles}
|
||||
options={roles}
|
||||
allowCreate={true}
|
||||
onCreateOption={handleCreateRole}
|
||||
/>
|
||||
<div>
|
||||
<MultiSelectDropdown
|
||||
label="Welche Rollen hat der Mitarbeiter?"
|
||||
selected={selectedRoles}
|
||||
onSelect={setSelectedRoles}
|
||||
options={roles}
|
||||
allowCreate={true}
|
||||
onCreateOption={handleCreateRole}
|
||||
/>
|
||||
{errors.roles && <p className="text-red-500 text-sm mt-1">{errors.roles[0]}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
|
|
@ -95,19 +186,20 @@ const EmployeeCreateView = () => {
|
|||
</div>
|
||||
|
||||
{isMultipleLocation && (
|
||||
<>
|
||||
<div>
|
||||
<MultiSelectDropdown
|
||||
label="An welchen Standorten ist der Trainer aktiv?"
|
||||
selected={selectedLocations}
|
||||
onSelect={setSelectedLocations}
|
||||
options={LOCATION}
|
||||
/>
|
||||
</>
|
||||
{errors.locations && <p className="text-red-500 text-sm mt-1">{errors.locations[0]}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="p-1 mb-2 text-blue text-lg font-semibold">
|
||||
Welche Administrationsrechte soll der Trainer erhalten
|
||||
Welche Administrationsrechte soll der Mitarbeiter erhalten
|
||||
</Label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{permissions.map((p) => (
|
||||
|
|
@ -126,13 +218,23 @@ const EmployeeCreateView = () => {
|
|||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button>Veröffentlichen</Button>
|
||||
<Button variant="outline" onClick={() => router.push("/employee")}>
|
||||
<Button onClick={handlePublish} disabled={isSubmitting}>
|
||||
{isSubmitting ? "Wird veröffentlicht..." : "Veröffentlichen"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.push("/employee")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
<button className="mt-6 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Entwurf speichern
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={isSubmitting}
|
||||
className="mt-6 w-fit cursor-pointer hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? "Wird gespeichert..." : "Entwurf speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
152
src/modules/employee/ui/views/employee-list-view.tsx
Normal file
152
src/modules/employee/ui/views/employee-list-view.tsx
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Button } from "@/src/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getMyEmployees, deleteEmployee } from "../../actions/employee-actions";
|
||||
import EmployeeListItem from "../components/EmployeeListItem";
|
||||
import { toast } from "sonner";
|
||||
import DeleteDialog from "@/src/components/layout/DeleteDialog";
|
||||
|
||||
type Employee = {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string;
|
||||
roles: string[];
|
||||
locations: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
permissions: any;
|
||||
status: string | null;
|
||||
isOnboarded: boolean;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
};
|
||||
|
||||
const EmployeeListView = () => {
|
||||
const router = useRouter();
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [deleteMode, setDeleteMode] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [employeeToDelete, setEmployeeToDelete] = useState<{ id: string; name: string } | null>(
|
||||
null
|
||||
);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadEmployees = async () => {
|
||||
setIsLoading(true);
|
||||
const result = await getMyEmployees();
|
||||
if (result.success) {
|
||||
setEmployees(result.employees);
|
||||
} else {
|
||||
toast.error(result.error || "Fehler beim Laden der Mitarbeiter");
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
const requestDelete = (employeeId: string, employeeName: string) => {
|
||||
setEmployeeToDelete({ id: employeeId, name: employeeName });
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!employeeToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
const result = await deleteEmployee(employeeToDelete.id);
|
||||
|
||||
setIsDeleting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setEmployeeToDelete(null);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(result.message);
|
||||
// Reload employees
|
||||
const reloadResult = await getMyEmployees();
|
||||
if (reloadResult.success) {
|
||||
setEmployees(reloadResult.employees);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || "Fehler beim Löschen");
|
||||
}
|
||||
};
|
||||
|
||||
const getEmployeeName = (employee: Employee) => {
|
||||
if (employee.firstName && employee.lastName) {
|
||||
return `${employee.firstName} ${employee.lastName}`;
|
||||
}
|
||||
return employee.email;
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[400px]">
|
||||
<p className="text-xl">Mitarbeiter wird geladen</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex w-full justify-end mr-2 gap-2">
|
||||
<Button
|
||||
variant={deleteMode ? "destructive" : "outline"}
|
||||
onClick={() => setDeleteMode(!deleteMode)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{deleteMode ? "Abbrechen" : "Mitarbeiter löschen"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{employees.length === 0 ? (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg">
|
||||
<p className="text-xl text-gray-600">Sie haben noch keinen Mitarbeiter erstellt</p>
|
||||
<p className="text-gray-500 mt-2">Erstellen Sie Ihren ersten Mitarbeiter!</p>
|
||||
<Button onClick={() => router.push("/employee/create")} className="mt-4">
|
||||
Mitarbeiter anlegen
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{employees.map((employee) => (
|
||||
<EmployeeListItem
|
||||
key={employee.id}
|
||||
employeeId={employee.id}
|
||||
employeeName={getEmployeeName(employee)}
|
||||
email={employee.email}
|
||||
roles={employee.roles}
|
||||
locations={employee.locations}
|
||||
permissions={
|
||||
employee.permissions as {
|
||||
canCreateCourses: boolean;
|
||||
canCreateEmployees: boolean;
|
||||
} | null
|
||||
}
|
||||
status={employee.status || "draft"}
|
||||
isOnboarded={employee.isOnboarded}
|
||||
showDeleteIcon={deleteMode}
|
||||
onDelete={() => requestDelete(employee.id, getEmployeeName(employee))}
|
||||
onEdit={() => router.push(`/employee/edit/${employee.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DeleteDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
courseName={employeeToDelete?.name}
|
||||
onConfirm={confirmDelete}
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmployeeListView;
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import { Button } from "@/src/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
import EmployeeListView from "./employee-list-view";
|
||||
|
||||
const EmployeeView = () => {
|
||||
const router = useRouter();
|
||||
|
|
@ -19,7 +20,7 @@ const EmployeeView = () => {
|
|||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Mitarbeiter List View */}
|
||||
<EmployeeListView />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue