Files
Business-Management/frontend/src/components/SourcePanel.tsx
T
curo1305 fec3953009 feat: category scopes, group-admin role, and permission model
- Three category scopes: personal / group / system (watch)
- PascalCase-with-dashes naming convention enforced at backend + frontend
- is_group_admin flag on GroupMembership; PATCH endpoint for admins to toggle it
- Categories router: scope-based list/create/rename/delete with _check_can_manage_cat
- Documents router: delete uses is_admin + can_delete share flag + group-admin check; remove_category requires doc ownership; assign_category accepts group/system categories
- Proxy layers inject x-user-is-admin and x-user-admin-groups headers
- Frontend: ManageCategoriesDialog grouped by scope with lock icons; SourcePanel scope picker + client-side name validation; AdminGroupsPage group-admin checkbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 22:16:49 +02:00

293 lines
10 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
import {
listCategories,
createCategory,
getMyGroups,
type CategoryOut,
ApiError,
} from "@/api/client";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
// PascalCase-with-dashes naming convention
const NAME_RE = /^[A-Z][a-zA-Z0-9]*(-[A-Z][a-zA-Z0-9]*)*$/;
export default function SourcePanel() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const currentView = searchParams.get("view") ?? "all";
const currentCategoryId = searchParams.get("category_id");
const [catSearch, setCatSearch] = useState("");
const [addingCat, setAddingCat] = useState(false);
const [newCatName, setNewCatName] = useState("");
const [newCatGroupId, setNewCatGroupId] = useState<string>("");
const [nameError, setNameError] = useState<string | null>(null);
const [manageOpen, setManageOpen] = useState(false);
const addInputRef = useRef<HTMLInputElement>(null);
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
});
const { data: myGroups = [] } = useQuery({
queryKey: ["my-groups"],
queryFn: getMyGroups,
});
const createMut = useMutation({
mutationFn: ({ name, groupId }: { name: string; groupId?: string }) =>
createCategory(name, groupId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCatName("");
setNewCatGroupId("");
setAddingCat(false);
setNameError(null);
},
onError: (err) => {
setNameError(err instanceof ApiError ? err.message : "Failed to create category");
},
});
useEffect(() => {
if (addingCat) addInputRef.current?.focus();
}, [addingCat]);
const filteredCats = catSearch
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
: categories;
// Split by scope for display
const personalCats = filteredCats.filter((c) => c.scope === "personal");
const systemCats = filteredCats.filter((c) => c.scope === "system");
const groupCatMap = new Map<string, { name: string; cats: CategoryOut[] }>();
for (const cat of filteredCats.filter((c) => c.scope === "group")) {
if (!cat.group_id) continue;
if (!groupCatMap.has(cat.group_id)) {
const grp = myGroups.find((g) => g.id === cat.group_id);
groupCatMap.set(cat.group_id, { name: grp?.name ?? cat.group_id, cats: [] });
}
groupCatMap.get(cat.group_id)!.cats.push(cat);
}
function setView(view: string) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("view", view);
next.delete("category_id");
next.set("page", "1");
return next;
});
}
function selectCategory(cat: CategoryOut) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("view");
next.set("category_id", cat.id);
next.set("page", "1");
return next;
});
}
function handleAddSubmit(e: React.FormEvent) {
e.preventDefault();
const name = newCatName.trim();
if (!name) return;
if (!NAME_RE.test(name)) {
setNameError(
"Must start with a capital letter. Join multiple words with dashes, each capitalised (e.g. Vendor-Invoices)."
);
return;
}
setNameError(null);
createMut.mutate({ name, groupId: newCatGroupId || undefined });
}
const viewItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
active
? "bg-primary/10 text-primary font-medium"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
const catItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1 rounded-md text-sm transition-colors",
active
? "bg-primary/10 text-primary font-medium"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
function renderCatSection(label: string, cats: CategoryOut[]) {
if (cats.length === 0) return null;
return (
<div className="mb-1">
<p className="text-[10px] font-semibold text-muted uppercase tracking-wider px-2 pt-1.5 pb-0.5">
{label}
</p>
{cats.map((cat) => (
<button
key={cat.id}
className={catItemClass(currentCategoryId === cat.id)}
onClick={() => selectCategory(cat)}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cat.name}</span>
</button>
))}
</div>
);
}
const hasSections =
personalCats.length > 0 ||
systemCats.length > 0 ||
groupCatMap.size > 0;
return (
<>
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
{/* Views */}
<div className="p-3 border-b border-border">
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5 px-1">
Views
</p>
<button
className={viewItemClass(!currentCategoryId && (currentView === "all" || !currentView))}
onClick={() => setView("all")}
>
<Files className="h-4 w-4 shrink-0" />
All Documents
</button>
<button
className={viewItemClass(!currentCategoryId && currentView === "mine")}
onClick={() => setView("mine")}
>
<User className="h-4 w-4 shrink-0" />
Mine
</button>
<button
className={viewItemClass(!currentCategoryId && currentView === "shared")}
onClick={() => setView("shared")}
>
<Users className="h-4 w-4 shrink-0" />
Shared with me
</button>
</div>
{/* Categories */}
<div className="flex-1 flex flex-col min-h-0 p-3">
<div className="flex items-center justify-between mb-1.5 px-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">
Categories
</p>
<button
onClick={() => setManageOpen(true)}
className="text-muted hover:text-foreground transition-colors"
title="Manage categories"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
{categories.length > 4 && (
<Input
placeholder="Search…"
value={catSearch}
onChange={(e) => setCatSearch(e.target.value)}
className="h-7 text-xs mb-2"
/>
)}
<div className="flex-1 overflow-y-auto min-h-0">
{renderCatSection("Mine", personalCats)}
{Array.from(groupCatMap.entries()).map(([, { name, cats }]) =>
renderCatSection(name, cats)
)}
{renderCatSection("System", systemCats)}
{!hasSections && catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories match</p>
)}
{categories.length === 0 && !catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories yet</p>
)}
</div>
{/* Add new category */}
<div className="pt-2 border-t border-border mt-2">
{addingCat ? (
<form onSubmit={handleAddSubmit} className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<Input
ref={addInputRef}
value={newCatName}
onChange={(e) => { setNewCatName(e.target.value); setNameError(null); }}
placeholder="Vendor-Invoices"
className="h-7 text-xs flex-1"
disabled={createMut.isPending}
/>
<button
type="submit"
disabled={!newCatName.trim() || createMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => {
setAddingCat(false);
setNewCatName("");
setNewCatGroupId("");
setNameError(null);
}}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
{myGroups.length > 0 && (
<select
value={newCatGroupId}
onChange={(e) => setNewCatGroupId(e.target.value)}
className="h-7 text-xs rounded border border-border bg-surface text-foreground px-1"
disabled={createMut.isPending}
>
<option value="">Personal</option>
{myGroups.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
)}
{nameError && (
<p className="text-[10px] text-red-500 leading-tight">{nameError}</p>
)}
</form>
) : (
<button
onClick={() => setAddingCat(true)}
className="flex items-center gap-1.5 text-xs text-muted hover:text-foreground transition-colors w-full px-2 py-1"
>
<Plus className="h-3.5 w-3.5" />
New category
</button>
)}
</div>
</div>
</aside>
{manageOpen && (
<ManageCategoriesDialog onClose={() => setManageOpen(false)} />
)}
</>
);
}