Files
CapaKraken/apps/web/src/components/roles/RolesClient.tsx
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

341 lines
13 KiB
TypeScript

"use client";
import { useState } from "react";
import type { RoleWithResourceCount } from "@capakraken/shared";
import { trpc } from "~/lib/trpc/client.js";
import { RoleModal } from "./RoleModal.js";
import { FilterChips } from "~/components/ui/FilterChips.js";
import { SortableColumnHeader } from "~/components/ui/SortableColumnHeader.js";
import { useTableSort } from "~/hooks/useTableSort.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
export function RolesClient() {
const [search, setSearch] = useState("");
const [showInactive, setShowInactive] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editRole, setEditRole] = useState<RoleWithResourceCount | null>(null);
const [confirmDelete, setConfirmDelete] = useState<RoleWithResourceCount | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const utils = trpc.useUtils();
const { data: roles, isLoading } = trpc.role.list.useQuery(
{ isActive: showInactive ? undefined : true, search: search || undefined },
{ placeholderData: (prev) => prev, staleTime: 60_000 },
);
const deactivateMutation = trpc.role.deactivate.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
},
onError: (err) => setActionError(err.message),
});
const activateMutation = trpc.role.update.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
},
onError: (err) => setActionError(err.message),
});
const deleteMutation = trpc.role.delete.useMutation({
onSuccess: async () => {
await utils.role.list.invalidate();
setConfirmDelete(null);
},
onError: (err) => {
setActionError(err.message);
setConfirmDelete(null);
},
});
function openCreate() {
setEditRole(null);
setModalOpen(true);
}
function openEdit(role: RoleWithResourceCount) {
setEditRole(role);
setModalOpen(true);
}
const roleList = (roles ?? []) as unknown as RoleWithResourceCount[];
const rolesViewPrefs = useViewPrefs("roles");
const { sorted, sortField, sortDir, toggle } = useTableSort(roleList, {
initialField: rolesViewPrefs.savedSort?.field ?? null,
initialDir: rolesViewPrefs.savedSort?.dir ?? null,
onSortChange: (field, dir) => {
rolesViewPrefs.setSavedSort(field && dir ? { field, dir } : null);
},
});
function clearAll() {
setSearch("");
setShowInactive(false);
}
const chips = [
...(search ? [{ label: `Search: "${search}"`, onRemove: () => setSearch("") }] : []),
...(showInactive
? [{ label: "Inactive included", onRemove: () => setShowInactive(false) }]
: []),
];
return (
<div className="app-page mx-auto max-w-6xl space-y-5">
<div className="app-page-header gap-4">
<div>
<h1 className="app-page-title">Roles</h1>
<p className="app-page-subtitle mt-1">Manage role definitions and resource assignments</p>
</div>
<button
type="button"
onClick={openCreate}
className="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700"
>
New Role
</button>
</div>
<div className="app-toolbar flex flex-wrap items-center gap-3">
<input
type="text"
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="app-input w-64"
/>
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-900/70 dark:text-gray-300">
<input
type="checkbox"
checked={showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
className="rounded border-gray-300"
/>
Show inactive
</label>
</div>
{chips.length > 0 && (
<div className="mb-3">
<FilterChips chips={chips} onClearAll={clearAll} />
</div>
)}
{actionError && (
<div className="flex items-center justify-between rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/30 dark:text-red-300">
{actionError}
<button
type="button"
onClick={() => setActionError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none"
>
&times;
</button>
</div>
)}
<div className="app-data-table">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<SortableColumnHeader
label="Name"
field="name"
sortField={sortField}
sortDir={sortDir}
onSort={toggle}
tooltip="Role name (e.g. 3D Artist, Producer). Shown in timeline bars and staffing views."
/>
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
<span className="inline-flex items-center gap-0.5">Description<InfoTooltip content="Optional description of the role's responsibilities and typical work." /></span>
</th>
<SortableColumnHeader
label="Resources"
field="resourceRoles"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.resourceRoles)}
align="center"
tooltip="Number of resources that currently have this role assigned (active assignments only)."
/>
<SortableColumnHeader
label="Allocations"
field="allocations"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => r._count.allocations)}
align="center"
tooltip="Total number of planning entries that use this role, including open-demand compatibility rows."
/>
<SortableColumnHeader
label="Status"
field="isActive"
sortField={sortField}
sortDir={sortDir}
onSort={(f) => toggle(f, (r) => (r.isActive ? 0 : 1))}
align="center"
tooltip="Active roles are available for assignment. Inactive roles are hidden from pickers but existing assignments remain."
/>
<th className="text-right px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-400">
Loading
</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-gray-400">
No roles found. Create one to get started.
</td>
</tr>
)}
{sorted.map((role) => (
<tr
key={role.id}
className="border-b border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/30 transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: role.color ?? "#6366f1" }}
/>
<span className="font-medium text-gray-900 dark:text-gray-100">
{role.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400 max-w-xs truncate">
{role.description ?? <span className="italic text-gray-300"></span>}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-6 rounded bg-gray-100 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
{role._count.resourceRoles}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex items-center justify-center w-8 h-6 rounded bg-gray-100 dark:bg-gray-800 text-xs font-medium text-gray-600 dark:text-gray-400">
{role._count.allocations}
</span>
</td>
<td className="px-4 py-3 text-center">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
role.isActive
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"
}`}
>
{role.isActive ? "Active" : "Inactive"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button
type="button"
onClick={() => openEdit(role)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
{role.isActive && (
<button
type="button"
onClick={() => {
setActionError(null);
deactivateMutation.mutate({ id: role.id });
}}
disabled={deactivateMutation.isPending}
className="text-xs text-gray-500 hover:text-gray-700"
>
Deactivate
</button>
)}
{!role.isActive && (
<button
type="button"
onClick={() => {
setActionError(null);
activateMutation.mutate({ id: role.id, data: { isActive: true } });
}}
disabled={activateMutation.isPending}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
Enable
</button>
)}
<button
type="button"
onClick={() => {
setConfirmDelete(role);
setActionError(null);
}}
className="text-xs text-red-500 hover:text-red-700"
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{modalOpen && (
<RoleModal
role={editRole}
onClose={() => setModalOpen(false)}
onSuccess={() => setModalOpen(false)}
/>
)}
{confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/55 px-4 backdrop-blur-sm">
<div className="w-full max-w-sm rounded-3xl border border-gray-200 bg-white p-6 shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-gray-100">
Delete Role
</h3>
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
Are you sure you want to delete <strong>{confirmDelete.name}</strong>?
{(confirmDelete._count.resourceRoles > 0 || confirmDelete._count.allocations > 0) && (
<span className="block mt-1 text-amber-600">
This role is assigned to {confirmDelete._count.resourceRoles} resource(s) and{" "}
{confirmDelete._count.allocations} allocation(s). Deletion will be blocked.
</span>
)}
</p>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => setConfirmDelete(null)}
className="rounded-xl px-4 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 hover:text-gray-800 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
type="button"
onClick={() => deleteMutation.mutate({ id: confirmDelete.id })}
disabled={deleteMutation.isPending}
className="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-700 disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}