Backend: - auth/permissions.go: full permission matrix with RequirePermission/RequireRole middleware, CanEditCase, CanDeleteDocument helpers - auth/context.go: add user role to request context - auth/middleware.go: resolve role alongside tenant in auth flow - auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID - handlers/case_assignments.go: CRUD for case-level user assignments - handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints - handlers/documents.go: permission-based delete (own vs all) - router/router.go: permission-wrapped routes for all endpoints - services/case_assignment_service.go: assign/unassign with tenant validation - services/tenant_service.go: UpdateMemberRole with owner protection - models/case_assignment.go: CaseAssignment model Database: - user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary) - case_assignments table: case_id, user_id, role (lead/team/viewer) - Migrated existing admin->partner, member->associate Frontend: - usePermissions hook: fetches /api/me, provides can() helper - TeamSettings: 5-role dropdown, role change, permission-gated invite - CaseAssignments: new component for case-level team management - Sidebar: conditionally hides AI/Settings based on permissions - Cases page: hides "Neue Akte" button for non-authorized roles - Case detail: new "Mitarbeiter" tab for assignment management
181 lines
6.4 KiB
TypeScript
181 lines
6.4 KiB
TypeScript
"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>
|
|
);
|
|
}
|