chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
@@ -0,0 +1,285 @@
"use client";
import { useState } from "react";
import type { RoleWithResourceCount } from "@planarchy/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";
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="p-6 max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Roles</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage role definitions and resource assignments
</p>
</div>
<button
type="button"
onClick={openCreate}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
>
+ New Role
</button>
</div>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-3">
<input
type="text"
placeholder="Search roles…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64"
/>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<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="mb-4 rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700 flex items-center justify-between">
{actionError}
<button type="button" onClick={() => setActionError(null)} className="text-red-400 hover:text-red-600 text-lg leading-none">&times;</button>
</div>
)}
{/* Table */}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<SortableColumnHeader label="Name" field="name" sortField={sortField} sortDir={sortDir} onSort={toggle} />
<th className="text-left px-4 py-3 font-medium text-gray-600 dark:text-gray-400 text-xs uppercase tracking-wider">Description</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="text-center py-8 text-gray-400">Loading</td>
</tr>
)}
{!isLoading && sorted.length === 0 && (
<tr>
<td colSpan={6} className="text-center py-8 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>
{/* Modals */}
{modalOpen && (
<RoleModal
role={editRole}
onClose={() => setModalOpen(false)}
onSuccess={() => setModalOpen(false)}
/>
)}
{confirmDelete && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-sm mx-4 p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">Delete Role</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
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 gap-3 justify-end">
<button type="button" onClick={() => setConfirmDelete(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
type="button"
onClick={() => deleteMutation.mutate({ id: confirmDelete.id })}
disabled={deleteMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium disabled:opacity-50"
>
{deleteMutation.isPending ? "Deleting…" : "Delete"}
</button>
</div>
</div>
</div>
)}
</div>
);
}