cd78f72f33
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>
341 lines
13 KiB
TypeScript
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"
|
|
>
|
|
×
|
|
</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>
|
|
);
|
|
}
|