feat: role-based permissions — owner/partner/associate/paralegal/secretary (P0)
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
Clock,
|
||||
FileText,
|
||||
Users,
|
||||
UserCheck,
|
||||
StickyNote,
|
||||
AlertTriangle,
|
||||
ScrollText,
|
||||
@@ -44,6 +45,7 @@ const TABS = [
|
||||
{ segment: "fristen", label: "Fristen", icon: Clock },
|
||||
{ segment: "dokumente", label: "Dokumente", icon: FileText },
|
||||
{ segment: "parteien", label: "Parteien", icon: Users },
|
||||
{ segment: "mitarbeiter", label: "Mitarbeiter", icon: UserCheck },
|
||||
{ segment: "notizen", label: "Notizen", icon: StickyNote },
|
||||
{ segment: "protokoll", label: "Protokoll", icon: ScrollText },
|
||||
] as const;
|
||||
@@ -53,6 +55,7 @@ const TAB_LABELS: Record<string, string> = {
|
||||
fristen: "Fristen",
|
||||
dokumente: "Dokumente",
|
||||
parteien: "Parteien",
|
||||
mitarbeiter: "Mitarbeiter",
|
||||
notizen: "Notizen",
|
||||
protokoll: "Protokoll",
|
||||
};
|
||||
|
||||
9
frontend/src/app/(app)/cases/[id]/mitarbeiter/page.tsx
Normal file
9
frontend/src/app/(app)/cases/[id]/mitarbeiter/page.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { CaseAssignments } from "@/components/cases/CaseAssignments";
|
||||
|
||||
export default function CaseMitarbeiterPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
return <CaseAssignments caseId={id} />;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Plus, Search, FolderOpen } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { SkeletonTable } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { usePermissions } from "@/lib/hooks/usePermissions";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "", label: "Alle Status" },
|
||||
@@ -49,6 +50,8 @@ const inputClass =
|
||||
export default function CasesPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { can } = usePermissions();
|
||||
const canCreateCase = can("create_case");
|
||||
|
||||
const [search, setSearch] = useState(searchParams.get("search") ?? "");
|
||||
const [status, setStatus] = useState(searchParams.get("status") ?? "");
|
||||
@@ -86,13 +89,15 @@ export default function CasesPage() {
|
||||
{data ? `${data.total} Akten` : "\u00A0"}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/cases/new"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Akte
|
||||
</Link>
|
||||
{canCreateCase && (
|
||||
<Link
|
||||
href="/cases/new"
|
||||
className="inline-flex w-fit items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Neue Akte
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
@@ -145,7 +150,7 @@ export default function CasesPage() {
|
||||
: "Erstellen Sie Ihre erste Akte, um loszulegen."
|
||||
}
|
||||
action={
|
||||
!search && !status && !type ? (
|
||||
!search && !status && !type && canCreateCase ? (
|
||||
<Link
|
||||
href="/cases/new"
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-neutral-800"
|
||||
|
||||
180
frontend/src/components/cases/CaseAssignments.tsx
Normal file
180
frontend/src/components/cases/CaseAssignments.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus, Trash2, Users } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CaseAssignment, UserTenant } from "@/lib/types";
|
||||
import { CASE_ASSIGNMENT_ROLE_LABELS } from "@/lib/types";
|
||||
import type { CaseAssignmentRole } from "@/lib/types";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { usePermissions } from "@/lib/hooks/usePermissions";
|
||||
|
||||
export function CaseAssignments({ caseId }: { caseId: string }) {
|
||||
const queryClient = useQueryClient();
|
||||
const { can } = usePermissions();
|
||||
const canManage = can("manage_team");
|
||||
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
|
||||
const [selectedUser, setSelectedUser] = useState("");
|
||||
const [assignRole, setAssignRole] = useState<CaseAssignmentRole>("team");
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["case-assignments", caseId],
|
||||
queryFn: () =>
|
||||
api.get<{ assignments: CaseAssignment[]; total: number }>(
|
||||
`/cases/${caseId}/assignments`,
|
||||
),
|
||||
});
|
||||
|
||||
const { data: members } = useQuery({
|
||||
queryKey: ["tenant-members", tenantId],
|
||||
queryFn: () =>
|
||||
api.get<UserTenant[]>(`/tenants/${tenantId}/members`),
|
||||
enabled: !!tenantId && canManage,
|
||||
});
|
||||
|
||||
const assignMutation = useMutation({
|
||||
mutationFn: (input: { user_id: string; role: string }) =>
|
||||
api.post(`/cases/${caseId}/assignments`, input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["case-assignments", caseId] });
|
||||
setSelectedUser("");
|
||||
toast.success("Mitarbeiter zugewiesen");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Zuweisen");
|
||||
},
|
||||
});
|
||||
|
||||
const unassignMutation = useMutation({
|
||||
mutationFn: (userId: string) =>
|
||||
api.delete(`/cases/${caseId}/assignments/${userId}`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["case-assignments", caseId] });
|
||||
toast.success("Zuweisung entfernt");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Entfernen");
|
||||
},
|
||||
});
|
||||
|
||||
const assignments = data?.assignments ?? [];
|
||||
const assignedUserIds = new Set(assignments.map((a) => a.user_id));
|
||||
const availableMembers = (members ?? []).filter(
|
||||
(m) => !assignedUserIds.has(m.user_id),
|
||||
);
|
||||
|
||||
const handleAssign = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedUser) return;
|
||||
assignMutation.mutate({ user_id: selectedUser, role: assignRole });
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-900">
|
||||
Zugewiesene Mitarbeiter
|
||||
</h3>
|
||||
|
||||
{/* Assign form — only for owners/partners */}
|
||||
{canManage && availableMembers.length > 0 && (
|
||||
<form onSubmit={handleAssign} className="flex flex-col gap-2 sm:flex-row">
|
||||
<select
|
||||
value={selectedUser}
|
||||
onChange={(e) => setSelectedUser(e.target.value)}
|
||||
className="flex-1 rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
<option value="">Mitarbeiter auswählen...</option>
|
||||
{availableMembers.map((m) => (
|
||||
<option key={m.user_id} value={m.user_id}>
|
||||
{m.user_id.slice(0, 8)}... ({m.role})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={assignRole}
|
||||
onChange={(e) => setAssignRole(e.target.value as CaseAssignmentRole)}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
{(Object.keys(CASE_ASSIGNMENT_ROLE_LABELS) as CaseAssignmentRole[]).map(
|
||||
(r) => (
|
||||
<option key={r} value={r}>
|
||||
{CASE_ASSIGNMENT_ROLE_LABELS[r]}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={assignMutation.isPending || !selectedUser}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
Zuweisen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Assignments list */}
|
||||
{assignments.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200">
|
||||
{assignments.map((a, i) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`flex items-center justify-between px-4 py-2.5 ${
|
||||
i < assignments.length - 1 ? "border-b border-neutral-100" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-neutral-100">
|
||||
<Users className="h-3.5 w-3.5 text-neutral-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-900">
|
||||
{a.user_id.slice(0, 8)}...
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">
|
||||
{CASE_ASSIGNMENT_ROLE_LABELS[a.role as CaseAssignmentRole] ??
|
||||
a.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
onClick={() => unassignMutation.mutate(a.user_id)}
|
||||
disabled={unassignMutation.isPending}
|
||||
className="rounded-md p-1 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
title="Zuweisung entfernen"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={Users}
|
||||
title="Keine Zuweisungen"
|
||||
description="Noch keine Mitarbeiter zugewiesen."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,19 +13,32 @@ import {
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect } from "react";
|
||||
import { usePermissions } from "@/lib/hooks/usePermissions";
|
||||
|
||||
const navigation = [
|
||||
interface NavItem {
|
||||
name: string;
|
||||
href: string;
|
||||
icon: typeof LayoutDashboard;
|
||||
permission?: string;
|
||||
}
|
||||
|
||||
const allNavigation: NavItem[] = [
|
||||
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
|
||||
{ name: "Akten", href: "/cases", icon: FolderOpen },
|
||||
{ name: "Fristen", href: "/fristen", icon: Clock },
|
||||
{ name: "Termine", href: "/termine", icon: Calendar },
|
||||
{ name: "AI Analyse", href: "/ai/extract", icon: Brain },
|
||||
{ name: "Einstellungen", href: "/einstellungen", icon: Settings },
|
||||
{ name: "AI Analyse", href: "/ai/extract", icon: Brain, permission: "ai_extraction" },
|
||||
{ name: "Einstellungen", href: "/einstellungen", icon: Settings, permission: "manage_settings" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { can, isLoading: permLoading } = usePermissions();
|
||||
|
||||
const navigation = allNavigation.filter(
|
||||
(item) => !item.permission || permLoading || can(item.permission),
|
||||
);
|
||||
|
||||
// Close on route change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,27 +3,36 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { UserPlus, Trash2, Shield, Crown, User } from "lucide-react";
|
||||
import { UserPlus, Trash2, Crown, Scale, Briefcase, FileText, Phone } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { UserTenant } from "@/lib/types";
|
||||
import type { UserTenant, UserRole } from "@/lib/types";
|
||||
import { ROLE_LABELS } from "@/lib/types";
|
||||
import { Skeleton } from "@/components/ui/Skeleton";
|
||||
import { EmptyState } from "@/components/ui/EmptyState";
|
||||
import { usePermissions } from "@/lib/hooks/usePermissions";
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; icon: typeof Crown }> = {
|
||||
owner: { label: "Eigentümer", icon: Crown },
|
||||
admin: { label: "Administrator", icon: Shield },
|
||||
member: { label: "Mitglied", icon: User },
|
||||
const ROLE_CONFIG: Record<UserRole, { label: string; icon: typeof Crown }> = {
|
||||
owner: { label: ROLE_LABELS.owner, icon: Crown },
|
||||
partner: { label: ROLE_LABELS.partner, icon: Scale },
|
||||
associate: { label: ROLE_LABELS.associate, icon: Briefcase },
|
||||
paralegal: { label: ROLE_LABELS.paralegal, icon: FileText },
|
||||
secretary: { label: ROLE_LABELS.secretary, icon: Phone },
|
||||
};
|
||||
|
||||
const INVITE_ROLES: UserRole[] = ["partner", "associate", "paralegal", "secretary"];
|
||||
|
||||
export function TeamSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
const { can, role: myRole } = usePermissions();
|
||||
const tenantId =
|
||||
typeof window !== "undefined"
|
||||
? localStorage.getItem("kanzlai_tenant_id")
|
||||
: null;
|
||||
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState("member");
|
||||
const [role, setRole] = useState<string>("associate");
|
||||
|
||||
const canManageTeam = can("manage_team");
|
||||
|
||||
const {
|
||||
data: members,
|
||||
@@ -42,7 +51,7 @@ export function TeamSettings() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||
setEmail("");
|
||||
setRole("member");
|
||||
setRole("associate");
|
||||
toast.success("Benutzer eingeladen");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
@@ -62,6 +71,19 @@ export function TeamSettings() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: ({ userId, newRole }: { userId: string; newRole: string }) =>
|
||||
api.put(`/tenants/${tenantId}/members/${userId}/role`, { role: newRole }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-members"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["me"] });
|
||||
toast.success("Rolle aktualisiert");
|
||||
},
|
||||
onError: (err: { error?: string }) => {
|
||||
toast.error(err.error || "Fehler beim Aktualisieren der Rolle");
|
||||
},
|
||||
});
|
||||
|
||||
const handleInvite = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!email.trim()) return;
|
||||
@@ -81,7 +103,7 @@ export function TeamSettings() {
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
icon={Briefcase}
|
||||
title="Fehler beim Laden"
|
||||
description="Team-Mitglieder konnten nicht geladen werden."
|
||||
/>
|
||||
@@ -90,38 +112,44 @@ export function TeamSettings() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Invite Form */}
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
<option value="member">Mitglied</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending || !email.trim()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
|
||||
</button>
|
||||
</form>
|
||||
{/* Invite Form — only for owners/partners */}
|
||||
{canManageTeam && (
|
||||
<form onSubmit={handleInvite} className="flex flex-col gap-3 sm:flex-row">
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
className="flex-1 rounded-md border border-neutral-200 px-3 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
/>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1.5 text-sm outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
{INVITE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABELS[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={inviteMutation.isPending || !email.trim()}
|
||||
className="inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-4 py-1.5 text-sm font-medium text-white hover:bg-neutral-800 disabled:opacity-50"
|
||||
>
|
||||
<UserPlus className="h-3.5 w-3.5" />
|
||||
{inviteMutation.isPending ? "Einladen..." : "Einladen"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Members List */}
|
||||
{Array.isArray(members) && members.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-md border border-neutral-200">
|
||||
{members.map((member, i) => {
|
||||
const roleInfo = ROLE_LABELS[member.role] || ROLE_LABELS.member;
|
||||
const roleKey = (member.role as UserRole) || "associate";
|
||||
const roleInfo = ROLE_CONFIG[roleKey] || ROLE_CONFIG.associate;
|
||||
const RoleIcon = roleInfo.icon;
|
||||
return (
|
||||
<div
|
||||
@@ -141,23 +169,48 @@ export function TeamSettings() {
|
||||
<p className="text-xs text-neutral-500">{roleInfo.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== "owner" && (
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(member.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
title="Mitglied entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Role dropdown — only for owners/partners, not for the member's own row if they are owner */}
|
||||
{canManageTeam && member.role !== "owner" && (
|
||||
<select
|
||||
value={member.role}
|
||||
onChange={(e) =>
|
||||
updateRoleMutation.mutate({
|
||||
userId: member.user_id,
|
||||
newRole: e.target.value,
|
||||
})
|
||||
}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
className="rounded-md border border-neutral-200 px-2 py-1 text-xs outline-none focus:border-neutral-400 focus:ring-1 focus:ring-neutral-400"
|
||||
>
|
||||
{myRole === "owner" && (
|
||||
<option value="owner">{ROLE_LABELS.owner}</option>
|
||||
)}
|
||||
{INVITE_ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{ROLE_LABELS[r]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{canManageTeam && member.role !== "owner" && (
|
||||
<button
|
||||
onClick={() => removeMutation.mutate(member.user_id)}
|
||||
disabled={removeMutation.isPending}
|
||||
className="rounded-md p-1.5 text-neutral-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-50"
|
||||
title="Mitglied entfernen"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon={User}
|
||||
icon={Briefcase}
|
||||
title="Noch keine Mitglieder"
|
||||
description="Laden Sie Teammitglieder per E-Mail ein."
|
||||
/>
|
||||
|
||||
29
frontend/src/lib/hooks/usePermissions.ts
Normal file
29
frontend/src/lib/hooks/usePermissions.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/lib/api";
|
||||
import type { UserInfo } from "@/lib/types";
|
||||
|
||||
export function usePermissions() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: () => api.get<UserInfo>("/me"),
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
|
||||
const role = data?.role ?? null;
|
||||
const permissions = data?.permissions ?? [];
|
||||
|
||||
function can(permission: string): boolean {
|
||||
return permissions.includes(permission);
|
||||
}
|
||||
|
||||
return {
|
||||
role,
|
||||
permissions,
|
||||
can,
|
||||
isLoading,
|
||||
userId: data?.user_id ?? null,
|
||||
tenantId: data?.tenant_id ?? null,
|
||||
};
|
||||
}
|
||||
@@ -189,36 +189,39 @@ export interface Note {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// Notifications
|
||||
|
||||
export interface Notification {
|
||||
export interface CaseAssignment {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
case_id: string;
|
||||
user_id: string;
|
||||
type: "deadline_reminder" | "deadline_overdue" | "case_update" | "assignment";
|
||||
entity_type?: "deadline" | "appointment" | "case";
|
||||
entity_id?: string;
|
||||
title: string;
|
||||
body?: string;
|
||||
sent_at?: string;
|
||||
read_at?: string;
|
||||
created_at: string;
|
||||
role: string;
|
||||
assigned_at: string;
|
||||
}
|
||||
|
||||
export interface NotificationPreferences {
|
||||
export interface UserInfo {
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
deadline_reminder_days: number[];
|
||||
email_enabled: boolean;
|
||||
daily_digest: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
role: UserRole;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
data: Notification[];
|
||||
total: number;
|
||||
}
|
||||
export type UserRole = "owner" | "partner" | "associate" | "paralegal" | "secretary";
|
||||
|
||||
export const ROLE_LABELS: Record<UserRole, string> = {
|
||||
owner: "Inhaber",
|
||||
partner: "Partner",
|
||||
associate: "Anwalt",
|
||||
paralegal: "Paralegal",
|
||||
secretary: "Sekretariat",
|
||||
};
|
||||
|
||||
export const CASE_ASSIGNMENT_ROLES = ["lead", "team", "viewer"] as const;
|
||||
export type CaseAssignmentRole = (typeof CASE_ASSIGNMENT_ROLES)[number];
|
||||
|
||||
export const CASE_ASSIGNMENT_ROLE_LABELS: Record<CaseAssignmentRole, string> = {
|
||||
lead: "Federführend",
|
||||
team: "Team",
|
||||
viewer: "Einsicht",
|
||||
};
|
||||
|
||||
export interface ApiError {
|
||||
error: string;
|
||||
|
||||
Reference in New Issue
Block a user