Merge pull request #132 from Since-3/refactor/employee-card-design

Refactor/employee card design
This commit is contained in:
Rajpreet0 2026-07-14 19:31:14 +02:00 • committed by GitHub
commit 378e16b5c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 154 additions and 142 deletions

View file

@ -42,7 +42,7 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo
return (
<div className="flex flex-col items-center gap-3">
<div
onClick={() => !preview && inputRef.current?.click()}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
@ -51,7 +51,7 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo
onDrop={handleDrop}
className={`relative w-28 h-28 rounded-full border-2 border-dashed flex items-center justify-center cursor-pointer transition-colors overflow-hidden
${isDragging ? "border-blue bg-blue/5" : "border-gray-300 hover:border-blue hover:bg-gray-50"}
${preview ? "border-solid border-blue cursor-default" : ""}
${preview ? "border-solid border-blue" : ""}
`}
>
{preview ? (
@ -73,15 +73,13 @@ const ImageUploadComponent = ({ value, preview, onChange, error }: ImageUploadCo
)}
</div>
{!preview && (
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-sm text-blue underline cursor-pointer"
>
Bild auswählen
</button>
)}
<button
type="button"
onClick={() => inputRef.current?.click()}
className="text-sm text-blue underline cursor-pointer"
>
{preview ? "Bild ändern" : "Bild auswählen"}
</button>
<input
ref={inputRef}

View file

@ -3,11 +3,12 @@
*
* 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.
* inklusive Rollen, Onboarding-Status und Bearbeitungsoption.
*/
import { Pen, Trash2, Mail, MapPin, Shield, Lock, Send, Loader2 } from "lucide-react";
import { Pen, Trash2, Mail, Send, Loader2 } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/src/components/ui/tooltip";
import { Avatar, AvatarFallback, AvatarImage } from "@/src/components/ui/avatar";
/**
* Props für die EmployeeListItem Komponente
@ -16,13 +17,8 @@ interface EmployeeListItemProps {
employeeId: string; // ID des Mitarbeiters
employeeName: string; // Name des Mitarbeiters (oder E-Mail wenn Name nicht vorhanden)
email: string; // E-Mail-Adresse
pbSrc?: string | null; // URL des Profilbilds
roles: string[]; // Rollen des Mitarbeiters
locations: string[]; // Standorte
permissions: {
employees: { view: boolean; create: boolean; edit: boolean; delete: boolean };
courses: { view: boolean; create: boolean; edit: boolean; delete: boolean };
rooms: { view: boolean; create: boolean; edit: boolean; delete: 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
@ -32,6 +28,13 @@ interface EmployeeListItemProps {
showDeleteIcon?: boolean; // Ob das Löschen-Icon angezeigt werden soll
}
const getInitials = (name: string) => {
if (!name) return "";
const parts = name.trim().split(" ");
if (parts.length === 1) return parts[0][0].toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
};
/**
* Mitarbeiter-Karten-Komponente
* Zeigt Mitarbeiterinformationen in einem visuell ansprechenden Karten-Layout
@ -39,9 +42,8 @@ interface EmployeeListItemProps {
const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
employeeName,
email,
pbSrc,
roles,
locations,
permissions,
status,
isOnboarded,
onEdit,
@ -50,11 +52,36 @@ const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
isResendingEmail,
showDeleteIcon,
}) => {
const canResend = status === "published" && !isOnboarded && onResendEmail;
return (
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-5 shadow-sm hover:shadow-md hover:bg-white/70 transition flex flex-col gap-4 relative">
{/* Status Badge */}
<div className="flex justify-between items-start">
<div className="flex gap-2">
<div className="border border-white/60 bg-white/55 backdrop-blur-xl rounded-xl p-5 shadow-sm hover:shadow-md hover:bg-white/70 transition flex flex-col gap-3 relative">
{/* Avatar + Name/Rollen + Status Badges */}
<div className="flex justify-between items-start gap-3">
<div className="flex items-start gap-3 min-w-0">
<Avatar className="size-12 shrink-0">
{pbSrc && <AvatarImage src={pbSrc} alt={employeeName} />}
<AvatarFallback className="bg-blue-50 text-blue-700 font-semibold">
{getInitials(employeeName)}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<h2 className="text-xl font-semibold text-gray-900 truncate">{employeeName}</h2>
{roles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1">
{roles.map((role, index) => (
<span
key={index}
className="w-fit text-xs font-semibold px-2 py-1 rounded bg-blue-50 text-blue-700"
>
{role}
</span>
))}
</div>
)}
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<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"
@ -72,106 +99,25 @@ const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
</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>
<div className="h-px bg-white/50" />
{/* 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>
)}
{/* Actions */}
<div className="flex items-center justify-between">
<Tooltip>
<TooltipTrigger asChild>
<a
href={`mailto:${email}`}
aria-label={`E-Mail an ${email} senden`}
className="p-2 rounded-md hover:bg-white/50 transition flex items-center justify-center"
>
<Mail size={20} className="text-gray-700" />
</a>
</TooltipTrigger>
<TooltipContent side="top">{email}</TooltipContent>
</Tooltip>
{/* 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 &&
(() => {
const LABELS: Record<string, string> = {
"employees.view": "Mitarbeiter ansehen",
"employees.create": "Mitarbeiter erstellen",
"employees.edit": "Mitarbeiter bearbeiten",
"employees.delete": "Mitarbeiter löschen",
"courses.view": "Kurse ansehen",
"courses.create": "Kurse erstellen",
"courses.edit": "Kurse bearbeiten",
"courses.delete": "Kurse löschen",
"rooms.view": "Räume ansehen",
"rooms.create": "Räume erstellen",
"rooms.edit": "Räume bearbeiten",
"rooms.delete": "Räume löschen",
};
const active = (
Object.entries(permissions) as [string, Record<string, boolean>][]
).flatMap(([res, actions]) =>
(Object.entries(actions) as [string, boolean][])
.filter(([, v]) => v)
.map(([action]) => `${res}.${action}`)
);
if (active.length === 0) return null;
return (
<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">
{active.map((key) => (
<span
key={key}
className="px-3 py-1 bg-purple-50 text-purple-700 text-sm rounded-full"
>
{LABELS[key] ?? key}
</span>
))}
</div>
</div>
);
})()}
{/* Action Buttons */}
<div className="flex justify-end items-center pt-3 border-t border-white/40">
<div className="flex gap-2">
{showDeleteIcon && onDelete && (
<button onClick={onDelete} className="p-2 rounded-md hover:bg-red-500/10 transition">
<Trash2 size={20} className="text-red-600" />
</button>
)}
{status === "published" && !isOnboarded && onResendEmail && (
<div className="flex items-center gap-1">
{canResend && (
<Tooltip>
<TooltipTrigger asChild>
<button
@ -184,7 +130,7 @@ const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
: "Onboarding-Mail erneut senden"
}
aria-busy={isResendingEmail || undefined}
className="p-2 rounded-md hover:bg-blue-500/10 transition disabled:opacity-50 disabled:cursor-not-allowed"
className="p-2 rounded-md hover:bg-blue-500/10 transition disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isResendingEmail ? (
<Loader2 size={20} className="text-blue-600 animate-spin" />
@ -196,8 +142,23 @@ const EmployeeListItem: React.FC<EmployeeListItemProps> = ({
<TooltipContent side="top">Onboarding-Mail erneut senden</TooltipContent>
</Tooltip>
)}
{showDeleteIcon && onDelete && (
<button
onClick={onDelete}
aria-label="Mitarbeiter löschen"
className="p-2 rounded-md hover:bg-red-500/10 transition flex items-center justify-center"
>
<Trash2 size={20} className="text-red-600" />
</button>
)}
{onEdit && (
<button onClick={onEdit} className="p-2 rounded-md hover:bg-white/50 transition">
<button
onClick={onEdit}
aria-label="Mitarbeiter bearbeiten"
className="p-2 rounded-md hover:bg-white/50 transition flex items-center justify-center"
>
<Pen size={20} className="text-gray-700" />
</button>
)}

View file

@ -5,11 +5,16 @@ import MultiSelectDropdown from "@/src/components/layout/MultiSelectDropdown";
import { Button } from "@/src/components/ui/button";
import { Checkbox } from "@/src/components/ui/checkbox";
import { Label } from "@/src/components/ui/label";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/src/components/ui/tooltip";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { createEmployee, updateEmployee } from "../../actions/employee-actions";
import {
createEmployee,
updateEmployee,
resendOnboardingEmail,
} from "../../actions/employee-actions";
import { toast } from "sonner";
import { Trash2 } from "lucide-react";
import { Trash2, Send, Loader2 } from "lucide-react";
import DeleteDialog from "@/src/components/layout/DeleteDialog";
import { useDeleteEmployee } from "../../hooks/useDeleteEmployee";
@ -57,6 +62,7 @@ type InitialEmployeeData = {
locations: string[];
permissions: unknown;
status: string | null;
isOnboarded?: boolean;
};
interface EmployeeCreateViewProps {
@ -117,6 +123,7 @@ const EmployeeCreateView = ({
const [permissionsState, setPermissionsState] = useState<PermissionsState>(initialPermissions());
const [isSubmitting, setIsSubmitting] = useState(false);
const [errors, setErrors] = useState<Record<string, string[]>>({});
const [isResendingEmail, setIsResendingEmail] = useState(false);
const {
deleteDialogOpen,
@ -196,6 +203,29 @@ const EmployeeCreateView = ({
const handlePublish = () => handleSubmit("published");
const handleSaveDraft = () => handleSubmit("draft");
const canResend =
mode === "edit" &&
!!employeeId &&
initialData?.status === "published" &&
!initialData?.isOnboarded;
const handleResendEmail = async () => {
if (!employeeId) return;
setIsResendingEmail(true);
try {
const result = await resendOnboardingEmail(employeeId);
if (result.success) {
toast.success("Onboarding-Mail wurde erneut versendet");
} else {
toast.error(result.error || "Fehler beim Versenden der E-Mail");
}
} catch {
toast.error("Fehler beim Versenden der E-Mail");
} finally {
setIsResendingEmail(false);
}
};
return (
<div>
<div className="flex w-full items-center justify-between">
@ -208,12 +238,40 @@ const EmployeeCreateView = ({
</p>
</div>
{mode === "edit" && employeeId && (
<Button
variant="destructive"
onClick={() => handleDeleteClick(employeeId, initialData?.email ?? employeeId)}
>
<Trash2 size={20} className="mb-1" /> Löschen
</Button>
<div className="flex items-center gap-2">
{canResend && (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
onClick={handleResendEmail}
disabled={isResendingEmail}
aria-label={
isResendingEmail
? "Onboarding-Mail wird erneut gesendet"
: "Onboarding-Mail erneut senden"
}
aria-busy={isResendingEmail || undefined}
>
{isResendingEmail ? (
<Loader2 size={20} className="mb-1 animate-spin" />
) : (
<Send size={20} className="mb-1" />
)}
Erneut senden
</Button>
</TooltipTrigger>
<TooltipContent side="top">Onboarding-Mail erneut senden</TooltipContent>
</Tooltip>
)}
<Button
variant="destructive"
onClick={() => handleDeleteClick(employeeId, initialData?.email ?? employeeId)}
>
<Trash2 size={20} className="mb-1" /> Löschen
</Button>
</div>
)}
</div>
<div className="mt-6 flex flex-col gap-4 max-w-xl">

View file

@ -17,6 +17,7 @@ type Employee = {
firstName: string | null;
lastName: string | null;
email: string;
pbSrc: string | null;
roles: string[];
locations: string[];
permissions: unknown;
@ -188,22 +189,15 @@ const EmployeeListView = ({ employees, canCreate, canEdit, canDelete }: Employee
)}
</div>
) : (
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredEmployees.map((employee) => (
<EmployeeListItem
key={employee.id}
employeeId={employee.id}
employeeName={getEmployeeName(employee)}
email={employee.email}
pbSrc={employee.pbSrc}
roles={employee.roles}
locations={employee.locations}
permissions={
employee.permissions as {
employees: { view: boolean; create: boolean; edit: boolean; delete: boolean };
courses: { view: boolean; create: boolean; edit: boolean; delete: boolean };
rooms: { view: boolean; create: boolean; edit: boolean; delete: boolean };
} | null
}
status={employee.status || "draft"}
isOnboarded={employee.isOnboarded}
showDeleteIcon={deleteMode && canDelete}

View file

@ -9,6 +9,7 @@ type Employee = {
firstName: string | null;
lastName: string | null;
email: string;
pbSrc: string | null;
roles: string[];
locations: string[];
permissions: unknown;