feat(sprint-5): Phase 6 — Staff management UI (list, invite, permissions, revoke)

- /settings/staff: staff account table with role badges + permission chips
- Invite sheet: email + role template + 8 granular permission checkboxes
- Edit permissions dialog with optimistic update
- Revoke access with AlertDialog confirmation
- React Query hooks wired (useStaffListQuery, mutations)
- Full i18n (de/en), mock fallback, loading skeletons
- Sidebar nav updated: Personal → /settings/staff with UserCog icon
- Added @radix-ui/react-checkbox + Checkbox UI component
This commit is contained in:
Patrick Plate
2026-06-12 20:32:54 +02:00
parent ed1efccc90
commit 2cc8c89944
9 changed files with 941 additions and 3 deletions
@@ -0,0 +1,7 @@
export default function SettingsLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}
@@ -0,0 +1,561 @@
"use client"
import { useState } from "react"
import {
useInviteStaffMutation,
useRevokeStaffMutation,
useStaffListQuery,
useUpdateStaffPermissionsMutation,
} from "@/services/staff"
import { useTranslations } from "next-intl"
import { Edit, Plus, ShieldX, UserCog } from "lucide-react"
import type { InviteStaffRequest, StaffMember } from "@/services/staff"
import { mockStaffAccounts } from "@/data/mock/staff"
import { useToast } from "@/hooks/use-toast"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { TableSkeleton } from "@/components/ui/data-skeleton"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Select } from "@/components/ui/select"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
// --- Permission definitions ---
const ALL_PERMISSIONS = [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
"ADD_MEMBER",
"VIEW_STOCK",
"RECORD_STOCK_IN",
"VIEW_COMPLIANCE_REPORT",
"MANAGE_GROW_CALENDAR",
] as const
type StaffPermission = (typeof ALL_PERMISSIONS)[number]
const ROLE_TEMPLATES: Record<string, StaffPermission[]> = {
ausgabe: ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST", "VIEW_MEMBER_QUOTA"],
lager: ["VIEW_STOCK", "RECORD_STOCK_IN", "MANAGE_GROW_CALENDAR"],
vorstand: [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
"ADD_MEMBER",
"VIEW_STOCK",
"RECORD_STOCK_IN",
"VIEW_COMPLIANCE_REPORT",
"MANAGE_GROW_CALENDAR",
],
custom: [],
}
// --- Status Badge ---
function StatusBadge({
status,
t,
}: {
status: StaffMember["status"]
t: ReturnType<typeof useTranslations>
}) {
if (status === "ACTIVE") {
return (
<Badge
variant="outline"
className="border-green-200 bg-green-50 text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-400"
>
{t("active")}
</Badge>
)
}
if (status === "REVOKED") {
return (
<Badge
variant="outline"
className="border-red-200 bg-red-50 text-red-700 dark:border-red-800 dark:bg-red-900/30 dark:text-red-400"
>
{t("revoked")}
</Badge>
)
}
return (
<Badge
variant="outline"
className="border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/30 dark:text-amber-400"
>
{t("invited")}
</Badge>
)
}
// --- Permission Checkboxes ---
function PermissionCheckboxes({
selected,
onChange,
t,
}: {
selected: string[]
onChange: (perms: string[]) => void
t: ReturnType<typeof useTranslations>
}) {
const permLabels: Record<string, string> = {
RECORD_DISTRIBUTION: t("permRecordDistribution"),
VIEW_MEMBER_LIST: t("permViewMemberList"),
VIEW_MEMBER_QUOTA: t("permViewMemberQuota"),
ADD_MEMBER: t("permAddMember"),
VIEW_STOCK: t("permViewStock"),
RECORD_STOCK_IN: t("permRecordStockIn"),
VIEW_COMPLIANCE_REPORT: t("permViewComplianceReport"),
MANAGE_GROW_CALENDAR: t("permManageGrowCalendar"),
}
const toggle = (perm: string) => {
if (selected.includes(perm)) {
onChange(selected.filter((p) => p !== perm))
} else {
onChange([...selected, perm])
}
}
return (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{ALL_PERMISSIONS.map((perm) => (
<div key={perm} className="flex items-center space-x-2">
<Checkbox
id={`perm-${perm}`}
checked={selected.includes(perm)}
onCheckedChange={() => toggle(perm)}
/>
<Label
htmlFor={`perm-${perm}`}
className="cursor-pointer text-sm font-normal"
>
{permLabels[perm]}
</Label>
</div>
))}
</div>
)
}
// --- Invite Staff Sheet ---
function InviteStaffSheet({
open,
onOpenChange,
t,
}: {
open: boolean
onOpenChange: (open: boolean) => void
t: ReturnType<typeof useTranslations>
}) {
const { toast } = useToast()
const inviteMutation = useInviteStaffMutation()
const [email, setEmail] = useState("")
const [displayName, setDisplayName] = useState("")
const [roleTemplate, setRoleTemplate] = useState("custom")
const [permissions, setPermissions] = useState<string[]>([])
const handleRoleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value
setRoleTemplate(value)
if (value !== "custom") {
setPermissions([...ROLE_TEMPLATES[value]])
}
}
const handleSubmit = () => {
const data: InviteStaffRequest = {
email,
displayName,
role: "STAFF",
permissions,
}
inviteMutation.mutate(data, {
onSuccess: () => {
toast({
description: t("inviteSuccess", { email }),
})
onOpenChange(false)
setEmail("")
setDisplayName("")
setRoleTemplate("custom")
setPermissions([])
},
})
}
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="overflow-y-auto sm:max-w-md">
<SheetHeader>
<SheetTitle>{t("inviteTitle")}</SheetTitle>
<SheetDescription>{t("inviteDesc")}</SheetDescription>
</SheetHeader>
<div className="mt-6 space-y-5">
<div className="space-y-2">
<Label htmlFor="invite-name">{t("name")}</Label>
<Input
id="invite-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Max Mustermann"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-email">{t("inviteEmail")}</Label>
<Input
id="invite-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="max@example.de"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">{t("inviteRole")}</Label>
<Select
id="invite-role"
value={roleTemplate}
onChange={handleRoleChange}
>
<option value="ausgabe">{t("roleAusgabe")}</option>
<option value="lager">{t("roleLager")}</option>
<option value="vorstand">{t("roleVorstand")}</option>
<option value="custom">{t("roleCustom")}</option>
</Select>
</div>
<div className="space-y-2">
<Label>{t("permissions")}</Label>
<PermissionCheckboxes
selected={permissions}
onChange={setPermissions}
t={t}
/>
</div>
<Button
className="w-full"
onClick={handleSubmit}
disabled={
!email ||
!displayName ||
permissions.length === 0 ||
inviteMutation.isPending
}
>
{t("inviteSend")}
</Button>
</div>
</SheetContent>
</Sheet>
)
}
// --- Edit Permissions Dialog ---
function EditPermissionsDialog({
staffMember,
open,
onOpenChange,
t,
}: {
staffMember: StaffMember | null
open: boolean
onOpenChange: (open: boolean) => void
t: ReturnType<typeof useTranslations>
}) {
const { toast } = useToast()
const [permissions, setPermissions] = useState<string[]>(
staffMember?.permissions ?? []
)
const updateMutation = useUpdateStaffPermissionsMutation(
staffMember?.id ?? ""
)
// Sync permissions when staff member changes
const handleOpen = (isOpen: boolean) => {
if (isOpen && staffMember) {
setPermissions([...staffMember.permissions])
}
onOpenChange(isOpen)
}
const handleSave = () => {
updateMutation.mutate(
{ permissions },
{
onSuccess: () => {
toast({ description: t("permissionsSaved") })
onOpenChange(false)
},
}
)
}
return (
<Dialog open={open} onOpenChange={handleOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("editPermissions")}</DialogTitle>
<DialogDescription>
{staffMember?.displayName} {staffMember?.email}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<PermissionCheckboxes
selected={permissions}
onChange={setPermissions}
t={t}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("cancel")}
</Button>
<Button
onClick={handleSave}
disabled={permissions.length === 0 || updateMutation.isPending}
>
{t("savePermissions")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// --- Revoke Confirmation ---
function RevokeConfirmDialog({
staffMember,
open,
onOpenChange,
t,
}: {
staffMember: StaffMember | null
open: boolean
onOpenChange: (open: boolean) => void
t: ReturnType<typeof useTranslations>
}) {
const { toast } = useToast()
const revokeMutation = useRevokeStaffMutation()
const handleRevoke = () => {
if (!staffMember) return
revokeMutation.mutate(staffMember.id, {
onSuccess: () => {
toast({ description: t("revokeSuccess") })
onOpenChange(false)
},
})
}
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("revokeAccess")}</AlertDialogTitle>
<AlertDialogDescription>
{t("revokeConfirm", { name: staffMember?.displayName ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleRevoke}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t("revokeAccess")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}
// --- Main Page ---
export default function StaffPage() {
const t = useTranslations("staff")
const { data: staffData, isLoading } = useStaffListQuery()
const [inviteOpen, setInviteOpen] = useState(false)
const [editTarget, setEditTarget] = useState<StaffMember | null>(null)
const [revokeTarget, setRevokeTarget] = useState<StaffMember | null>(null)
// Fallback to mock data when API unavailable
const staff: StaffMember[] = staffData ?? mockStaffAccounts
const permLabel = (perm: string): string => {
const labels: Record<string, string> = {
RECORD_DISTRIBUTION: t("permRecordDistribution"),
VIEW_MEMBER_LIST: t("permViewMemberList"),
VIEW_MEMBER_QUOTA: t("permViewMemberQuota"),
ADD_MEMBER: t("permAddMember"),
VIEW_STOCK: t("permViewStock"),
RECORD_STOCK_IN: t("permRecordStockIn"),
VIEW_COMPLIANCE_REPORT: t("permViewComplianceReport"),
MANAGE_GROW_CALENDAR: t("permManageGrowCalendar"),
}
return labels[perm] ?? perm
}
return (
<div className="space-y-6 p-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<UserCog className="text-muted-foreground h-6 w-6" />
<h1 className="text-2xl font-bold tracking-tight">{t("title")}</h1>
</div>
<Button onClick={() => setInviteOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
{t("invite")}
</Button>
</div>
{/* Table */}
{isLoading ? (
<TableSkeleton />
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("email")}</TableHead>
<TableHead>{t("role")}</TableHead>
<TableHead>{t("permissions")}</TableHead>
<TableHead>{t("status")}</TableHead>
<TableHead className="text-right">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{staff.map((member) => (
<TableRow key={member.id}>
<TableCell className="font-medium">
{member.displayName}
</TableCell>
<TableCell className="text-muted-foreground">
{member.email}
</TableCell>
<TableCell>
<Badge variant="secondary">{member.role}</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{member.permissions.slice(0, 3).map((perm) => (
<Badge key={perm} variant="outline" className="text-xs">
{permLabel(perm)}
</Badge>
))}
{member.permissions.length > 3 && (
<Badge variant="outline" className="text-xs">
+{member.permissions.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<StatusBadge status={member.status} t={t} />
</TableCell>
<TableCell className="text-right">
{member.status === "ACTIVE" && (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setEditTarget(member)}
>
<Edit className="mr-1 h-3.5 w-3.5" />
{t("editPermissions")}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => setRevokeTarget(member)}
>
<ShieldX className="mr-1 h-3.5 w-3.5" />
{t("revokeAccess")}
</Button>
</div>
)}
</TableCell>
</TableRow>
))}
{staff.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="text-muted-foreground py-8 text-center"
>
{t("noStaff")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
)}
{/* Dialogs */}
<InviteStaffSheet open={inviteOpen} onOpenChange={setInviteOpen} t={t} />
<EditPermissionsDialog
staffMember={editTarget}
open={!!editTarget}
onOpenChange={(open) => {
if (!open) setEditTarget(null)
}}
t={t}
/>
<RevokeConfirmDialog
staffMember={revokeTarget}
open={!!revokeTarget}
onOpenChange={(open) => {
if (!open) setRevokeTarget(null)
}}
t={t}
/>
</div>
)
}
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef<
React.ComponentRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground peer h-4 w-4 shrink-0 rounded-sm border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-3.5 w-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
@@ -0,0 +1,55 @@
import type { StaffMember } from "@/services/staff"
export const mockStaffAccounts: StaffMember[] = [
{
id: "staff-1",
displayName: "Maria Schulz",
email: "maria@gruener-daumen.de",
role: "STAFF",
permissions: [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
],
status: "ACTIVE",
lastLoginAt: "2026-06-10T14:30:00Z",
createdAt: "2025-11-15T09:00:00Z",
},
{
id: "staff-2",
displayName: "Thomas Klein",
email: "thomas@gruener-daumen.de",
role: "STAFF",
permissions: ["VIEW_STOCK", "RECORD_STOCK_IN", "MANAGE_GROW_CALENDAR"],
status: "ACTIVE",
lastLoginAt: "2026-06-11T08:45:00Z",
createdAt: "2025-12-01T10:00:00Z",
},
{
id: "staff-3",
displayName: "Petra Wagner",
email: "petra@gruener-daumen.de",
role: "MANAGER",
permissions: [
"RECORD_DISTRIBUTION",
"VIEW_MEMBER_LIST",
"VIEW_MEMBER_QUOTA",
"ADD_MEMBER",
"VIEW_STOCK",
"RECORD_STOCK_IN",
"VIEW_COMPLIANCE_REPORT",
],
status: "ACTIVE",
lastLoginAt: "2026-06-12T07:15:00Z",
createdAt: "2026-01-10T11:00:00Z",
},
{
id: "staff-4",
displayName: "Stefan Braun",
email: "stefan@gruener-daumen.de",
role: "STAFF",
permissions: ["RECORD_DISTRIBUTION", "VIEW_MEMBER_LIST"],
status: "REVOKED",
createdAt: "2025-10-01T08:00:00Z",
},
]
+3 -3
View File
@@ -30,9 +30,9 @@ export const navigationsData: NavigationType[] = [
iconName: "FileText",
},
{
title: "Einstellungen",
href: "/settings",
iconName: "Settings",
title: "Personal",
href: "/settings/staff",
iconName: "UserCog",
},
],
},