mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
Merge pull request #132 from Since-3/refactor/employee-card-design
Refactor/employee card design
This commit is contained in:
commit
378e16b5c7
5 changed files with 154 additions and 142 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ type Employee = {
|
|||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string;
|
||||
pbSrc: string | null;
|
||||
roles: string[];
|
||||
locations: string[];
|
||||
permissions: unknown;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue