chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -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">×</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user