Gowrisankar Cursor commited on
Commit
9b4a4ef
·
1 Parent(s): 3b249df

Re-skin the portal to match Runn.io for internal IT use

Browse files

- Top navigation now mirrors Runn: People, Projects, Manage, Reports,
Insights (dropdown removed; Manage is a regular tab).
- Root path redirects to the People planner.
- Manage is now a hub: /manage shows a table of every manageable entity
with its count and a "Details" button, exactly like Runn's Manage
hub screenshot.
- Manage gains two new list views matching Runn:
* /manage/people: searchable table with Role, Team, Tags and a
three-dot menu (Open in Planner / Edit Details / Archive / Restore).
Full create + edit modal.
* /manage/projects: searchable table with Status, Type, Dates, Tags
and the same three-dot menu, plus a create/edit modal.
- All Manage sections (Roles, Teams, Tags, Skills, Holidays, Users)
adopt the same icon-circle page header + "+ New" pill button
pattern. Each section is now its own file under pages/manage/.
- Dashboard renamed to "Insights" with the new header treatment and
routed at /insights. Old /dashboard URLs redirect.
- Removed billing / hourly / rate-card concepts that don't apply to an
internal IT team. Pricing model, Default Hourly Rate/Cost, Bench by
team etc. were never wired but the patterns are now consistent.
- Schedule tab removed from the top nav (route kept for bookmarks).

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/App.tsx CHANGED
@@ -16,15 +16,17 @@ const router = createBrowserRouter([
16
  path: "/",
17
  element: <Layout />,
18
  children: [
19
- { index: true, element: <DashboardPage /> },
20
- { path: "schedule", element: <SchedulePage /> },
21
- { path: "projects", element: <ProjectsPage /> },
22
  { path: "people", element: <PeoplePage /> },
23
- { path: "capacity", element: <CapacityPage /> },
24
- { path: "reports", element: <ReportsPage /> },
25
- { path: "manage", element: <Navigate to="/manage/roles" replace /> },
26
  { path: "manage/:section", element: <ManagePage /> },
27
- { path: "settings", element: <Navigate to="/manage/roles" replace /> },
 
 
 
 
 
28
  ],
29
  },
30
  ]);
 
16
  path: "/",
17
  element: <Layout />,
18
  children: [
19
+ { index: true, element: <Navigate to="/people" replace /> },
 
 
20
  { path: "people", element: <PeoplePage /> },
21
+ { path: "projects", element: <ProjectsPage /> },
22
+ { path: "manage", element: <ManagePage /> },
 
23
  { path: "manage/:section", element: <ManagePage /> },
24
+ { path: "reports", element: <ReportsPage /> },
25
+ { path: "insights", element: <DashboardPage /> },
26
+ { path: "schedule", element: <SchedulePage /> },
27
+ { path: "capacity", element: <CapacityPage /> },
28
+ { path: "settings", element: <Navigate to="/manage" replace /> },
29
+ { path: "dashboard", element: <Navigate to="/insights" replace /> },
30
  ],
31
  },
32
  ]);
frontend/src/components/layout/Layout.tsx CHANGED
@@ -1,75 +1,29 @@
1
- import {
2
- Award,
3
- Briefcase,
4
- CalendarDays,
5
- ChevronDown,
6
- Shield,
7
- Tag as TagIcon,
8
- Users,
9
- } from "lucide-react";
10
- import { useEffect, useRef, useState, type ComponentType } from "react";
11
- import { NavLink, Outlet, useLocation } from "react-router-dom";
12
 
13
  import { useAuth } from "../../auth";
14
 
15
  const navItems = [
16
- { to: "/", label: "Dashboard" },
17
  { to: "/people", label: "People" },
18
  { to: "/projects", label: "Projects" },
19
- { to: "/schedule", label: "Schedule" },
20
  { to: "/reports", label: "Reports" },
21
- ];
22
-
23
- interface ManageItem {
24
- to: string;
25
- label: string;
26
- icon: ComponentType<{ size?: number; className?: string }>;
27
- }
28
-
29
- interface ManageGroup {
30
- items: ManageItem[];
31
- }
32
-
33
- const manageGroups: ManageGroup[] = [
34
- {
35
- items: [
36
- { to: "/manage/roles", label: "Roles", icon: Briefcase },
37
- { to: "/manage/teams", label: "Teams", icon: Users },
38
- { to: "/manage/skills", label: "Skills", icon: Award },
39
- { to: "/manage/tags", label: "Tags", icon: TagIcon },
40
- ],
41
- },
42
- {
43
- items: [
44
- { to: "/manage/holidays", label: "Holidays", icon: CalendarDays },
45
- { to: "/manage/users", label: "Users", icon: Shield },
46
- ],
47
- },
48
  ];
49
 
50
  export function Layout() {
51
  const { logout, user } = useAuth();
52
  const [menuOpen, setMenuOpen] = useState(false);
53
- const [manageOpen, setManageOpen] = useState(false);
54
- const manageRef = useRef<HTMLDivElement>(null);
55
- const location = useLocation();
56
-
57
- const isManageActive = location.pathname.startsWith("/manage") || location.pathname === "/settings";
58
-
59
- useEffect(() => {
60
- setManageOpen(false);
61
- }, [location.pathname]);
62
 
63
  useEffect(() => {
64
- if (!manageOpen) return;
65
- const handler = (event: MouseEvent) => {
66
- if (manageRef.current && !manageRef.current.contains(event.target as Node)) {
67
- setManageOpen(false);
68
- }
69
  };
70
- document.addEventListener("mousedown", handler);
71
- return () => document.removeEventListener("mousedown", handler);
72
- }, [manageOpen]);
73
 
74
  return (
75
  <div className="app-shell">
@@ -87,44 +41,11 @@ export function Layout() {
87
  <NavLink
88
  key={item.to}
89
  to={item.to}
90
- end={item.to === "/"}
91
  className={({ isActive }) => `topnav-tab${isActive ? " active" : ""}`}
92
  >
93
  {item.label}
94
  </NavLink>
95
  ))}
96
-
97
- <div className="topnav-dropdown" ref={manageRef}>
98
- <button
99
- aria-haspopup="menu"
100
- aria-expanded={manageOpen}
101
- className={`topnav-tab${isManageActive ? " active" : ""}`}
102
- onClick={() => setManageOpen((open) => !open)}
103
- type="button"
104
- >
105
- Manage
106
- <ChevronDown size={14} className="topnav-tab-caret" />
107
- </button>
108
- {manageOpen ? (
109
- <div className="topnav-menu" role="menu">
110
- {manageGroups.map((group, groupIndex) => (
111
- <div className="topnav-menu-group" key={groupIndex}>
112
- {group.items.map((item) => (
113
- <NavLink
114
- key={item.to}
115
- to={item.to}
116
- className={({ isActive }) => `topnav-menu-item${isActive ? " active" : ""}`}
117
- role="menuitem"
118
- >
119
- <item.icon size={16} className="topnav-menu-icon" />
120
- <span>{item.label}</span>
121
- </NavLink>
122
- ))}
123
- </div>
124
- ))}
125
- </div>
126
- ) : null}
127
- </div>
128
  </nav>
129
 
130
  <div className="topnav-actions">
@@ -142,7 +63,7 @@ export function Layout() {
142
  </span>
143
  </button>
144
  {menuOpen ? (
145
- <div className="user-menu" onMouseLeave={() => setMenuOpen(false)} role="menu">
146
  <button
147
  onClick={() => {
148
  setMenuOpen(false);
 
1
+ import { useEffect, useState } from "react";
2
+ import { NavLink, Outlet } from "react-router-dom";
 
 
 
 
 
 
 
 
 
3
 
4
  import { useAuth } from "../../auth";
5
 
6
  const navItems = [
 
7
  { to: "/people", label: "People" },
8
  { to: "/projects", label: "Projects" },
9
+ { to: "/manage", label: "Manage" },
10
  { to: "/reports", label: "Reports" },
11
+ { to: "/insights", label: "Insights" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  ];
13
 
14
  export function Layout() {
15
  const { logout, user } = useAuth();
16
  const [menuOpen, setMenuOpen] = useState(false);
 
 
 
 
 
 
 
 
 
17
 
18
  useEffect(() => {
19
+ if (!menuOpen) return;
20
+ const onClick = (event: MouseEvent) => {
21
+ const target = event.target as HTMLElement;
22
+ if (!target.closest(".topnav-actions")) setMenuOpen(false);
 
23
  };
24
+ document.addEventListener("mousedown", onClick);
25
+ return () => document.removeEventListener("mousedown", onClick);
26
+ }, [menuOpen]);
27
 
28
  return (
29
  <div className="app-shell">
 
41
  <NavLink
42
  key={item.to}
43
  to={item.to}
 
44
  className={({ isActive }) => `topnav-tab${isActive ? " active" : ""}`}
45
  >
46
  {item.label}
47
  </NavLink>
48
  ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  </nav>
50
 
51
  <div className="topnav-actions">
 
63
  </span>
64
  </button>
65
  {menuOpen ? (
66
+ <div className="user-menu" role="menu">
67
  <button
68
  onClick={() => {
69
  setMenuOpen(false);
frontend/src/pages/Dashboard.tsx CHANGED
@@ -1,21 +1,20 @@
 
1
  import { Link } from "react-router-dom";
2
 
3
  import { EmptyState } from "../components/ui/EmptyState";
4
  import { useDashboard } from "../hooks/usePortalData";
 
5
 
6
  export function DashboardPage() {
7
  const { data, isLoading, isError } = useDashboard();
8
 
9
  return (
10
  <section>
11
- <header className="page-header">
12
- <p className="eyebrow">Overview</p>
13
- <h2>Dashboard</h2>
14
- <p className="page-subtitle">
15
- Live picture of resource health across IT. Use the quick links to manage people, projects,
16
- and allocations.
17
- </p>
18
- </header>
19
 
20
  {isLoading ? (
21
  <div className="kpi-grid">
@@ -42,7 +41,7 @@ export function DashboardPage() {
42
  <article className="card">
43
  <span>Avg Utilization (this month)</span>
44
  <strong>{data?.average_utilization_pct ?? 0}%</strong>
45
- <Link to="/capacity" className="link-muted">View capacity →</Link>
46
  </article>
47
  <article className="card">
48
  <span>On Leave This Week</span>
@@ -98,8 +97,8 @@ export function DashboardPage() {
98
  <article className="panel banner">
99
  <h3>Welcome — your portal is empty.</h3>
100
  <p>
101
- Add your IT team in <Link to="/people">People</Link> and create your first project in
102
- <Link to="/projects"> Projects</Link> to start planning capacity.
103
  </p>
104
  </article>
105
  ) : null}
 
1
+ import { BarChart3 } from "lucide-react";
2
  import { Link } from "react-router-dom";
3
 
4
  import { EmptyState } from "../components/ui/EmptyState";
5
  import { useDashboard } from "../hooks/usePortalData";
6
+ import { ManagePageHeader } from "./manage/_shared";
7
 
8
  export function DashboardPage() {
9
  const { data, isLoading, isError } = useDashboard();
10
 
11
  return (
12
  <section>
13
+ <ManagePageHeader
14
+ icon={BarChart3}
15
+ title="Insights"
16
+ subtitle="Live picture of resource health across IT — utilization, leave, and upcoming milestones."
17
+ />
 
 
 
18
 
19
  {isLoading ? (
20
  <div className="kpi-grid">
 
41
  <article className="card">
42
  <span>Avg Utilization (this month)</span>
43
  <strong>{data?.average_utilization_pct ?? 0}%</strong>
44
+ <Link to="/people" className="link-muted">View planner →</Link>
45
  </article>
46
  <article className="card">
47
  <span>On Leave This Week</span>
 
97
  <article className="panel banner">
98
  <h3>Welcome — your portal is empty.</h3>
99
  <p>
100
+ Add your IT team in <Link to="/manage/people">Manage → People</Link> and create your first
101
+ project in <Link to="/manage/projects"> Manage → Projects</Link> to start planning capacity.
102
  </p>
103
  </article>
104
  ) : null}
frontend/src/pages/Manage.tsx CHANGED
@@ -1,39 +1,18 @@
1
- import { useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { MoreVertical, Pencil, Plus, Search, Trash2 } from "lucide-react";
3
- import { useEffect, useMemo, useRef, useState, type FormEvent } from "react";
4
- import { Link, NavLink, Navigate, useParams } from "react-router-dom";
5
-
6
- import { createPublicHoliday } from "../api/leaves";
7
- import { createSkill } from "../api/people";
8
- import {
9
- createRole,
10
- createTag,
11
- createTeam,
12
- deleteRole,
13
- deleteTag,
14
- deleteTeam,
15
- updateRole,
16
- updateTag,
17
- updateTeam,
18
- } from "../api/taxonomy";
19
- import { createUser } from "../api/users";
20
- import { useAuth } from "../auth";
21
- import { EmptyState } from "../components/ui/EmptyState";
22
- import { Modal } from "../components/ui/Modal";
23
- import { toastFromError, useToast } from "../components/ui/Toast";
24
- import {
25
- usePublicHolidays,
26
- useRoles,
27
- useSkills,
28
- useTags,
29
- useTeams,
30
- useUsers,
31
- } from "../hooks/usePortalData";
32
- import type { Role, Tag, Team } from "../types";
33
-
34
- const TAG_PALETTE = ["#7c3aed", "#0ea5e9", "#16a34a", "#f97316", "#dc2626", "#facc15", "#0f766e", "#a855f7"];
35
 
36
  const SECTIONS = [
 
 
37
  { key: "roles", label: "Roles" },
38
  { key: "teams", label: "Teams" },
39
  { key: "tags", label: "Tags" },
@@ -44,49 +23,20 @@ const SECTIONS = [
44
 
45
  type SectionKey = (typeof SECTIONS)[number]["key"];
46
 
47
- const sectionTitles: Record<SectionKey, { title: string; subtitle: string }> = {
48
- roles: {
49
- title: "Roles",
50
- subtitle: "Job titles assigned to each person — e.g. \"Senior DevOps\", \"PM\".",
51
- },
52
- teams: {
53
- title: "Teams",
54
- subtitle: "Groupings of people across the IT org — e.g. \"Platform\", \"Helpdesk\".",
55
- },
56
- tags: {
57
- title: "Tags",
58
- subtitle: "Colored labels for people and projects — e.g. \"remote\", \"on-call\".",
59
- },
60
- skills: {
61
- title: "Skills",
62
- subtitle: "Skill library, assignable to each team member from the People page.",
63
- },
64
- holidays: {
65
- title: "Public holidays",
66
- subtitle: "Dates that reduce capacity for everyone in the portal.",
67
- },
68
- users: {
69
- title: "Portal users",
70
- subtitle: "Admins, managers, and viewers who can sign in to the portal.",
71
- },
72
- };
73
 
74
  export function ManagePage() {
75
  const params = useParams<{ section?: string }>();
76
- const section = (params.section ?? "roles") as SectionKey;
77
 
78
- if (!SECTIONS.some((s) => s.key === section)) {
79
- return <Navigate to="/manage/roles" replace />;
 
 
 
80
  }
81
 
82
  return (
83
  <section className="manage-page">
84
- <header className="page-header">
85
- <p className="eyebrow">Admin · Manage</p>
86
- <h2>{sectionTitles[section].title}</h2>
87
- <p className="page-subtitle">{sectionTitles[section].subtitle}</p>
88
- </header>
89
-
90
  <nav className="manage-subnav">
91
  {SECTIONS.map((s) => (
92
  <NavLink
@@ -100,6 +50,8 @@ export function ManagePage() {
100
  </nav>
101
 
102
  <div className="manage-section">
 
 
103
  {section === "roles" && <RolesSection />}
104
  {section === "teams" && <TeamsSection />}
105
  {section === "tags" && <TagsSection />}
@@ -110,999 +62,3 @@ export function ManagePage() {
110
  </section>
111
  );
112
  }
113
-
114
- interface RowMenuItem {
115
- label: string;
116
- icon?: React.ComponentType<{ size?: number }>;
117
- onClick: () => void;
118
- danger?: boolean;
119
- }
120
-
121
- function RowMenu({ items }: { items: RowMenuItem[] }) {
122
- const [open, setOpen] = useState(false);
123
- const ref = useRef<HTMLDivElement>(null);
124
-
125
- useEffect(() => {
126
- if (!open) return;
127
- const handler = (event: MouseEvent) => {
128
- if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
129
- };
130
- document.addEventListener("mousedown", handler);
131
- return () => document.removeEventListener("mousedown", handler);
132
- }, [open]);
133
-
134
- return (
135
- <div className="row-menu" ref={ref}>
136
- <button
137
- aria-haspopup="menu"
138
- aria-expanded={open}
139
- className="row-menu-trigger"
140
- onClick={() => setOpen((v) => !v)}
141
- type="button"
142
- >
143
- <MoreVertical size={16} />
144
- </button>
145
- {open ? (
146
- <div className="row-menu-popover" role="menu">
147
- {items.map((item) => (
148
- <button
149
- className={`row-menu-item${item.danger ? " danger" : ""}`}
150
- key={item.label}
151
- onClick={() => {
152
- setOpen(false);
153
- item.onClick();
154
- }}
155
- role="menuitem"
156
- type="button"
157
- >
158
- {item.icon ? <item.icon size={15} /> : null}
159
- <span>{item.label}</span>
160
- </button>
161
- ))}
162
- </div>
163
- ) : null}
164
- </div>
165
- );
166
- }
167
-
168
- interface ManageToolbarProps {
169
- search: string;
170
- onSearchChange: (value: string) => void;
171
- onNewClick: () => void;
172
- newLabel: string;
173
- searchPlaceholder?: string;
174
- }
175
-
176
- function ManageToolbar({ search, onSearchChange, onNewClick, newLabel, searchPlaceholder }: ManageToolbarProps) {
177
- return (
178
- <div className="manage-toolbar">
179
- <div className="manage-toolbar-search">
180
- <Search size={15} className="manage-toolbar-search-icon" />
181
- <input
182
- onChange={(event) => onSearchChange(event.target.value)}
183
- placeholder={searchPlaceholder ?? "Search"}
184
- type="search"
185
- value={search}
186
- />
187
- </div>
188
- <button className="primary-button manage-toolbar-new" onClick={onNewClick} type="button">
189
- <Plus size={15} />
190
- {newLabel}
191
- </button>
192
- </div>
193
- );
194
- }
195
-
196
- function RolesSection() {
197
- const queryClient = useQueryClient();
198
- const { push } = useToast();
199
- const { data: roles = [] } = useRoles();
200
- const [search, setSearch] = useState("");
201
- const [editing, setEditing] = useState<Role | null>(null);
202
- const [creating, setCreating] = useState(false);
203
- const [name, setName] = useState("");
204
-
205
- const invalidate = () => {
206
- queryClient.invalidateQueries({ queryKey: ["roles"] });
207
- queryClient.invalidateQueries({ queryKey: ["people"] });
208
- };
209
-
210
- const createMutation = useMutation({
211
- mutationFn: createRole,
212
- onSuccess: () => {
213
- push("Role added", "success");
214
- invalidate();
215
- setCreating(false);
216
- setName("");
217
- },
218
- onError: (error) => push(toastFromError(error, "Could not add role"), "error"),
219
- });
220
-
221
- const updateMutation = useMutation({
222
- mutationFn: ({ id, input }: { id: number; input: { name: string } }) => updateRole(id, input),
223
- onSuccess: () => {
224
- push("Role updated", "success");
225
- invalidate();
226
- setEditing(null);
227
- },
228
- onError: (error) => push(toastFromError(error, "Could not update role"), "error"),
229
- });
230
-
231
- const deleteMutation = useMutation({
232
- mutationFn: deleteRole,
233
- onSuccess: () => {
234
- push("Role removed", "success");
235
- invalidate();
236
- },
237
- onError: (error) => push(toastFromError(error, "Could not remove role"), "error"),
238
- });
239
-
240
- const filtered = useMemo(() => {
241
- const term = search.trim().toLowerCase();
242
- if (!term) return roles;
243
- return roles.filter((role) => role.name.toLowerCase().includes(term));
244
- }, [roles, search]);
245
-
246
- return (
247
- <article className="manage-card">
248
- <ManageToolbar
249
- newLabel="New Role"
250
- onNewClick={() => {
251
- setName("");
252
- setCreating(true);
253
- }}
254
- onSearchChange={setSearch}
255
- search={search}
256
- searchPlaceholder="Search"
257
- />
258
-
259
- {roles.length === 0 ? (
260
- <EmptyState title="No roles yet" description="Create a role to start assigning team members." />
261
- ) : (
262
- <table className="data-table runn-table">
263
- <thead>
264
- <tr>
265
- <th>Name</th>
266
- <th className="num">Active People</th>
267
- <th className="num">Active Projects</th>
268
- <th aria-label="Actions" className="row-actions" />
269
- </tr>
270
- </thead>
271
- <tbody>
272
- {filtered.map((role) => (
273
- <tr key={role.id}>
274
- <td>
275
- <button
276
- className="link-text"
277
- onClick={() => setEditing(role)}
278
- type="button"
279
- >
280
- {role.name}
281
- </button>
282
- </td>
283
- <td className="num">
284
- <CountLink count={role.active_people_count} to={`/people?role=${role.id}`} />
285
- </td>
286
- <td className="num">
287
- <span className="count-static">{role.active_projects_count}</span>
288
- </td>
289
- <td className="row-actions">
290
- <RowMenu
291
- items={[
292
- { label: "Edit Details", icon: Pencil, onClick: () => setEditing(role) },
293
- {
294
- label: "Delete",
295
- icon: Trash2,
296
- danger: true,
297
- onClick: () => {
298
- if (window.confirm(`Remove role "${role.name}"?`)) {
299
- deleteMutation.mutate(role.id);
300
- }
301
- },
302
- },
303
- ]}
304
- />
305
- </td>
306
- </tr>
307
- ))}
308
- </tbody>
309
- </table>
310
- )}
311
-
312
- <Modal
313
- open={creating}
314
- onClose={() => setCreating(false)}
315
- title="New role"
316
- footer={
317
- <>
318
- <button className="ghost-button" onClick={() => setCreating(false)} type="button">
319
- Cancel
320
- </button>
321
- <button
322
- className="primary-button"
323
- disabled={createMutation.isPending || !name.trim()}
324
- onClick={() => createMutation.mutate({ name: name.trim() })}
325
- type="button"
326
- >
327
- Add role
328
- </button>
329
- </>
330
- }
331
- >
332
- <div className="form-grid">
333
- <label className="span-2">
334
- Role name
335
- <input
336
- autoFocus
337
- onChange={(event) => setName(event.target.value)}
338
- placeholder="e.g. Senior DevOps Engineer"
339
- type="text"
340
- value={name}
341
- />
342
- </label>
343
- </div>
344
- </Modal>
345
-
346
- <RoleEditModal
347
- role={editing}
348
- onClose={() => setEditing(null)}
349
- onSave={(input) => editing && updateMutation.mutate({ id: editing.id, input })}
350
- isSaving={updateMutation.isPending}
351
- />
352
- </article>
353
- );
354
- }
355
-
356
- function RoleEditModal({
357
- role,
358
- onClose,
359
- onSave,
360
- isSaving,
361
- }: {
362
- role: Role | null;
363
- onClose: () => void;
364
- onSave: (input: { name: string }) => void;
365
- isSaving: boolean;
366
- }) {
367
- const [name, setName] = useState("");
368
- useEffect(() => {
369
- setName(role?.name ?? "");
370
- }, [role]);
371
-
372
- return (
373
- <Modal
374
- open={!!role}
375
- onClose={onClose}
376
- title="Edit role"
377
- footer={
378
- <>
379
- <button className="ghost-button" onClick={onClose} type="button">
380
- Cancel
381
- </button>
382
- <button
383
- className="primary-button"
384
- disabled={isSaving || !name.trim()}
385
- onClick={() => onSave({ name: name.trim() })}
386
- type="button"
387
- >
388
- Save
389
- </button>
390
- </>
391
- }
392
- >
393
- <div className="form-grid">
394
- <label className="span-2">
395
- Role name
396
- <input
397
- autoFocus
398
- onChange={(event) => setName(event.target.value)}
399
- type="text"
400
- value={name}
401
- />
402
- </label>
403
- </div>
404
- </Modal>
405
- );
406
- }
407
-
408
- function TeamsSection() {
409
- const queryClient = useQueryClient();
410
- const { push } = useToast();
411
- const { data: teams = [] } = useTeams();
412
- const [search, setSearch] = useState("");
413
- const [editing, setEditing] = useState<Team | null>(null);
414
- const [creating, setCreating] = useState(false);
415
- const [name, setName] = useState("");
416
-
417
- const invalidate = () => {
418
- queryClient.invalidateQueries({ queryKey: ["teams"] });
419
- queryClient.invalidateQueries({ queryKey: ["people"] });
420
- };
421
-
422
- const createMutation = useMutation({
423
- mutationFn: createTeam,
424
- onSuccess: () => {
425
- push("Team added", "success");
426
- invalidate();
427
- setCreating(false);
428
- setName("");
429
- },
430
- onError: (error) => push(toastFromError(error, "Could not add team"), "error"),
431
- });
432
-
433
- const updateMutation = useMutation({
434
- mutationFn: ({ id, input }: { id: number; input: { name: string } }) => updateTeam(id, input),
435
- onSuccess: () => {
436
- push("Team updated", "success");
437
- invalidate();
438
- setEditing(null);
439
- },
440
- onError: (error) => push(toastFromError(error, "Could not update team"), "error"),
441
- });
442
-
443
- const deleteMutation = useMutation({
444
- mutationFn: deleteTeam,
445
- onSuccess: () => {
446
- push("Team removed", "success");
447
- invalidate();
448
- },
449
- onError: (error) => push(toastFromError(error, "Could not remove team"), "error"),
450
- });
451
-
452
- const filtered = useMemo(() => {
453
- const term = search.trim().toLowerCase();
454
- if (!term) return teams;
455
- return teams.filter((team) => team.name.toLowerCase().includes(term));
456
- }, [teams, search]);
457
-
458
- return (
459
- <article className="manage-card">
460
- <ManageToolbar
461
- newLabel="New Team"
462
- onNewClick={() => {
463
- setName("");
464
- setCreating(true);
465
- }}
466
- onSearchChange={setSearch}
467
- search={search}
468
- />
469
-
470
- {teams.length === 0 ? (
471
- <EmptyState title="No teams yet" description="Create a team to group people by department or function." />
472
- ) : (
473
- <table className="data-table runn-table">
474
- <thead>
475
- <tr>
476
- <th>Name</th>
477
- <th className="num">Active People</th>
478
- <th className="num">Active Projects</th>
479
- <th aria-label="Actions" className="row-actions" />
480
- </tr>
481
- </thead>
482
- <tbody>
483
- {filtered.map((team) => (
484
- <tr key={team.id}>
485
- <td>
486
- <button
487
- className="link-text"
488
- onClick={() => setEditing(team)}
489
- type="button"
490
- >
491
- {team.name}
492
- </button>
493
- </td>
494
- <td className="num">
495
- <CountLink count={team.active_people_count} to={`/people?team=${team.id}`} />
496
- </td>
497
- <td className="num">
498
- <span className="count-static">{team.active_projects_count}</span>
499
- </td>
500
- <td className="row-actions">
501
- <RowMenu
502
- items={[
503
- { label: "Edit Details", icon: Pencil, onClick: () => setEditing(team) },
504
- {
505
- label: "Delete",
506
- icon: Trash2,
507
- danger: true,
508
- onClick: () => {
509
- if (window.confirm(`Remove team "${team.name}"?`)) {
510
- deleteMutation.mutate(team.id);
511
- }
512
- },
513
- },
514
- ]}
515
- />
516
- </td>
517
- </tr>
518
- ))}
519
- </tbody>
520
- </table>
521
- )}
522
-
523
- <Modal
524
- open={creating}
525
- onClose={() => setCreating(false)}
526
- title="New team"
527
- footer={
528
- <>
529
- <button className="ghost-button" onClick={() => setCreating(false)} type="button">
530
- Cancel
531
- </button>
532
- <button
533
- className="primary-button"
534
- disabled={createMutation.isPending || !name.trim()}
535
- onClick={() => createMutation.mutate({ name: name.trim() })}
536
- type="button"
537
- >
538
- Add team
539
- </button>
540
- </>
541
- }
542
- >
543
- <div className="form-grid">
544
- <label className="span-2">
545
- Team name
546
- <input
547
- autoFocus
548
- onChange={(event) => setName(event.target.value)}
549
- placeholder="e.g. Platform"
550
- type="text"
551
- value={name}
552
- />
553
- </label>
554
- </div>
555
- </Modal>
556
-
557
- <TeamEditModal
558
- team={editing}
559
- onClose={() => setEditing(null)}
560
- onSave={(input) => editing && updateMutation.mutate({ id: editing.id, input })}
561
- isSaving={updateMutation.isPending}
562
- />
563
- </article>
564
- );
565
- }
566
-
567
- function TeamEditModal({
568
- team,
569
- onClose,
570
- onSave,
571
- isSaving,
572
- }: {
573
- team: Team | null;
574
- onClose: () => void;
575
- onSave: (input: { name: string }) => void;
576
- isSaving: boolean;
577
- }) {
578
- const [name, setName] = useState("");
579
- useEffect(() => {
580
- setName(team?.name ?? "");
581
- }, [team]);
582
-
583
- return (
584
- <Modal
585
- open={!!team}
586
- onClose={onClose}
587
- title="Edit team"
588
- footer={
589
- <>
590
- <button className="ghost-button" onClick={onClose} type="button">
591
- Cancel
592
- </button>
593
- <button
594
- className="primary-button"
595
- disabled={isSaving || !name.trim()}
596
- onClick={() => onSave({ name: name.trim() })}
597
- type="button"
598
- >
599
- Save
600
- </button>
601
- </>
602
- }
603
- >
604
- <div className="form-grid">
605
- <label className="span-2">
606
- Team name
607
- <input
608
- autoFocus
609
- onChange={(event) => setName(event.target.value)}
610
- type="text"
611
- value={name}
612
- />
613
- </label>
614
- </div>
615
- </Modal>
616
- );
617
- }
618
-
619
- function TagsSection() {
620
- const queryClient = useQueryClient();
621
- const { push } = useToast();
622
- const { data: tags = [] } = useTags();
623
- const [search, setSearch] = useState("");
624
- const [editing, setEditing] = useState<Tag | null>(null);
625
- const [creating, setCreating] = useState(false);
626
- const [form, setForm] = useState({ name: "", color: TAG_PALETTE[0] });
627
-
628
- const invalidate = () => {
629
- queryClient.invalidateQueries({ queryKey: ["tags"] });
630
- queryClient.invalidateQueries({ queryKey: ["people"] });
631
- queryClient.invalidateQueries({ queryKey: ["projects"] });
632
- };
633
-
634
- const createMutation = useMutation({
635
- mutationFn: createTag,
636
- onSuccess: () => {
637
- push("Tag added", "success");
638
- invalidate();
639
- setCreating(false);
640
- setForm({ name: "", color: TAG_PALETTE[0] });
641
- },
642
- onError: (error) => push(toastFromError(error, "Could not add tag"), "error"),
643
- });
644
-
645
- const updateMutation = useMutation({
646
- mutationFn: ({ id, input }: { id: number; input: { name: string; color: string } }) => updateTag(id, input),
647
- onSuccess: () => {
648
- push("Tag updated", "success");
649
- invalidate();
650
- setEditing(null);
651
- },
652
- onError: (error) => push(toastFromError(error, "Could not update tag"), "error"),
653
- });
654
-
655
- const deleteMutation = useMutation({
656
- mutationFn: deleteTag,
657
- onSuccess: () => {
658
- push("Tag removed", "success");
659
- invalidate();
660
- },
661
- onError: (error) => push(toastFromError(error, "Could not remove tag"), "error"),
662
- });
663
-
664
- const filtered = useMemo(() => {
665
- const term = search.trim().toLowerCase();
666
- if (!term) return tags;
667
- return tags.filter((tag) => tag.name.toLowerCase().includes(term));
668
- }, [tags, search]);
669
-
670
- return (
671
- <article className="manage-card">
672
- <ManageToolbar
673
- newLabel="New Tag"
674
- onNewClick={() => {
675
- setForm({ name: "", color: TAG_PALETTE[0] });
676
- setCreating(true);
677
- }}
678
- onSearchChange={setSearch}
679
- search={search}
680
- />
681
-
682
- {tags.length === 0 ? (
683
- <EmptyState title="No tags yet" description="Create your first tag and apply it to people or projects." />
684
- ) : (
685
- <table className="data-table runn-table">
686
- <thead>
687
- <tr>
688
- <th>Name</th>
689
- <th className="num">Active People</th>
690
- <th className="num">Active Projects</th>
691
- <th aria-label="Actions" className="row-actions" />
692
- </tr>
693
- </thead>
694
- <tbody>
695
- {filtered.map((tag) => (
696
- <tr key={tag.id}>
697
- <td>
698
- <button className="link-text tag-name-cell" onClick={() => setEditing(tag)} type="button">
699
- <span className="tag-dot" style={{ background: tag.color }} />
700
- {tag.name}
701
- </button>
702
- </td>
703
- <td className="num">
704
- <CountLink count={tag.active_people_count} to={`/people?tag=${tag.id}`} />
705
- </td>
706
- <td className="num">
707
- <CountLink count={tag.active_projects_count} to={`/projects?tag=${tag.id}`} />
708
- </td>
709
- <td className="row-actions">
710
- <RowMenu
711
- items={[
712
- { label: "Edit Details", icon: Pencil, onClick: () => setEditing(tag) },
713
- {
714
- label: "Delete",
715
- icon: Trash2,
716
- danger: true,
717
- onClick: () => {
718
- if (window.confirm(`Remove tag "${tag.name}"?`)) {
719
- deleteMutation.mutate(tag.id);
720
- }
721
- },
722
- },
723
- ]}
724
- />
725
- </td>
726
- </tr>
727
- ))}
728
- </tbody>
729
- </table>
730
- )}
731
-
732
- <Modal
733
- open={creating}
734
- onClose={() => setCreating(false)}
735
- title="New tag"
736
- footer={
737
- <>
738
- <button className="ghost-button" onClick={() => setCreating(false)} type="button">
739
- Cancel
740
- </button>
741
- <button
742
- className="primary-button"
743
- disabled={createMutation.isPending || !form.name.trim()}
744
- onClick={() => createMutation.mutate({ name: form.name.trim(), color: form.color })}
745
- type="button"
746
- >
747
- Add tag
748
- </button>
749
- </>
750
- }
751
- >
752
- <TagForm form={form} onChange={setForm} />
753
- </Modal>
754
-
755
- <TagEditModal
756
- tag={editing}
757
- onClose={() => setEditing(null)}
758
- onSave={(input) => editing && updateMutation.mutate({ id: editing.id, input })}
759
- isSaving={updateMutation.isPending}
760
- />
761
- </article>
762
- );
763
- }
764
-
765
- function TagForm({
766
- form,
767
- onChange,
768
- }: {
769
- form: { name: string; color: string };
770
- onChange: (next: { name: string; color: string }) => void;
771
- }) {
772
- return (
773
- <div className="form-grid">
774
- <label className="span-2">
775
- Tag name
776
- <input
777
- autoFocus
778
- onChange={(event) => onChange({ ...form, name: event.target.value })}
779
- placeholder="e.g. on-call"
780
- type="text"
781
- value={form.name}
782
- />
783
- </label>
784
- <label className="span-2">
785
- Color
786
- <div className="color-picker">
787
- {TAG_PALETTE.map((color) => (
788
- <button
789
- aria-label={`Use color ${color}`}
790
- className={`color-swatch ${form.color === color ? "selected" : ""}`}
791
- key={color}
792
- onClick={() => onChange({ ...form, color })}
793
- style={{ background: color }}
794
- type="button"
795
- />
796
- ))}
797
- </div>
798
- </label>
799
- </div>
800
- );
801
- }
802
-
803
- function TagEditModal({
804
- tag,
805
- onClose,
806
- onSave,
807
- isSaving,
808
- }: {
809
- tag: Tag | null;
810
- onClose: () => void;
811
- onSave: (input: { name: string; color: string }) => void;
812
- isSaving: boolean;
813
- }) {
814
- const [form, setForm] = useState({ name: "", color: TAG_PALETTE[0] });
815
- useEffect(() => {
816
- if (tag) setForm({ name: tag.name, color: tag.color });
817
- }, [tag]);
818
-
819
- return (
820
- <Modal
821
- open={!!tag}
822
- onClose={onClose}
823
- title="Edit tag"
824
- footer={
825
- <>
826
- <button className="ghost-button" onClick={onClose} type="button">
827
- Cancel
828
- </button>
829
- <button
830
- className="primary-button"
831
- disabled={isSaving || !form.name.trim()}
832
- onClick={() => onSave({ name: form.name.trim(), color: form.color })}
833
- type="button"
834
- >
835
- Save
836
- </button>
837
- </>
838
- }
839
- >
840
- <TagForm form={form} onChange={setForm} />
841
- </Modal>
842
- );
843
- }
844
-
845
- function CountLink({ count, to }: { count: number; to: string }) {
846
- if (!count) return <span className="count-static count-zero">0</span>;
847
- return (
848
- <Link className="count-link" to={to}>
849
- {count}
850
- </Link>
851
- );
852
- }
853
-
854
- function SkillsSection() {
855
- const queryClient = useQueryClient();
856
- const { push } = useToast();
857
- const { data: skills = [] } = useSkills();
858
- const [form, setForm] = useState({ name: "", category: "" });
859
-
860
- const createMutation = useMutation({
861
- mutationFn: createSkill,
862
- onSuccess: () => {
863
- push("Skill added", "success");
864
- setForm({ name: "", category: "" });
865
- queryClient.invalidateQueries({ queryKey: ["skills"] });
866
- },
867
- onError: (error) => push(toastFromError(error, "Could not add skill"), "error"),
868
- });
869
-
870
- const submit = (event: FormEvent<HTMLFormElement>) => {
871
- event.preventDefault();
872
- createMutation.mutate({ name: form.name.trim(), category: form.category.trim() || null });
873
- };
874
-
875
- return (
876
- <article className="panel">
877
- {skills.length === 0 ? (
878
- <EmptyState
879
- title="No skills yet"
880
- description="Add skills like Kubernetes or Network engineering to assign them to people."
881
- />
882
- ) : (
883
- <table className="data-table">
884
- <thead>
885
- <tr>
886
- <th>Name</th>
887
- <th>Category</th>
888
- </tr>
889
- </thead>
890
- <tbody>
891
- {skills.map((skill) => (
892
- <tr key={skill.id}>
893
- <td><strong>{skill.name}</strong></td>
894
- <td>{skill.category ?? <span className="muted-text">Uncategorized</span>}</td>
895
- </tr>
896
- ))}
897
- </tbody>
898
- </table>
899
- )}
900
-
901
- <form className="form-grid manage-inline-form" onSubmit={submit}>
902
- <label>
903
- Skill name
904
- <input
905
- onChange={(event) => setForm({ ...form, name: event.target.value })}
906
- placeholder="e.g. Kubernetes"
907
- required
908
- type="text"
909
- value={form.name}
910
- />
911
- </label>
912
- <label>
913
- Category
914
- <input
915
- onChange={(event) => setForm({ ...form, category: event.target.value })}
916
- placeholder="e.g. Infrastructure"
917
- type="text"
918
- value={form.category}
919
- />
920
- </label>
921
- <div className="span-2">
922
- <button className="primary-button" disabled={createMutation.isPending} type="submit">
923
- Add skill
924
- </button>
925
- </div>
926
- </form>
927
- </article>
928
- );
929
- }
930
-
931
- function HolidaysSection() {
932
- const queryClient = useQueryClient();
933
- const { push } = useToast();
934
- const { data: holidays = [] } = usePublicHolidays();
935
- const [form, setForm] = useState({ note: "", date: "" });
936
-
937
- const createMutation = useMutation({
938
- mutationFn: createPublicHoliday,
939
- onSuccess: () => {
940
- push("Holiday added", "success");
941
- setForm({ note: "", date: "" });
942
- queryClient.invalidateQueries({ queryKey: ["public-holidays"] });
943
- queryClient.invalidateQueries({ queryKey: ["capacity"] });
944
- },
945
- onError: (error) => push(toastFromError(error, "Could not add holiday"), "error"),
946
- });
947
-
948
- const submit = (event: FormEvent<HTMLFormElement>) => {
949
- event.preventDefault();
950
- createMutation.mutate({
951
- leave_type: "public_holiday",
952
- start_date: form.date,
953
- end_date: form.date,
954
- note: form.note,
955
- });
956
- };
957
-
958
- return (
959
- <article className="panel">
960
- {holidays.length === 0 ? (
961
- <EmptyState title="No holidays configured" description="Adding holidays reduces capacity on those dates for everyone." />
962
- ) : (
963
- <table className="data-table">
964
- <thead>
965
- <tr>
966
- <th>Date</th>
967
- <th>Label</th>
968
- </tr>
969
- </thead>
970
- <tbody>
971
- {holidays.map((holiday) => (
972
- <tr key={holiday.id}>
973
- <td><strong>{holiday.start_date}</strong></td>
974
- <td>{holiday.note ?? <span className="muted-text">Holiday</span>}</td>
975
- </tr>
976
- ))}
977
- </tbody>
978
- </table>
979
- )}
980
-
981
- <form className="form-grid manage-inline-form" onSubmit={submit}>
982
- <label>
983
- Date
984
- <input
985
- onChange={(event) => setForm({ ...form, date: event.target.value })}
986
- required
987
- type="date"
988
- value={form.date}
989
- />
990
- </label>
991
- <label>
992
- Label
993
- <input
994
- onChange={(event) => setForm({ ...form, note: event.target.value })}
995
- placeholder="e.g. Independence Day"
996
- type="text"
997
- value={form.note}
998
- />
999
- </label>
1000
- <div className="span-2">
1001
- <button className="primary-button" disabled={createMutation.isPending} type="submit">
1002
- Add holiday
1003
- </button>
1004
- </div>
1005
- </form>
1006
- </article>
1007
- );
1008
- }
1009
-
1010
- function UsersSection() {
1011
- const queryClient = useQueryClient();
1012
- const { user } = useAuth();
1013
- const { push } = useToast();
1014
- const { data: users = [] } = useUsers();
1015
- const [form, setForm] = useState({ username: "", password: "", role: "viewer" });
1016
- const isAdmin = user?.role === "admin";
1017
-
1018
- const createMutation = useMutation({
1019
- mutationFn: createUser,
1020
- onSuccess: () => {
1021
- push("User created", "success");
1022
- setForm({ username: "", password: "", role: "viewer" });
1023
- queryClient.invalidateQueries({ queryKey: ["users"] });
1024
- },
1025
- onError: (error) => push(toastFromError(error, "Could not create user"), "error"),
1026
- });
1027
-
1028
- const submit = (event: FormEvent<HTMLFormElement>) => {
1029
- event.preventDefault();
1030
- createMutation.mutate(form);
1031
- };
1032
-
1033
- if (!isAdmin) {
1034
- return (
1035
- <article className="panel">
1036
- <EmptyState
1037
- title="Admins only"
1038
- description="You need the admin role to manage portal users."
1039
- />
1040
- </article>
1041
- );
1042
- }
1043
-
1044
- return (
1045
- <article className="panel">
1046
- {users.length === 0 ? (
1047
- <EmptyState title="No users yet" />
1048
- ) : (
1049
- <table className="data-table">
1050
- <thead>
1051
- <tr>
1052
- <th>Username</th>
1053
- <th>Role</th>
1054
- <th>Created</th>
1055
- </tr>
1056
- </thead>
1057
- <tbody>
1058
- {users.map((portalUser) => (
1059
- <tr key={portalUser.id}>
1060
- <td><strong>{portalUser.username}</strong></td>
1061
- <td><span className="badge muted">{portalUser.role}</span></td>
1062
- <td>{portalUser.created_at.slice(0, 10)}</td>
1063
- </tr>
1064
- ))}
1065
- </tbody>
1066
- </table>
1067
- )}
1068
-
1069
- <form className="form-grid manage-inline-form" onSubmit={submit}>
1070
- <label>
1071
- Username
1072
- <input
1073
- onChange={(event) => setForm({ ...form, username: event.target.value })}
1074
- required
1075
- type="text"
1076
- value={form.username}
1077
- />
1078
- </label>
1079
- <label>
1080
- Password
1081
- <input
1082
- minLength={4}
1083
- onChange={(event) => setForm({ ...form, password: event.target.value })}
1084
- required
1085
- type="password"
1086
- value={form.password}
1087
- />
1088
- </label>
1089
- <label>
1090
- Role
1091
- <select
1092
- onChange={(event) => setForm({ ...form, role: event.target.value })}
1093
- value={form.role}
1094
- >
1095
- <option value="viewer">Viewer</option>
1096
- <option value="manager">Manager</option>
1097
- <option value="admin">Admin</option>
1098
- </select>
1099
- </label>
1100
- <div className="span-2">
1101
- <button className="primary-button" disabled={createMutation.isPending} type="submit">
1102
- Create user
1103
- </button>
1104
- </div>
1105
- </form>
1106
- </article>
1107
- );
1108
- }
 
1
+ import { NavLink, Navigate, useParams } from "react-router-dom";
2
+
3
+ import { HolidaysSection } from "./manage/HolidaysSection";
4
+ import { HubSection } from "./manage/HubSection";
5
+ import { PeopleSection } from "./manage/PeopleSection";
6
+ import { ProjectsSection } from "./manage/ProjectsSection";
7
+ import { RolesSection } from "./manage/RolesSection";
8
+ import { SkillsSection } from "./manage/SkillsSection";
9
+ import { TagsSection } from "./manage/TagsSection";
10
+ import { TeamsSection } from "./manage/TeamsSection";
11
+ import { UsersSection } from "./manage/UsersSection";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
  const SECTIONS = [
14
+ { key: "people", label: "People" },
15
+ { key: "projects", label: "Projects" },
16
  { key: "roles", label: "Roles" },
17
  { key: "teams", label: "Teams" },
18
  { key: "tags", label: "Tags" },
 
23
 
24
  type SectionKey = (typeof SECTIONS)[number]["key"];
25
 
26
+ const SECTION_KEYS = new Set<SectionKey>(SECTIONS.map((s) => s.key) as SectionKey[]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  export function ManagePage() {
29
  const params = useParams<{ section?: string }>();
 
30
 
31
+ if (!params.section) return <HubSection />;
32
+
33
+ const section = params.section as SectionKey;
34
+ if (!SECTION_KEYS.has(section)) {
35
+ return <Navigate to="/manage" replace />;
36
  }
37
 
38
  return (
39
  <section className="manage-page">
 
 
 
 
 
 
40
  <nav className="manage-subnav">
41
  {SECTIONS.map((s) => (
42
  <NavLink
 
50
  </nav>
51
 
52
  <div className="manage-section">
53
+ {section === "people" && <PeopleSection />}
54
+ {section === "projects" && <ProjectsSection />}
55
  {section === "roles" && <RolesSection />}
56
  {section === "teams" && <TeamsSection />}
57
  {section === "tags" && <TagsSection />}
 
62
  </section>
63
  );
64
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/pages/Reports.tsx CHANGED
@@ -1,4 +1,5 @@
1
  import { format, parseISO } from "date-fns";
 
2
  import { useState } from "react";
3
 
4
  import { EmptyState } from "../components/ui/EmptyState";
@@ -12,6 +13,7 @@ import {
12
  } from "../hooks/usePortalData";
13
  import { useTimelineStore, type TimelineRange } from "../store/timeline";
14
  import type { Leave, ProjectAllocationRow, UtilizationRow } from "../types";
 
15
 
16
  const RANGE_OPTIONS: Array<{ value: TimelineRange; label: string }> = [
17
  { value: 1, label: "4 weeks" },
@@ -58,27 +60,25 @@ export function ReportsPage() {
58
 
59
  return (
60
  <section>
61
- <header className="page-header row">
62
- <div>
63
- <p className="eyebrow">Management</p>
64
- <h2>Reports</h2>
65
- <p className="page-subtitle">
66
- Reports for {format(parseISO(start), "MMM d")} → {format(parseISO(end), "MMM d, yyyy")}.
67
- </p>
68
- </div>
69
- <div className="view-toggle">
70
- {RANGE_OPTIONS.map((option) => (
71
- <button
72
- className={months === option.value ? "primary-button" : "secondary-button"}
73
- key={option.value}
74
- onClick={() => setMonths(option.value)}
75
- type="button"
76
- >
77
- {option.label}
78
- </button>
79
- ))}
80
- </div>
81
- </header>
82
 
83
  <div className="tabs">
84
  <button className={tab === "utilization" ? "tab active" : "tab"} onClick={() => setTab("utilization")} type="button">
 
1
  import { format, parseISO } from "date-fns";
2
+ import { LineChart } from "lucide-react";
3
  import { useState } from "react";
4
 
5
  import { EmptyState } from "../components/ui/EmptyState";
 
13
  } from "../hooks/usePortalData";
14
  import { useTimelineStore, type TimelineRange } from "../store/timeline";
15
  import type { Leave, ProjectAllocationRow, UtilizationRow } from "../types";
16
+ import { ManagePageHeader } from "./manage/_shared";
17
 
18
  const RANGE_OPTIONS: Array<{ value: TimelineRange; label: string }> = [
19
  { value: 1, label: "4 weeks" },
 
60
 
61
  return (
62
  <section>
63
+ <ManagePageHeader
64
+ icon={LineChart}
65
+ title="Reports Center"
66
+ subtitle={`Reports for ${format(parseISO(start), "MMM d")} → ${format(parseISO(end), "MMM d, yyyy")}.`}
67
+ actions={
68
+ <div className="view-toggle">
69
+ {RANGE_OPTIONS.map((option) => (
70
+ <button
71
+ className={months === option.value ? "primary-button" : "secondary-button"}
72
+ key={option.value}
73
+ onClick={() => setMonths(option.value)}
74
+ type="button"
75
+ >
76
+ {option.label}
77
+ </button>
78
+ ))}
79
+ </div>
80
+ }
81
+ />
 
 
82
 
83
  <div className="tabs">
84
  <button className={tab === "utilization" ? "tab active" : "tab"} onClick={() => setTab("utilization")} type="button">
frontend/src/pages/manage/HolidaysSection.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { CalendarDays } from "lucide-react";
3
+ import { useMemo, useState } from "react";
4
+
5
+ import { createPublicHoliday } from "../../api/leaves";
6
+ import { EmptyState } from "../../components/ui/EmptyState";
7
+ import { Modal } from "../../components/ui/Modal";
8
+ import { toastFromError, useToast } from "../../components/ui/Toast";
9
+ import { usePublicHolidays } from "../../hooks/usePortalData";
10
+ import { ManagePageHeader, ManageToolbar } from "./_shared";
11
+
12
+ export function HolidaysSection() {
13
+ const queryClient = useQueryClient();
14
+ const { push } = useToast();
15
+ const { data: holidays = [] } = usePublicHolidays();
16
+ const [search, setSearch] = useState("");
17
+ const [creating, setCreating] = useState(false);
18
+ const [form, setForm] = useState({ note: "", date: "" });
19
+
20
+ const createMutation = useMutation({
21
+ mutationFn: createPublicHoliday,
22
+ onSuccess: () => {
23
+ push("Holiday added", "success");
24
+ setForm({ note: "", date: "" });
25
+ setCreating(false);
26
+ queryClient.invalidateQueries({ queryKey: ["public-holidays"] });
27
+ queryClient.invalidateQueries({ queryKey: ["capacity"] });
28
+ },
29
+ onError: (error) => push(toastFromError(error, "Could not add holiday"), "error"),
30
+ });
31
+
32
+ const filtered = useMemo(() => {
33
+ const term = search.trim().toLowerCase();
34
+ if (!term) return holidays;
35
+ return holidays.filter(
36
+ (holiday) =>
37
+ (holiday.note ?? "").toLowerCase().includes(term) || holiday.start_date.includes(term),
38
+ );
39
+ }, [holidays, search]);
40
+
41
+ return (
42
+ <>
43
+ <ManagePageHeader
44
+ icon={CalendarDays}
45
+ title="Public Holidays"
46
+ subtitle="Dates that reduce capacity for everyone in the portal."
47
+ actions={
48
+ <button
49
+ className="primary-button manage-toolbar-new"
50
+ onClick={() => {
51
+ setForm({ note: "", date: "" });
52
+ setCreating(true);
53
+ }}
54
+ type="button"
55
+ >
56
+ + New Holiday
57
+ </button>
58
+ }
59
+ />
60
+
61
+ <article className="manage-card">
62
+ <ManageToolbar onSearchChange={setSearch} search={search} />
63
+
64
+ {filtered.length === 0 ? (
65
+ <EmptyState
66
+ title={search ? "No matches" : "No holidays configured"}
67
+ description={search ? "Try a different term." : "Adding holidays reduces capacity on those dates for everyone."}
68
+ />
69
+ ) : (
70
+ <table className="data-table runn-table">
71
+ <thead>
72
+ <tr>
73
+ <th>Date</th>
74
+ <th>Label</th>
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ {filtered.map((holiday) => (
79
+ <tr key={holiday.id}>
80
+ <td>
81
+ <strong>{holiday.start_date}</strong>
82
+ </td>
83
+ <td>{holiday.note ?? <span className="muted-text">Holiday</span>}</td>
84
+ </tr>
85
+ ))}
86
+ </tbody>
87
+ </table>
88
+ )}
89
+ </article>
90
+
91
+ <Modal
92
+ open={creating}
93
+ onClose={() => setCreating(false)}
94
+ title="New public holiday"
95
+ footer={
96
+ <>
97
+ <button className="ghost-button" onClick={() => setCreating(false)} type="button">
98
+ Cancel
99
+ </button>
100
+ <button
101
+ className="primary-button"
102
+ disabled={createMutation.isPending || !form.date}
103
+ onClick={() =>
104
+ createMutation.mutate({
105
+ leave_type: "public_holiday",
106
+ start_date: form.date,
107
+ end_date: form.date,
108
+ note: form.note,
109
+ })
110
+ }
111
+ type="button"
112
+ >
113
+ Add holiday
114
+ </button>
115
+ </>
116
+ }
117
+ >
118
+ <div className="form-grid">
119
+ <label>
120
+ Date
121
+ <input
122
+ autoFocus
123
+ onChange={(event) => setForm({ ...form, date: event.target.value })}
124
+ type="date"
125
+ value={form.date}
126
+ />
127
+ </label>
128
+ <label>
129
+ Label
130
+ <input
131
+ onChange={(event) => setForm({ ...form, note: event.target.value })}
132
+ placeholder="e.g. Independence Day"
133
+ type="text"
134
+ value={form.note}
135
+ />
136
+ </label>
137
+ </div>
138
+ </Modal>
139
+ </>
140
+ );
141
+ }
frontend/src/pages/manage/HubSection.tsx ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LayoutDashboard } from "lucide-react";
2
+ import { useMemo, useState } from "react";
3
+ import { useNavigate } from "react-router-dom";
4
+
5
+ import {
6
+ usePeople,
7
+ useProjects,
8
+ usePublicHolidays,
9
+ useRoles,
10
+ useSkills,
11
+ useTags,
12
+ useTeams,
13
+ useUsers,
14
+ } from "../../hooks/usePortalData";
15
+ import { ManagePageHeader, ManageToolbar } from "./_shared";
16
+
17
+ interface HubEntry {
18
+ key: string;
19
+ name: string;
20
+ count: number;
21
+ }
22
+
23
+ export function HubSection() {
24
+ const navigate = useNavigate();
25
+ const [search, setSearch] = useState("");
26
+
27
+ const { data: people = [] } = usePeople();
28
+ const { data: projects = [] } = useProjects();
29
+ const { data: roles = [] } = useRoles();
30
+ const { data: teams = [] } = useTeams();
31
+ const { data: tags = [] } = useTags();
32
+ const { data: skills = [] } = useSkills();
33
+ const { data: holidays = [] } = usePublicHolidays();
34
+ const { data: users = [] } = useUsers();
35
+
36
+ const entries: HubEntry[] = useMemo(
37
+ () => [
38
+ { key: "projects", name: "Projects", count: projects.length },
39
+ { key: "people", name: "People", count: people.length },
40
+ { key: "roles", name: "Roles", count: roles.length },
41
+ { key: "teams", name: "Teams", count: teams.length },
42
+ { key: "skills", name: "Skills", count: skills.length },
43
+ { key: "tags", name: "Tags", count: tags.length },
44
+ { key: "holidays", name: "Holidays", count: holidays.length },
45
+ { key: "users", name: "Users", count: users.length },
46
+ ],
47
+ [people.length, projects.length, roles.length, teams.length, tags.length, skills.length, holidays.length, users.length],
48
+ );
49
+
50
+ const filtered = useMemo(() => {
51
+ const term = search.trim().toLowerCase();
52
+ if (!term) return entries;
53
+ return entries.filter((entry) => entry.name.toLowerCase().includes(term));
54
+ }, [entries, search]);
55
+
56
+ return (
57
+ <section className="manage-page">
58
+ <ManagePageHeader icon={LayoutDashboard} title="Manage" />
59
+
60
+ <article className="manage-card">
61
+ <ManageToolbar onSearchChange={setSearch} search={search} />
62
+
63
+ <table className="data-table runn-table">
64
+ <thead>
65
+ <tr>
66
+ <th>Name</th>
67
+ <th className="num">Count</th>
68
+ <th aria-label="Actions" className="row-actions" />
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ {filtered.map((entry) => (
73
+ <tr key={entry.key}>
74
+ <td>
75
+ <button
76
+ className="link-text"
77
+ onClick={() => navigate(`/manage/${entry.key}`)}
78
+ type="button"
79
+ >
80
+ {entry.name}
81
+ </button>
82
+ </td>
83
+ <td className="num">
84
+ <span className="count-static">{entry.count}</span>
85
+ </td>
86
+ <td className="row-actions">
87
+ <button
88
+ className="ghost-button hub-details-btn"
89
+ onClick={() => navigate(`/manage/${entry.key}`)}
90
+ type="button"
91
+ >
92
+ Details
93
+ </button>
94
+ </td>
95
+ </tr>
96
+ ))}
97
+ </tbody>
98
+ </table>
99
+ </article>
100
+ </section>
101
+ );
102
+ }
frontend/src/pages/manage/PeopleSection.tsx ADDED
@@ -0,0 +1,436 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { ArchiveRestore, ExternalLink, Pencil, Trash2, UserRound } from "lucide-react";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { useNavigate } from "react-router-dom";
5
+
6
+ import { archivePerson, createPerson, restorePerson, updatePerson } from "../../api/people";
7
+ import { Avatar } from "../../components/ui/Avatar";
8
+ import { EmptyState } from "../../components/ui/EmptyState";
9
+ import { Modal } from "../../components/ui/Modal";
10
+ import { toastFromError, useToast } from "../../components/ui/Toast";
11
+ import { usePeople, useRoles, useTags, useTeams } from "../../hooks/usePortalData";
12
+ import type { Person, PersonInput } from "../../types";
13
+ import { ManagePageHeader, ManageToolbar, RowMenu } from "./_shared";
14
+
15
+ const AVATAR_PALETTE = ["#7c3aed", "#6366f1", "#0ea5e9", "#16a34a", "#f97316", "#a855f7", "#facc15", "#ef4444"];
16
+
17
+ const emptyForm: PersonInput = {
18
+ name: "",
19
+ email: "",
20
+ role_id: 0,
21
+ team_id: null,
22
+ weekly_capacity_hrs: 40,
23
+ start_date: null,
24
+ end_date: null,
25
+ avatar_color: AVATAR_PALETTE[0],
26
+ skill_ids: [],
27
+ tag_ids: [],
28
+ };
29
+
30
+ export function PeopleSection() {
31
+ const queryClient = useQueryClient();
32
+ const navigate = useNavigate();
33
+ const { push } = useToast();
34
+
35
+ const [filter, setFilter] = useState<"active" | "archived" | "all">("active");
36
+ const [search, setSearch] = useState("");
37
+ const [creating, setCreating] = useState(false);
38
+ const [editing, setEditing] = useState<Person | null>(null);
39
+
40
+ const includeArchived = filter !== "active";
41
+ const { data: people = [] } = usePeople(includeArchived);
42
+ const { data: roles = [] } = useRoles();
43
+ const { data: teams = [] } = useTeams();
44
+ const { data: allTags = [] } = useTags();
45
+
46
+ const invalidate = () => {
47
+ queryClient.invalidateQueries({ queryKey: ["people"] });
48
+ queryClient.invalidateQueries({ queryKey: ["roles"] });
49
+ queryClient.invalidateQueries({ queryKey: ["teams"] });
50
+ queryClient.invalidateQueries({ queryKey: ["tags"] });
51
+ };
52
+
53
+ const createMutation = useMutation({
54
+ mutationFn: createPerson,
55
+ onSuccess: () => {
56
+ push("Person added", "success");
57
+ invalidate();
58
+ setCreating(false);
59
+ },
60
+ onError: (error) => push(toastFromError(error, "Could not create person"), "error"),
61
+ });
62
+
63
+ const updateMutation = useMutation({
64
+ mutationFn: ({ id, input }: { id: number; input: Partial<PersonInput> }) => updatePerson(id, input),
65
+ onSuccess: () => {
66
+ push("Person updated", "success");
67
+ invalidate();
68
+ setEditing(null);
69
+ },
70
+ onError: (error) => push(toastFromError(error, "Could not update person"), "error"),
71
+ });
72
+
73
+ const archiveMutation = useMutation({
74
+ mutationFn: archivePerson,
75
+ onSuccess: () => {
76
+ push("Person archived", "success");
77
+ invalidate();
78
+ },
79
+ onError: (error) => push(toastFromError(error, "Could not archive person"), "error"),
80
+ });
81
+
82
+ const restoreMutation = useMutation({
83
+ mutationFn: restorePerson,
84
+ onSuccess: () => {
85
+ push("Person restored", "success");
86
+ invalidate();
87
+ },
88
+ onError: (error) => push(toastFromError(error, "Could not restore person"), "error"),
89
+ });
90
+
91
+ const filtered = useMemo(() => {
92
+ const term = search.trim().toLowerCase();
93
+ return people.filter((person) => {
94
+ if (filter === "active" && !person.is_active) return false;
95
+ if (filter === "archived" && person.is_active) return false;
96
+ if (!term) return true;
97
+ return (
98
+ person.name.toLowerCase().includes(term) ||
99
+ person.email.toLowerCase().includes(term) ||
100
+ person.role?.name?.toLowerCase().includes(term) ||
101
+ (person.team?.name ?? "").toLowerCase().includes(term) ||
102
+ person.tags.some((t) => t.name.toLowerCase().includes(term))
103
+ );
104
+ });
105
+ }, [people, search, filter]);
106
+
107
+ return (
108
+ <>
109
+ <ManagePageHeader
110
+ icon={UserRound}
111
+ title="People"
112
+ actions={
113
+ <button className="primary-button manage-toolbar-new" onClick={() => setCreating(true)} type="button">
114
+ + New Person
115
+ </button>
116
+ }
117
+ />
118
+
119
+ <article className="manage-card">
120
+ <ManageToolbar
121
+ onSearchChange={setSearch}
122
+ search={search}
123
+ trailing={
124
+ <div className="filter-pill">
125
+ <label htmlFor="people-filter">Filter</label>
126
+ <select
127
+ id="people-filter"
128
+ onChange={(event) => setFilter(event.target.value as typeof filter)}
129
+ value={filter}
130
+ >
131
+ <option value="active">Active</option>
132
+ <option value="archived">Archived</option>
133
+ <option value="all">All</option>
134
+ </select>
135
+ </div>
136
+ }
137
+ />
138
+
139
+ {filtered.length === 0 ? (
140
+ <EmptyState
141
+ title={search ? "No matches" : "No people yet"}
142
+ description={search ? "Try a different search term." : "Add your first IT team member to get started."}
143
+ />
144
+ ) : (
145
+ <table className="data-table runn-table">
146
+ <thead>
147
+ <tr>
148
+ <th>Name</th>
149
+ <th>Role</th>
150
+ <th>Team</th>
151
+ <th>Tags</th>
152
+ <th aria-label="Actions" className="row-actions" />
153
+ </tr>
154
+ </thead>
155
+ <tbody>
156
+ {filtered.map((person) => (
157
+ <tr key={person.id} className={!person.is_active ? "row-archived" : undefined}>
158
+ <td>
159
+ <div className="cell-person">
160
+ <Avatar name={person.name} color={person.avatar_color} size={28} />
161
+ <button className="link-text" onClick={() => setEditing(person)} type="button">
162
+ {person.name}
163
+ </button>
164
+ {!person.is_active ? <span className="badge muted">Archived</span> : null}
165
+ </div>
166
+ </td>
167
+ <td>{person.role?.name ?? <span className="muted-text">—</span>}</td>
168
+ <td>{person.team?.name ?? <span className="muted-text">—</span>}</td>
169
+ <td>
170
+ {person.tags.length === 0 ? (
171
+ <span className="muted-text">—</span>
172
+ ) : (
173
+ <div className="cell-tags">
174
+ {person.tags.map((tag) => (
175
+ <span
176
+ className="tag-pill"
177
+ key={tag.id}
178
+ style={{ background: `${tag.color}22`, color: tag.color }}
179
+ >
180
+ {tag.name}
181
+ </span>
182
+ ))}
183
+ </div>
184
+ )}
185
+ </td>
186
+ <td className="row-actions">
187
+ <RowMenu
188
+ items={[
189
+ {
190
+ label: "Open in Planner",
191
+ icon: ExternalLink,
192
+ onClick: () => navigate(`/people?focus=${person.id}`),
193
+ },
194
+ { label: "Edit Details", icon: Pencil, onClick: () => setEditing(person) },
195
+ person.is_active
196
+ ? {
197
+ label: "Archive",
198
+ icon: Trash2,
199
+ danger: true,
200
+ onClick: () => {
201
+ if (window.confirm(`Archive ${person.name}?`)) {
202
+ archiveMutation.mutate(person.id);
203
+ }
204
+ },
205
+ }
206
+ : {
207
+ label: "Restore",
208
+ icon: ArchiveRestore,
209
+ onClick: () => restoreMutation.mutate(person.id),
210
+ },
211
+ ]}
212
+ />
213
+ </td>
214
+ </tr>
215
+ ))}
216
+ </tbody>
217
+ </table>
218
+ )}
219
+ </article>
220
+
221
+ <PersonFormModal
222
+ open={creating || !!editing}
223
+ title={editing ? `Edit ${editing.name}` : "New person"}
224
+ onClose={() => {
225
+ setCreating(false);
226
+ setEditing(null);
227
+ }}
228
+ initial={
229
+ editing
230
+ ? {
231
+ name: editing.name,
232
+ email: editing.email,
233
+ role_id: editing.role.id,
234
+ team_id: editing.team?.id ?? null,
235
+ weekly_capacity_hrs: editing.weekly_capacity_hrs,
236
+ start_date: editing.start_date ?? null,
237
+ end_date: editing.end_date ?? null,
238
+ avatar_color: editing.avatar_color,
239
+ skill_ids: editing.person_skills.map((ps) => ps.skill.id),
240
+ tag_ids: editing.tags.map((t) => t.id),
241
+ }
242
+ : emptyForm
243
+ }
244
+ roles={roles}
245
+ teams={teams}
246
+ tags={allTags}
247
+ isSaving={createMutation.isPending || updateMutation.isPending}
248
+ onSave={(input) => {
249
+ if (editing) updateMutation.mutate({ id: editing.id, input });
250
+ else createMutation.mutate(input);
251
+ }}
252
+ />
253
+ </>
254
+ );
255
+ }
256
+
257
+ interface PersonFormModalProps {
258
+ open: boolean;
259
+ title: string;
260
+ onClose: () => void;
261
+ initial: PersonInput;
262
+ roles: { id: number; name: string }[];
263
+ teams: { id: number; name: string }[];
264
+ tags: { id: number; name: string; color: string }[];
265
+ isSaving: boolean;
266
+ onSave: (input: PersonInput) => void;
267
+ }
268
+
269
+ function PersonFormModal({
270
+ open,
271
+ title,
272
+ onClose,
273
+ initial,
274
+ roles,
275
+ teams,
276
+ tags,
277
+ isSaving,
278
+ onSave,
279
+ }: PersonFormModalProps) {
280
+ const [form, setForm] = useState<PersonInput>(initial);
281
+
282
+ useEffect(() => {
283
+ if (open) setForm(initial);
284
+ }, [open, initial]);
285
+
286
+ const toggleTag = (tagId: number) => {
287
+ const exists = form.tag_ids.includes(tagId);
288
+ setForm({
289
+ ...form,
290
+ tag_ids: exists ? form.tag_ids.filter((id) => id !== tagId) : [...form.tag_ids, tagId],
291
+ });
292
+ };
293
+
294
+ const canSave = form.name.trim() && form.email.trim() && form.role_id > 0;
295
+
296
+ return (
297
+ <Modal
298
+ open={open}
299
+ onClose={onClose}
300
+ title={title}
301
+ size="lg"
302
+ footer={
303
+ <>
304
+ <button className="ghost-button" onClick={onClose} type="button">
305
+ Cancel
306
+ </button>
307
+ <button
308
+ className="primary-button"
309
+ disabled={isSaving || !canSave}
310
+ onClick={() => onSave(form)}
311
+ type="button"
312
+ >
313
+ Save
314
+ </button>
315
+ </>
316
+ }
317
+ >
318
+ <div className="form-grid">
319
+ <label>
320
+ Name
321
+ <input
322
+ autoFocus
323
+ onChange={(event) => setForm({ ...form, name: event.target.value })}
324
+ placeholder="Jane Doe"
325
+ type="text"
326
+ value={form.name}
327
+ />
328
+ </label>
329
+ <label>
330
+ Email
331
+ <input
332
+ onChange={(event) => setForm({ ...form, email: event.target.value })}
333
+ placeholder="jane@example.com"
334
+ type="email"
335
+ value={form.email}
336
+ />
337
+ </label>
338
+ <label>
339
+ Default role
340
+ <select
341
+ onChange={(event) => setForm({ ...form, role_id: Number(event.target.value) })}
342
+ value={form.role_id || ""}
343
+ >
344
+ <option value="">Select a role…</option>
345
+ {roles.map((role) => (
346
+ <option key={role.id} value={role.id}>
347
+ {role.name}
348
+ </option>
349
+ ))}
350
+ </select>
351
+ </label>
352
+ <label>
353
+ Team
354
+ <select
355
+ onChange={(event) =>
356
+ setForm({ ...form, team_id: event.target.value ? Number(event.target.value) : null })
357
+ }
358
+ value={form.team_id ?? ""}
359
+ >
360
+ <option value="">No team</option>
361
+ {teams.map((team) => (
362
+ <option key={team.id} value={team.id}>
363
+ {team.name}
364
+ </option>
365
+ ))}
366
+ </select>
367
+ </label>
368
+ <label>
369
+ Weekly capacity (hrs)
370
+ <input
371
+ min={0}
372
+ onChange={(event) => setForm({ ...form, weekly_capacity_hrs: Number(event.target.value) })}
373
+ step={0.5}
374
+ type="number"
375
+ value={form.weekly_capacity_hrs}
376
+ />
377
+ </label>
378
+ <label>
379
+ Avatar color
380
+ <div className="color-picker">
381
+ {AVATAR_PALETTE.map((color) => (
382
+ <button
383
+ aria-label={`Use color ${color}`}
384
+ className={`color-swatch ${form.avatar_color === color ? "selected" : ""}`}
385
+ key={color}
386
+ onClick={() => setForm({ ...form, avatar_color: color })}
387
+ style={{ background: color }}
388
+ type="button"
389
+ />
390
+ ))}
391
+ </div>
392
+ </label>
393
+ <label>
394
+ Start date
395
+ <input
396
+ onChange={(event) => setForm({ ...form, start_date: event.target.value || null })}
397
+ type="date"
398
+ value={form.start_date ?? ""}
399
+ />
400
+ </label>
401
+ <label>
402
+ End date
403
+ <input
404
+ onChange={(event) => setForm({ ...form, end_date: event.target.value || null })}
405
+ type="date"
406
+ value={form.end_date ?? ""}
407
+ />
408
+ </label>
409
+ <label className="span-2">
410
+ Tags
411
+ <div className="tag-picker">
412
+ {tags.length === 0 ? <span className="muted-text">No tags yet — add one from Manage → Tags.</span> : null}
413
+ {tags.map((tag) => {
414
+ const active = form.tag_ids.includes(tag.id);
415
+ return (
416
+ <button
417
+ className={`badge tag-chip${active ? "" : " muted"}`}
418
+ key={tag.id}
419
+ onClick={() => toggleTag(tag.id)}
420
+ style={
421
+ active
422
+ ? { borderColor: tag.color, background: tag.color, color: "white" }
423
+ : { borderColor: tag.color, color: tag.color }
424
+ }
425
+ type="button"
426
+ >
427
+ {tag.name}
428
+ </button>
429
+ );
430
+ })}
431
+ </div>
432
+ </label>
433
+ </div>
434
+ </Modal>
435
+ );
436
+ }
frontend/src/pages/manage/ProjectsSection.tsx ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { addMonths, format } from "date-fns";
3
+ import { ArchiveRestore, ClipboardList, ExternalLink, Pencil, Trash2 } from "lucide-react";
4
+ import { useEffect, useMemo, useState } from "react";
5
+ import { useNavigate } from "react-router-dom";
6
+
7
+ import { archiveProject, createProject, restoreProject, updateProject } from "../../api/projects";
8
+ import { EmptyState } from "../../components/ui/EmptyState";
9
+ import { Modal } from "../../components/ui/Modal";
10
+ import { toastFromError, useToast } from "../../components/ui/Toast";
11
+ import { usePeople, useProjects, useTags } from "../../hooks/usePortalData";
12
+ import type { Project, ProjectInput } from "../../types";
13
+ import { ManagePageHeader, ManageToolbar, RowMenu } from "./_shared";
14
+
15
+ const PROJECT_COLORS = ["#0ea5e9", "#16a34a", "#f97316", "#a855f7", "#facc15", "#ef4444", "#0f766e"];
16
+
17
+ const STATUSES: Array<{ value: string; label: string }> = [
18
+ { value: "planning", label: "Planning" },
19
+ { value: "active", label: "Active" },
20
+ { value: "on_hold", label: "On hold" },
21
+ { value: "completed", label: "Completed" },
22
+ ];
23
+
24
+ const TYPES: Array<{ value: string; label: string }> = [
25
+ { value: "infrastructure", label: "Infrastructure" },
26
+ { value: "development", label: "Development" },
27
+ { value: "support", label: "Support" },
28
+ { value: "other", label: "Other" },
29
+ ];
30
+
31
+ const statusLabel = (value: string) => STATUSES.find((s) => s.value === value)?.label ?? value;
32
+
33
+ export function ProjectsSection() {
34
+ const queryClient = useQueryClient();
35
+ const navigate = useNavigate();
36
+ const { push } = useToast();
37
+
38
+ const [filter, setFilter] = useState<"active" | "archived" | "all">("active");
39
+ const [search, setSearch] = useState("");
40
+ const [creating, setCreating] = useState(false);
41
+ const [editing, setEditing] = useState<Project | null>(null);
42
+
43
+ const includeArchived = filter !== "active";
44
+ const { data: projects = [] } = useProjects(includeArchived);
45
+ const { data: people = [] } = usePeople();
46
+ const { data: allTags = [] } = useTags();
47
+
48
+ const invalidate = () => {
49
+ queryClient.invalidateQueries({ queryKey: ["projects"] });
50
+ queryClient.invalidateQueries({ queryKey: ["allocations"] });
51
+ };
52
+
53
+ const createMutation = useMutation({
54
+ mutationFn: createProject,
55
+ onSuccess: () => {
56
+ push("Project created", "success");
57
+ invalidate();
58
+ setCreating(false);
59
+ },
60
+ onError: (error) => push(toastFromError(error, "Could not create project"), "error"),
61
+ });
62
+
63
+ const updateMutation = useMutation({
64
+ mutationFn: ({ id, input }: { id: number; input: Partial<ProjectInput> }) => updateProject(id, input),
65
+ onSuccess: () => {
66
+ push("Project updated", "success");
67
+ invalidate();
68
+ setEditing(null);
69
+ },
70
+ onError: (error) => push(toastFromError(error, "Could not update project"), "error"),
71
+ });
72
+
73
+ const archiveMutation = useMutation({
74
+ mutationFn: archiveProject,
75
+ onSuccess: () => {
76
+ push("Project archived", "success");
77
+ invalidate();
78
+ },
79
+ onError: (error) => push(toastFromError(error, "Could not archive project"), "error"),
80
+ });
81
+
82
+ const restoreMutation = useMutation({
83
+ mutationFn: restoreProject,
84
+ onSuccess: () => {
85
+ push("Project restored", "success");
86
+ invalidate();
87
+ },
88
+ onError: (error) => push(toastFromError(error, "Could not restore project"), "error"),
89
+ });
90
+
91
+ const filtered = useMemo(() => {
92
+ const term = search.trim().toLowerCase();
93
+ return projects.filter((project) => {
94
+ if (filter === "active" && !project.is_active) return false;
95
+ if (filter === "archived" && project.is_active) return false;
96
+ if (!term) return true;
97
+ return (
98
+ project.name.toLowerCase().includes(term) ||
99
+ (project.description ?? "").toLowerCase().includes(term) ||
100
+ project.tags.some((tag) => tag.name.toLowerCase().includes(term))
101
+ );
102
+ });
103
+ }, [projects, search, filter]);
104
+
105
+ return (
106
+ <>
107
+ <ManagePageHeader
108
+ icon={ClipboardList}
109
+ title="Projects"
110
+ actions={
111
+ <button className="primary-button manage-toolbar-new" onClick={() => setCreating(true)} type="button">
112
+ + New Project
113
+ </button>
114
+ }
115
+ />
116
+
117
+ <article className="manage-card">
118
+ <ManageToolbar
119
+ onSearchChange={setSearch}
120
+ search={search}
121
+ trailing={
122
+ <div className="filter-pill">
123
+ <label htmlFor="projects-filter">Filter</label>
124
+ <select
125
+ id="projects-filter"
126
+ onChange={(event) => setFilter(event.target.value as typeof filter)}
127
+ value={filter}
128
+ >
129
+ <option value="active">Active</option>
130
+ <option value="archived">Archived</option>
131
+ <option value="all">All</option>
132
+ </select>
133
+ </div>
134
+ }
135
+ />
136
+
137
+ {filtered.length === 0 ? (
138
+ <EmptyState
139
+ title={search ? "No matches" : "No projects yet"}
140
+ description={search ? "Try a different search term." : "Create your first project to allocate people."}
141
+ />
142
+ ) : (
143
+ <table className="data-table runn-table">
144
+ <thead>
145
+ <tr>
146
+ <th>Name</th>
147
+ <th>Status</th>
148
+ <th>Type</th>
149
+ <th>Dates</th>
150
+ <th>Tags</th>
151
+ <th aria-label="Actions" className="row-actions" />
152
+ </tr>
153
+ </thead>
154
+ <tbody>
155
+ {filtered.map((project) => (
156
+ <tr key={project.id} className={!project.is_active ? "row-archived" : undefined}>
157
+ <td>
158
+ <div className="cell-project">
159
+ <span className="project-swatch" style={{ background: project.color }} />
160
+ <button className="link-text" onClick={() => setEditing(project)} type="button">
161
+ {project.name}
162
+ </button>
163
+ {!project.is_active ? <span className="badge muted">Archived</span> : null}
164
+ </div>
165
+ </td>
166
+ <td>
167
+ <span className={`badge status-${project.status}`}>{statusLabel(project.status)}</span>
168
+ </td>
169
+ <td>{project.type}</td>
170
+ <td>
171
+ <span className="muted-text">
172
+ {format(new Date(project.start_date), "d MMM yy")} – {format(new Date(project.end_date), "d MMM yy")}
173
+ </span>
174
+ </td>
175
+ <td>
176
+ {project.tags.length === 0 ? (
177
+ <span className="muted-text">—</span>
178
+ ) : (
179
+ <div className="cell-tags">
180
+ {project.tags.map((tag) => (
181
+ <span
182
+ className="tag-pill"
183
+ key={tag.id}
184
+ style={{ background: `${tag.color}22`, color: tag.color }}
185
+ >
186
+ {tag.name}
187
+ </span>
188
+ ))}
189
+ </div>
190
+ )}
191
+ </td>
192
+ <td className="row-actions">
193
+ <RowMenu
194
+ items={[
195
+ {
196
+ label: "Open in Planner",
197
+ icon: ExternalLink,
198
+ onClick: () => navigate(`/projects?focus=${project.id}`),
199
+ },
200
+ { label: "Edit Details", icon: Pencil, onClick: () => setEditing(project) },
201
+ project.is_active
202
+ ? {
203
+ label: "Archive",
204
+ icon: Trash2,
205
+ danger: true,
206
+ onClick: () => {
207
+ if (window.confirm(`Archive ${project.name}?`)) {
208
+ archiveMutation.mutate(project.id);
209
+ }
210
+ },
211
+ }
212
+ : {
213
+ label: "Restore",
214
+ icon: ArchiveRestore,
215
+ onClick: () => restoreMutation.mutate(project.id),
216
+ },
217
+ ]}
218
+ />
219
+ </td>
220
+ </tr>
221
+ ))}
222
+ </tbody>
223
+ </table>
224
+ )}
225
+ </article>
226
+
227
+ <ProjectFormModal
228
+ open={creating || !!editing}
229
+ title={editing ? `Edit ${editing.name}` : "New project"}
230
+ onClose={() => {
231
+ setCreating(false);
232
+ setEditing(null);
233
+ }}
234
+ initial={
235
+ editing
236
+ ? {
237
+ name: editing.name,
238
+ description: editing.description ?? "",
239
+ status: editing.status,
240
+ type: editing.type,
241
+ start_date: editing.start_date,
242
+ end_date: editing.end_date,
243
+ is_tentative: editing.is_tentative,
244
+ color: editing.color,
245
+ owner_id: editing.owner_id ?? null,
246
+ tag_ids: editing.tags.map((t) => t.id),
247
+ }
248
+ : {
249
+ name: "",
250
+ description: "",
251
+ status: "planning",
252
+ type: "development",
253
+ start_date: format(new Date(), "yyyy-MM-dd"),
254
+ end_date: format(addMonths(new Date(), 2), "yyyy-MM-dd"),
255
+ is_tentative: false,
256
+ color: PROJECT_COLORS[0],
257
+ owner_id: null,
258
+ tag_ids: [],
259
+ }
260
+ }
261
+ owners={people}
262
+ tags={allTags}
263
+ isSaving={createMutation.isPending || updateMutation.isPending}
264
+ onSave={(input) => {
265
+ if (editing) updateMutation.mutate({ id: editing.id, input });
266
+ else createMutation.mutate(input);
267
+ }}
268
+ />
269
+ </>
270
+ );
271
+ }
272
+
273
+ interface ProjectFormModalProps {
274
+ open: boolean;
275
+ title: string;
276
+ onClose: () => void;
277
+ initial: ProjectInput;
278
+ owners: { id: number; name: string }[];
279
+ tags: { id: number; name: string; color: string }[];
280
+ isSaving: boolean;
281
+ onSave: (input: ProjectInput) => void;
282
+ }
283
+
284
+ function ProjectFormModal({ open, title, onClose, initial, owners, tags, isSaving, onSave }: ProjectFormModalProps) {
285
+ const [form, setForm] = useState<ProjectInput>(initial);
286
+
287
+ useEffect(() => {
288
+ if (open) setForm(initial);
289
+ }, [open, initial]);
290
+
291
+ const toggleTag = (tagId: number) => {
292
+ const exists = form.tag_ids.includes(tagId);
293
+ setForm({
294
+ ...form,
295
+ tag_ids: exists ? form.tag_ids.filter((id) => id !== tagId) : [...form.tag_ids, tagId],
296
+ });
297
+ };
298
+
299
+ const canSave = form.name.trim() && form.start_date && form.end_date;
300
+
301
+ return (
302
+ <Modal
303
+ open={open}
304
+ onClose={onClose}
305
+ title={title}
306
+ size="lg"
307
+ footer={
308
+ <>
309
+ <button className="ghost-button" onClick={onClose} type="button">
310
+ Cancel
311
+ </button>
312
+ <button
313
+ className="primary-button"
314
+ disabled={isSaving || !canSave}
315
+ onClick={() => onSave(form)}
316
+ type="button"
317
+ >
318
+ Save
319
+ </button>
320
+ </>
321
+ }
322
+ >
323
+ <div className="form-grid">
324
+ <label className="span-2">
325
+ Project name
326
+ <input
327
+ autoFocus
328
+ onChange={(event) => setForm({ ...form, name: event.target.value })}
329
+ type="text"
330
+ value={form.name}
331
+ />
332
+ </label>
333
+ <label className="span-2">
334
+ Description
335
+ <textarea
336
+ onChange={(event) => setForm({ ...form, description: event.target.value })}
337
+ placeholder="What is this project about?"
338
+ rows={3}
339
+ value={form.description ?? ""}
340
+ />
341
+ </label>
342
+ <label>
343
+ Status
344
+ <select
345
+ onChange={(event) => setForm({ ...form, status: event.target.value })}
346
+ value={form.status}
347
+ >
348
+ {STATUSES.map((status) => (
349
+ <option key={status.value} value={status.value}>
350
+ {status.label}
351
+ </option>
352
+ ))}
353
+ </select>
354
+ </label>
355
+ <label>
356
+ Type
357
+ <select onChange={(event) => setForm({ ...form, type: event.target.value })} value={form.type}>
358
+ {TYPES.map((type) => (
359
+ <option key={type.value} value={type.value}>
360
+ {type.label}
361
+ </option>
362
+ ))}
363
+ </select>
364
+ </label>
365
+ <label>
366
+ Start date
367
+ <input
368
+ onChange={(event) => setForm({ ...form, start_date: event.target.value })}
369
+ type="date"
370
+ value={form.start_date}
371
+ />
372
+ </label>
373
+ <label>
374
+ End date
375
+ <input
376
+ onChange={(event) => setForm({ ...form, end_date: event.target.value })}
377
+ type="date"
378
+ value={form.end_date}
379
+ />
380
+ </label>
381
+ <label>
382
+ Project owner
383
+ <select
384
+ onChange={(event) =>
385
+ setForm({ ...form, owner_id: event.target.value ? Number(event.target.value) : null })
386
+ }
387
+ value={form.owner_id ?? ""}
388
+ >
389
+ <option value="">Unassigned</option>
390
+ {owners.map((person) => (
391
+ <option key={person.id} value={person.id}>
392
+ {person.name}
393
+ </option>
394
+ ))}
395
+ </select>
396
+ </label>
397
+ <label>
398
+ Color
399
+ <div className="color-picker">
400
+ {PROJECT_COLORS.map((color) => (
401
+ <button
402
+ aria-label={`Use color ${color}`}
403
+ className={`color-swatch ${form.color === color ? "selected" : ""}`}
404
+ key={color}
405
+ onClick={() => setForm({ ...form, color })}
406
+ style={{ background: color }}
407
+ type="button"
408
+ />
409
+ ))}
410
+ </div>
411
+ </label>
412
+ <label className="span-2 checkbox-row">
413
+ <input
414
+ checked={form.is_tentative}
415
+ onChange={(event) => setForm({ ...form, is_tentative: event.target.checked })}
416
+ type="checkbox"
417
+ />
418
+ <span>Tentative — not confirmed yet</span>
419
+ </label>
420
+ <label className="span-2">
421
+ Tags
422
+ <div className="tag-picker">
423
+ {tags.length === 0 ? <span className="muted-text">No tags yet — add one from Manage → Tags.</span> : null}
424
+ {tags.map((tag) => {
425
+ const active = form.tag_ids.includes(tag.id);
426
+ return (
427
+ <button
428
+ className={`badge tag-chip${active ? "" : " muted"}`}
429
+ key={tag.id}
430
+ onClick={() => toggleTag(tag.id)}
431
+ style={
432
+ active
433
+ ? { borderColor: tag.color, background: tag.color, color: "white" }
434
+ : { borderColor: tag.color, color: tag.color }
435
+ }
436
+ type="button"
437
+ >
438
+ {tag.name}
439
+ </button>
440
+ );
441
+ })}
442
+ </div>
443
+ </label>
444
+ </div>
445
+ </Modal>
446
+ );
447
+ }
frontend/src/pages/manage/RolesSection.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { Briefcase, Pencil, Trash2 } from "lucide-react";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { Link } from "react-router-dom";
5
+
6
+ import { createRole, deleteRole, updateRole } from "../../api/taxonomy";
7
+ import { EmptyState } from "../../components/ui/EmptyState";
8
+ import { Modal } from "../../components/ui/Modal";
9
+ import { toastFromError, useToast } from "../../components/ui/Toast";
10
+ import { useRoles } from "../../hooks/usePortalData";
11
+ import type { Role } from "../../types";
12
+ import { ManagePageHeader, ManageToolbar, RowMenu } from "./_shared";
13
+
14
+ export function RolesSection() {
15
+ const queryClient = useQueryClient();
16
+ const { push } = useToast();
17
+ const { data: roles = [] } = useRoles();
18
+ const [search, setSearch] = useState("");
19
+ const [editing, setEditing] = useState<Role | null>(null);
20
+ const [creating, setCreating] = useState(false);
21
+
22
+ const invalidate = () => {
23
+ queryClient.invalidateQueries({ queryKey: ["roles"] });
24
+ queryClient.invalidateQueries({ queryKey: ["people"] });
25
+ };
26
+
27
+ const createMutation = useMutation({
28
+ mutationFn: createRole,
29
+ onSuccess: () => {
30
+ push("Role added", "success");
31
+ invalidate();
32
+ setCreating(false);
33
+ },
34
+ onError: (error) => push(toastFromError(error, "Could not add role"), "error"),
35
+ });
36
+
37
+ const updateMutation = useMutation({
38
+ mutationFn: ({ id, input }: { id: number; input: { name: string } }) => updateRole(id, input),
39
+ onSuccess: () => {
40
+ push("Role updated", "success");
41
+ invalidate();
42
+ setEditing(null);
43
+ },
44
+ onError: (error) => push(toastFromError(error, "Could not update role"), "error"),
45
+ });
46
+
47
+ const deleteMutation = useMutation({
48
+ mutationFn: deleteRole,
49
+ onSuccess: () => {
50
+ push("Role removed", "success");
51
+ invalidate();
52
+ },
53
+ onError: (error) => push(toastFromError(error, "Could not remove role"), "error"),
54
+ });
55
+
56
+ const filtered = useMemo(() => {
57
+ const term = search.trim().toLowerCase();
58
+ if (!term) return roles;
59
+ return roles.filter((role) => role.name.toLowerCase().includes(term));
60
+ }, [roles, search]);
61
+
62
+ return (
63
+ <>
64
+ <ManagePageHeader
65
+ icon={Briefcase}
66
+ title="Roles"
67
+ subtitle='Job titles assigned to each person — e.g. "Senior DevOps", "PM".'
68
+ actions={
69
+ <button className="primary-button manage-toolbar-new" onClick={() => setCreating(true)} type="button">
70
+ + New Role
71
+ </button>
72
+ }
73
+ />
74
+
75
+ <article className="manage-card">
76
+ <ManageToolbar onSearchChange={setSearch} search={search} />
77
+
78
+ {roles.length === 0 ? (
79
+ <EmptyState title="No roles yet" description="Create a role to start assigning team members." />
80
+ ) : (
81
+ <table className="data-table runn-table">
82
+ <thead>
83
+ <tr>
84
+ <th>Name</th>
85
+ <th className="num">Active People</th>
86
+ <th className="num">Active Projects</th>
87
+ <th aria-label="Actions" className="row-actions" />
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {filtered.map((role) => (
92
+ <tr key={role.id}>
93
+ <td>
94
+ <button className="link-text" onClick={() => setEditing(role)} type="button">
95
+ {role.name}
96
+ </button>
97
+ </td>
98
+ <td className="num">
99
+ <CountLink count={role.active_people_count} to={`/people?role=${role.id}`} />
100
+ </td>
101
+ <td className="num">
102
+ <span className="count-static">{role.active_projects_count}</span>
103
+ </td>
104
+ <td className="row-actions">
105
+ <RowMenu
106
+ items={[
107
+ { label: "Edit Details", icon: Pencil, onClick: () => setEditing(role) },
108
+ {
109
+ label: "Delete",
110
+ icon: Trash2,
111
+ danger: true,
112
+ onClick: () => {
113
+ if (window.confirm(`Remove role "${role.name}"?`)) {
114
+ deleteMutation.mutate(role.id);
115
+ }
116
+ },
117
+ },
118
+ ]}
119
+ />
120
+ </td>
121
+ </tr>
122
+ ))}
123
+ </tbody>
124
+ </table>
125
+ )}
126
+ </article>
127
+
128
+ <RoleModal
129
+ open={creating}
130
+ title="New role"
131
+ initial=""
132
+ onClose={() => setCreating(false)}
133
+ onSave={(name) => createMutation.mutate({ name })}
134
+ isSaving={createMutation.isPending}
135
+ />
136
+ <RoleModal
137
+ open={!!editing}
138
+ title="Edit role"
139
+ initial={editing?.name ?? ""}
140
+ onClose={() => setEditing(null)}
141
+ onSave={(name) => editing && updateMutation.mutate({ id: editing.id, input: { name } })}
142
+ isSaving={updateMutation.isPending}
143
+ />
144
+ </>
145
+ );
146
+ }
147
+
148
+ function RoleModal({
149
+ open,
150
+ title,
151
+ initial,
152
+ onClose,
153
+ onSave,
154
+ isSaving,
155
+ }: {
156
+ open: boolean;
157
+ title: string;
158
+ initial: string;
159
+ onClose: () => void;
160
+ onSave: (name: string) => void;
161
+ isSaving: boolean;
162
+ }) {
163
+ const [name, setName] = useState("");
164
+ useEffect(() => {
165
+ if (open) setName(initial);
166
+ }, [open, initial]);
167
+
168
+ return (
169
+ <Modal
170
+ open={open}
171
+ onClose={onClose}
172
+ title={title}
173
+ footer={
174
+ <>
175
+ <button className="ghost-button" onClick={onClose} type="button">
176
+ Cancel
177
+ </button>
178
+ <button
179
+ className="primary-button"
180
+ disabled={isSaving || !name.trim()}
181
+ onClick={() => onSave(name.trim())}
182
+ type="button"
183
+ >
184
+ Save
185
+ </button>
186
+ </>
187
+ }
188
+ >
189
+ <div className="form-grid">
190
+ <label className="span-2">
191
+ Role name
192
+ <input
193
+ autoFocus
194
+ onChange={(event) => setName(event.target.value)}
195
+ placeholder="e.g. Senior DevOps Engineer"
196
+ type="text"
197
+ value={name}
198
+ />
199
+ </label>
200
+ </div>
201
+ </Modal>
202
+ );
203
+ }
204
+
205
+ function CountLink({ count, to }: { count: number; to: string }) {
206
+ if (!count) return <span className="count-static count-zero">0</span>;
207
+ return (
208
+ <Link className="count-link" to={to}>
209
+ {count}
210
+ </Link>
211
+ );
212
+ }
frontend/src/pages/manage/SkillsSection.tsx ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { Award } from "lucide-react";
3
+ import { useMemo, useState, type FormEvent } from "react";
4
+
5
+ import { createSkill } from "../../api/people";
6
+ import { EmptyState } from "../../components/ui/EmptyState";
7
+ import { Modal } from "../../components/ui/Modal";
8
+ import { toastFromError, useToast } from "../../components/ui/Toast";
9
+ import { useSkills } from "../../hooks/usePortalData";
10
+ import { ManagePageHeader, ManageToolbar } from "./_shared";
11
+
12
+ export function SkillsSection() {
13
+ const queryClient = useQueryClient();
14
+ const { push } = useToast();
15
+ const { data: skills = [] } = useSkills();
16
+ const [search, setSearch] = useState("");
17
+ const [creating, setCreating] = useState(false);
18
+ const [form, setForm] = useState({ name: "", category: "" });
19
+
20
+ const createMutation = useMutation({
21
+ mutationFn: createSkill,
22
+ onSuccess: () => {
23
+ push("Skill added", "success");
24
+ setForm({ name: "", category: "" });
25
+ setCreating(false);
26
+ queryClient.invalidateQueries({ queryKey: ["skills"] });
27
+ },
28
+ onError: (error) => push(toastFromError(error, "Could not add skill"), "error"),
29
+ });
30
+
31
+ const filtered = useMemo(() => {
32
+ const term = search.trim().toLowerCase();
33
+ if (!term) return skills;
34
+ return skills.filter(
35
+ (skill) =>
36
+ skill.name.toLowerCase().includes(term) || (skill.category ?? "").toLowerCase().includes(term),
37
+ );
38
+ }, [skills, search]);
39
+
40
+ const submit = (event: FormEvent<HTMLFormElement>) => {
41
+ event.preventDefault();
42
+ createMutation.mutate({ name: form.name.trim(), category: form.category.trim() || null });
43
+ };
44
+
45
+ return (
46
+ <>
47
+ <ManagePageHeader
48
+ icon={Award}
49
+ title="Skills"
50
+ subtitle="Skill library, assignable to each team member from the People page."
51
+ actions={
52
+ <button
53
+ className="primary-button manage-toolbar-new"
54
+ onClick={() => {
55
+ setForm({ name: "", category: "" });
56
+ setCreating(true);
57
+ }}
58
+ type="button"
59
+ >
60
+ + New Skill
61
+ </button>
62
+ }
63
+ />
64
+
65
+ <article className="manage-card">
66
+ <ManageToolbar onSearchChange={setSearch} search={search} />
67
+
68
+ {filtered.length === 0 ? (
69
+ <EmptyState
70
+ title={search ? "No matches" : "No skills yet"}
71
+ description={search ? "Try a different term." : "Add skills like Kubernetes or Networking to assign to people."}
72
+ />
73
+ ) : (
74
+ <table className="data-table runn-table">
75
+ <thead>
76
+ <tr>
77
+ <th>Name</th>
78
+ <th>Category</th>
79
+ </tr>
80
+ </thead>
81
+ <tbody>
82
+ {filtered.map((skill) => (
83
+ <tr key={skill.id}>
84
+ <td>
85
+ <strong>{skill.name}</strong>
86
+ </td>
87
+ <td>{skill.category ?? <span className="muted-text">Uncategorized</span>}</td>
88
+ </tr>
89
+ ))}
90
+ </tbody>
91
+ </table>
92
+ )}
93
+ </article>
94
+
95
+ <Modal
96
+ open={creating}
97
+ onClose={() => setCreating(false)}
98
+ title="New skill"
99
+ footer={
100
+ <>
101
+ <button className="ghost-button" onClick={() => setCreating(false)} type="button">
102
+ Cancel
103
+ </button>
104
+ <button
105
+ className="primary-button"
106
+ disabled={createMutation.isPending || !form.name.trim()}
107
+ onClick={() =>
108
+ createMutation.mutate({
109
+ name: form.name.trim(),
110
+ category: form.category.trim() || null,
111
+ })
112
+ }
113
+ type="button"
114
+ >
115
+ Add skill
116
+ </button>
117
+ </>
118
+ }
119
+ >
120
+ <form className="form-grid" onSubmit={submit}>
121
+ <label>
122
+ Skill name
123
+ <input
124
+ autoFocus
125
+ onChange={(event) => setForm({ ...form, name: event.target.value })}
126
+ placeholder="e.g. Kubernetes"
127
+ required
128
+ type="text"
129
+ value={form.name}
130
+ />
131
+ </label>
132
+ <label>
133
+ Category
134
+ <input
135
+ onChange={(event) => setForm({ ...form, category: event.target.value })}
136
+ placeholder="e.g. Infrastructure"
137
+ type="text"
138
+ value={form.category}
139
+ />
140
+ </label>
141
+ </form>
142
+ </Modal>
143
+ </>
144
+ );
145
+ }
frontend/src/pages/manage/TagsSection.tsx ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { Pencil, Tag as TagIcon, Trash2 } from "lucide-react";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { Link } from "react-router-dom";
5
+
6
+ import { createTag, deleteTag, updateTag } from "../../api/taxonomy";
7
+ import { EmptyState } from "../../components/ui/EmptyState";
8
+ import { Modal } from "../../components/ui/Modal";
9
+ import { toastFromError, useToast } from "../../components/ui/Toast";
10
+ import { useTags } from "../../hooks/usePortalData";
11
+ import type { Tag } from "../../types";
12
+ import { ManagePageHeader, ManageToolbar, RowMenu } from "./_shared";
13
+
14
+ const TAG_PALETTE = ["#7c3aed", "#0ea5e9", "#16a34a", "#f97316", "#dc2626", "#facc15", "#0f766e", "#a855f7"];
15
+
16
+ export function TagsSection() {
17
+ const queryClient = useQueryClient();
18
+ const { push } = useToast();
19
+ const { data: tags = [] } = useTags();
20
+ const [search, setSearch] = useState("");
21
+ const [editing, setEditing] = useState<Tag | null>(null);
22
+ const [creating, setCreating] = useState(false);
23
+
24
+ const invalidate = () => {
25
+ queryClient.invalidateQueries({ queryKey: ["tags"] });
26
+ queryClient.invalidateQueries({ queryKey: ["people"] });
27
+ queryClient.invalidateQueries({ queryKey: ["projects"] });
28
+ };
29
+
30
+ const createMutation = useMutation({
31
+ mutationFn: createTag,
32
+ onSuccess: () => {
33
+ push("Tag added", "success");
34
+ invalidate();
35
+ setCreating(false);
36
+ },
37
+ onError: (error) => push(toastFromError(error, "Could not add tag"), "error"),
38
+ });
39
+
40
+ const updateMutation = useMutation({
41
+ mutationFn: ({ id, input }: { id: number; input: { name: string; color: string } }) => updateTag(id, input),
42
+ onSuccess: () => {
43
+ push("Tag updated", "success");
44
+ invalidate();
45
+ setEditing(null);
46
+ },
47
+ onError: (error) => push(toastFromError(error, "Could not update tag"), "error"),
48
+ });
49
+
50
+ const deleteMutation = useMutation({
51
+ mutationFn: deleteTag,
52
+ onSuccess: () => {
53
+ push("Tag removed", "success");
54
+ invalidate();
55
+ },
56
+ onError: (error) => push(toastFromError(error, "Could not remove tag"), "error"),
57
+ });
58
+
59
+ const filtered = useMemo(() => {
60
+ const term = search.trim().toLowerCase();
61
+ if (!term) return tags;
62
+ return tags.filter((tag) => tag.name.toLowerCase().includes(term));
63
+ }, [tags, search]);
64
+
65
+ return (
66
+ <>
67
+ <ManagePageHeader
68
+ icon={TagIcon}
69
+ title="Tags"
70
+ subtitle='Colored labels for people and projects — e.g. "remote", "on-call".'
71
+ actions={
72
+ <button className="primary-button manage-toolbar-new" onClick={() => setCreating(true)} type="button">
73
+ + New Tag
74
+ </button>
75
+ }
76
+ />
77
+
78
+ <article className="manage-card">
79
+ <ManageToolbar onSearchChange={setSearch} search={search} />
80
+
81
+ {tags.length === 0 ? (
82
+ <EmptyState title="No tags yet" description="Create your first tag and apply it to people or projects." />
83
+ ) : (
84
+ <table className="data-table runn-table">
85
+ <thead>
86
+ <tr>
87
+ <th>Name</th>
88
+ <th className="num">Active People</th>
89
+ <th className="num">Active Projects</th>
90
+ <th aria-label="Actions" className="row-actions" />
91
+ </tr>
92
+ </thead>
93
+ <tbody>
94
+ {filtered.map((tag) => (
95
+ <tr key={tag.id}>
96
+ <td>
97
+ <button className="link-text tag-name-cell" onClick={() => setEditing(tag)} type="button">
98
+ <span className="tag-dot" style={{ background: tag.color }} />
99
+ {tag.name}
100
+ </button>
101
+ </td>
102
+ <td className="num">
103
+ <CountLink count={tag.active_people_count} to={`/people?tag=${tag.id}`} />
104
+ </td>
105
+ <td className="num">
106
+ <CountLink count={tag.active_projects_count} to={`/projects?tag=${tag.id}`} />
107
+ </td>
108
+ <td className="row-actions">
109
+ <RowMenu
110
+ items={[
111
+ { label: "Edit Details", icon: Pencil, onClick: () => setEditing(tag) },
112
+ {
113
+ label: "Delete",
114
+ icon: Trash2,
115
+ danger: true,
116
+ onClick: () => {
117
+ if (window.confirm(`Remove tag "${tag.name}"?`)) {
118
+ deleteMutation.mutate(tag.id);
119
+ }
120
+ },
121
+ },
122
+ ]}
123
+ />
124
+ </td>
125
+ </tr>
126
+ ))}
127
+ </tbody>
128
+ </table>
129
+ )}
130
+ </article>
131
+
132
+ <TagModal
133
+ open={creating}
134
+ title="New tag"
135
+ initial={{ name: "", color: TAG_PALETTE[0] }}
136
+ onClose={() => setCreating(false)}
137
+ onSave={(input) => createMutation.mutate(input)}
138
+ isSaving={createMutation.isPending}
139
+ />
140
+ <TagModal
141
+ open={!!editing}
142
+ title="Edit tag"
143
+ initial={{ name: editing?.name ?? "", color: editing?.color ?? TAG_PALETTE[0] }}
144
+ onClose={() => setEditing(null)}
145
+ onSave={(input) => editing && updateMutation.mutate({ id: editing.id, input })}
146
+ isSaving={updateMutation.isPending}
147
+ />
148
+ </>
149
+ );
150
+ }
151
+
152
+ function TagModal({
153
+ open,
154
+ title,
155
+ initial,
156
+ onClose,
157
+ onSave,
158
+ isSaving,
159
+ }: {
160
+ open: boolean;
161
+ title: string;
162
+ initial: { name: string; color: string };
163
+ onClose: () => void;
164
+ onSave: (input: { name: string; color: string }) => void;
165
+ isSaving: boolean;
166
+ }) {
167
+ const [form, setForm] = useState(initial);
168
+ useEffect(() => {
169
+ if (open) setForm(initial);
170
+ }, [open, initial]);
171
+
172
+ return (
173
+ <Modal
174
+ open={open}
175
+ onClose={onClose}
176
+ title={title}
177
+ footer={
178
+ <>
179
+ <button className="ghost-button" onClick={onClose} type="button">
180
+ Cancel
181
+ </button>
182
+ <button
183
+ className="primary-button"
184
+ disabled={isSaving || !form.name.trim()}
185
+ onClick={() => onSave({ name: form.name.trim(), color: form.color })}
186
+ type="button"
187
+ >
188
+ Save
189
+ </button>
190
+ </>
191
+ }
192
+ >
193
+ <div className="form-grid">
194
+ <label className="span-2">
195
+ Tag name
196
+ <input
197
+ autoFocus
198
+ onChange={(event) => setForm({ ...form, name: event.target.value })}
199
+ placeholder="e.g. on-call"
200
+ type="text"
201
+ value={form.name}
202
+ />
203
+ </label>
204
+ <label className="span-2">
205
+ Color
206
+ <div className="color-picker">
207
+ {TAG_PALETTE.map((color) => (
208
+ <button
209
+ aria-label={`Use color ${color}`}
210
+ className={`color-swatch ${form.color === color ? "selected" : ""}`}
211
+ key={color}
212
+ onClick={() => setForm({ ...form, color })}
213
+ style={{ background: color }}
214
+ type="button"
215
+ />
216
+ ))}
217
+ </div>
218
+ </label>
219
+ </div>
220
+ </Modal>
221
+ );
222
+ }
223
+
224
+ function CountLink({ count, to }: { count: number; to: string }) {
225
+ if (!count) return <span className="count-static count-zero">0</span>;
226
+ return (
227
+ <Link className="count-link" to={to}>
228
+ {count}
229
+ </Link>
230
+ );
231
+ }
frontend/src/pages/manage/TeamsSection.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { Pencil, Trash2, Users } from "lucide-react";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { Link } from "react-router-dom";
5
+
6
+ import { createTeam, deleteTeam, updateTeam } from "../../api/taxonomy";
7
+ import { EmptyState } from "../../components/ui/EmptyState";
8
+ import { Modal } from "../../components/ui/Modal";
9
+ import { toastFromError, useToast } from "../../components/ui/Toast";
10
+ import { useTeams } from "../../hooks/usePortalData";
11
+ import type { Team } from "../../types";
12
+ import { ManagePageHeader, ManageToolbar, RowMenu } from "./_shared";
13
+
14
+ export function TeamsSection() {
15
+ const queryClient = useQueryClient();
16
+ const { push } = useToast();
17
+ const { data: teams = [] } = useTeams();
18
+ const [search, setSearch] = useState("");
19
+ const [editing, setEditing] = useState<Team | null>(null);
20
+ const [creating, setCreating] = useState(false);
21
+
22
+ const invalidate = () => {
23
+ queryClient.invalidateQueries({ queryKey: ["teams"] });
24
+ queryClient.invalidateQueries({ queryKey: ["people"] });
25
+ };
26
+
27
+ const createMutation = useMutation({
28
+ mutationFn: createTeam,
29
+ onSuccess: () => {
30
+ push("Team added", "success");
31
+ invalidate();
32
+ setCreating(false);
33
+ },
34
+ onError: (error) => push(toastFromError(error, "Could not add team"), "error"),
35
+ });
36
+
37
+ const updateMutation = useMutation({
38
+ mutationFn: ({ id, input }: { id: number; input: { name: string } }) => updateTeam(id, input),
39
+ onSuccess: () => {
40
+ push("Team updated", "success");
41
+ invalidate();
42
+ setEditing(null);
43
+ },
44
+ onError: (error) => push(toastFromError(error, "Could not update team"), "error"),
45
+ });
46
+
47
+ const deleteMutation = useMutation({
48
+ mutationFn: deleteTeam,
49
+ onSuccess: () => {
50
+ push("Team removed", "success");
51
+ invalidate();
52
+ },
53
+ onError: (error) => push(toastFromError(error, "Could not remove team"), "error"),
54
+ });
55
+
56
+ const filtered = useMemo(() => {
57
+ const term = search.trim().toLowerCase();
58
+ if (!term) return teams;
59
+ return teams.filter((team) => team.name.toLowerCase().includes(term));
60
+ }, [teams, search]);
61
+
62
+ return (
63
+ <>
64
+ <ManagePageHeader
65
+ icon={Users}
66
+ title="Teams"
67
+ subtitle='Groupings of people across the IT org — e.g. "Platform", "Helpdesk".'
68
+ actions={
69
+ <button className="primary-button manage-toolbar-new" onClick={() => setCreating(true)} type="button">
70
+ + New Team
71
+ </button>
72
+ }
73
+ />
74
+
75
+ <article className="manage-card">
76
+ <ManageToolbar onSearchChange={setSearch} search={search} />
77
+
78
+ {teams.length === 0 ? (
79
+ <EmptyState title="No teams yet" description="Create a team to group people by department or function." />
80
+ ) : (
81
+ <table className="data-table runn-table">
82
+ <thead>
83
+ <tr>
84
+ <th>Name</th>
85
+ <th className="num">Active People</th>
86
+ <th className="num">Active Projects</th>
87
+ <th aria-label="Actions" className="row-actions" />
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ {filtered.map((team) => (
92
+ <tr key={team.id}>
93
+ <td>
94
+ <button className="link-text" onClick={() => setEditing(team)} type="button">
95
+ {team.name}
96
+ </button>
97
+ </td>
98
+ <td className="num">
99
+ <CountLink count={team.active_people_count} to={`/people?team=${team.id}`} />
100
+ </td>
101
+ <td className="num">
102
+ <span className="count-static">{team.active_projects_count}</span>
103
+ </td>
104
+ <td className="row-actions">
105
+ <RowMenu
106
+ items={[
107
+ { label: "Edit Details", icon: Pencil, onClick: () => setEditing(team) },
108
+ {
109
+ label: "Delete",
110
+ icon: Trash2,
111
+ danger: true,
112
+ onClick: () => {
113
+ if (window.confirm(`Remove team "${team.name}"?`)) {
114
+ deleteMutation.mutate(team.id);
115
+ }
116
+ },
117
+ },
118
+ ]}
119
+ />
120
+ </td>
121
+ </tr>
122
+ ))}
123
+ </tbody>
124
+ </table>
125
+ )}
126
+ </article>
127
+
128
+ <TeamModal
129
+ open={creating}
130
+ title="New team"
131
+ initial=""
132
+ onClose={() => setCreating(false)}
133
+ onSave={(name) => createMutation.mutate({ name })}
134
+ isSaving={createMutation.isPending}
135
+ />
136
+ <TeamModal
137
+ open={!!editing}
138
+ title="Edit team"
139
+ initial={editing?.name ?? ""}
140
+ onClose={() => setEditing(null)}
141
+ onSave={(name) => editing && updateMutation.mutate({ id: editing.id, input: { name } })}
142
+ isSaving={updateMutation.isPending}
143
+ />
144
+ </>
145
+ );
146
+ }
147
+
148
+ function TeamModal({
149
+ open,
150
+ title,
151
+ initial,
152
+ onClose,
153
+ onSave,
154
+ isSaving,
155
+ }: {
156
+ open: boolean;
157
+ title: string;
158
+ initial: string;
159
+ onClose: () => void;
160
+ onSave: (name: string) => void;
161
+ isSaving: boolean;
162
+ }) {
163
+ const [name, setName] = useState("");
164
+ useEffect(() => {
165
+ if (open) setName(initial);
166
+ }, [open, initial]);
167
+
168
+ return (
169
+ <Modal
170
+ open={open}
171
+ onClose={onClose}
172
+ title={title}
173
+ footer={
174
+ <>
175
+ <button className="ghost-button" onClick={onClose} type="button">
176
+ Cancel
177
+ </button>
178
+ <button
179
+ className="primary-button"
180
+ disabled={isSaving || !name.trim()}
181
+ onClick={() => onSave(name.trim())}
182
+ type="button"
183
+ >
184
+ Save
185
+ </button>
186
+ </>
187
+ }
188
+ >
189
+ <div className="form-grid">
190
+ <label className="span-2">
191
+ Team name
192
+ <input
193
+ autoFocus
194
+ onChange={(event) => setName(event.target.value)}
195
+ placeholder="e.g. Platform"
196
+ type="text"
197
+ value={name}
198
+ />
199
+ </label>
200
+ </div>
201
+ </Modal>
202
+ );
203
+ }
204
+
205
+ function CountLink({ count, to }: { count: number; to: string }) {
206
+ if (!count) return <span className="count-static count-zero">0</span>;
207
+ return (
208
+ <Link className="count-link" to={to}>
209
+ {count}
210
+ </Link>
211
+ );
212
+ }
frontend/src/pages/manage/UsersSection.tsx ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
2
+ import { Shield } from "lucide-react";
3
+ import { useMemo, useState } from "react";
4
+
5
+ import { createUser } from "../../api/users";
6
+ import { useAuth } from "../../auth";
7
+ import { EmptyState } from "../../components/ui/EmptyState";
8
+ import { Modal } from "../../components/ui/Modal";
9
+ import { toastFromError, useToast } from "../../components/ui/Toast";
10
+ import { useUsers } from "../../hooks/usePortalData";
11
+ import { ManagePageHeader, ManageToolbar } from "./_shared";
12
+
13
+ export function UsersSection() {
14
+ const queryClient = useQueryClient();
15
+ const { user } = useAuth();
16
+ const { push } = useToast();
17
+ const { data: users = [] } = useUsers();
18
+ const [search, setSearch] = useState("");
19
+ const [creating, setCreating] = useState(false);
20
+ const [form, setForm] = useState({ username: "", password: "", role: "viewer" });
21
+ const isAdmin = user?.role === "admin";
22
+
23
+ const createMutation = useMutation({
24
+ mutationFn: createUser,
25
+ onSuccess: () => {
26
+ push("User created", "success");
27
+ setForm({ username: "", password: "", role: "viewer" });
28
+ setCreating(false);
29
+ queryClient.invalidateQueries({ queryKey: ["users"] });
30
+ },
31
+ onError: (error) => push(toastFromError(error, "Could not create user"), "error"),
32
+ });
33
+
34
+ const filtered = useMemo(() => {
35
+ const term = search.trim().toLowerCase();
36
+ if (!term) return users;
37
+ return users.filter(
38
+ (portalUser) =>
39
+ portalUser.username.toLowerCase().includes(term) || portalUser.role.toLowerCase().includes(term),
40
+ );
41
+ }, [users, search]);
42
+
43
+ if (!isAdmin) {
44
+ return (
45
+ <>
46
+ <ManagePageHeader icon={Shield} title="Users" subtitle="Admins, managers, and viewers who can sign in." />
47
+ <article className="manage-card">
48
+ <EmptyState title="Admins only" description="You need the admin role to manage portal users." />
49
+ </article>
50
+ </>
51
+ );
52
+ }
53
+
54
+ return (
55
+ <>
56
+ <ManagePageHeader
57
+ icon={Shield}
58
+ title="Users"
59
+ subtitle="Admins, managers, and viewers who can sign in to the portal."
60
+ actions={
61
+ <button
62
+ className="primary-button manage-toolbar-new"
63
+ onClick={() => {
64
+ setForm({ username: "", password: "", role: "viewer" });
65
+ setCreating(true);
66
+ }}
67
+ type="button"
68
+ >
69
+ + New User
70
+ </button>
71
+ }
72
+ />
73
+
74
+ <article className="manage-card">
75
+ <ManageToolbar onSearchChange={setSearch} search={search} />
76
+
77
+ {filtered.length === 0 ? (
78
+ <EmptyState title={search ? "No matches" : "No users yet"} />
79
+ ) : (
80
+ <table className="data-table runn-table">
81
+ <thead>
82
+ <tr>
83
+ <th>Username</th>
84
+ <th>Role</th>
85
+ <th>Created</th>
86
+ </tr>
87
+ </thead>
88
+ <tbody>
89
+ {filtered.map((portalUser) => (
90
+ <tr key={portalUser.id}>
91
+ <td>
92
+ <strong>{portalUser.username}</strong>
93
+ </td>
94
+ <td>
95
+ <span className="badge muted">{portalUser.role}</span>
96
+ </td>
97
+ <td>{portalUser.created_at.slice(0, 10)}</td>
98
+ </tr>
99
+ ))}
100
+ </tbody>
101
+ </table>
102
+ )}
103
+ </article>
104
+
105
+ <Modal
106
+ open={creating}
107
+ onClose={() => setCreating(false)}
108
+ title="New user"
109
+ footer={
110
+ <>
111
+ <button className="ghost-button" onClick={() => setCreating(false)} type="button">
112
+ Cancel
113
+ </button>
114
+ <button
115
+ className="primary-button"
116
+ disabled={createMutation.isPending || !form.username || form.password.length < 4}
117
+ onClick={() => createMutation.mutate(form)}
118
+ type="button"
119
+ >
120
+ Create user
121
+ </button>
122
+ </>
123
+ }
124
+ >
125
+ <div className="form-grid">
126
+ <label>
127
+ Username
128
+ <input
129
+ autoFocus
130
+ onChange={(event) => setForm({ ...form, username: event.target.value })}
131
+ type="text"
132
+ value={form.username}
133
+ />
134
+ </label>
135
+ <label>
136
+ Password
137
+ <input
138
+ minLength={4}
139
+ onChange={(event) => setForm({ ...form, password: event.target.value })}
140
+ type="password"
141
+ value={form.password}
142
+ />
143
+ </label>
144
+ <label>
145
+ Role
146
+ <select
147
+ onChange={(event) => setForm({ ...form, role: event.target.value })}
148
+ value={form.role}
149
+ >
150
+ <option value="viewer">Viewer</option>
151
+ <option value="manager">Manager</option>
152
+ <option value="admin">Admin</option>
153
+ </select>
154
+ </label>
155
+ </div>
156
+ </Modal>
157
+ </>
158
+ );
159
+ }
frontend/src/pages/manage/_shared.tsx ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MoreVertical, Plus, Search } from "lucide-react";
2
+ import { useEffect, useRef, useState, type ComponentType, type ReactNode } from "react";
3
+
4
+ export interface RowMenuItem {
5
+ label: string;
6
+ icon?: ComponentType<{ size?: number }>;
7
+ onClick: () => void;
8
+ danger?: boolean;
9
+ }
10
+
11
+ export function RowMenu({ items }: { items: RowMenuItem[] }) {
12
+ const [open, setOpen] = useState(false);
13
+ const ref = useRef<HTMLDivElement>(null);
14
+
15
+ useEffect(() => {
16
+ if (!open) return;
17
+ const handler = (event: MouseEvent) => {
18
+ if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
19
+ };
20
+ document.addEventListener("mousedown", handler);
21
+ return () => document.removeEventListener("mousedown", handler);
22
+ }, [open]);
23
+
24
+ return (
25
+ <div className="row-menu" ref={ref}>
26
+ <button
27
+ aria-haspopup="menu"
28
+ aria-expanded={open}
29
+ className="row-menu-trigger"
30
+ onClick={() => setOpen((v) => !v)}
31
+ type="button"
32
+ >
33
+ <MoreVertical size={16} />
34
+ </button>
35
+ {open ? (
36
+ <div className="row-menu-popover" role="menu">
37
+ {items.map((item) => (
38
+ <button
39
+ className={`row-menu-item${item.danger ? " danger" : ""}`}
40
+ key={item.label}
41
+ onClick={() => {
42
+ setOpen(false);
43
+ item.onClick();
44
+ }}
45
+ role="menuitem"
46
+ type="button"
47
+ >
48
+ {item.icon ? <item.icon size={15} /> : null}
49
+ <span>{item.label}</span>
50
+ </button>
51
+ ))}
52
+ </div>
53
+ ) : null}
54
+ </div>
55
+ );
56
+ }
57
+
58
+ interface ManageToolbarProps {
59
+ search: string;
60
+ onSearchChange: (value: string) => void;
61
+ onNewClick?: () => void;
62
+ newLabel?: string;
63
+ newButton?: ReactNode;
64
+ searchPlaceholder?: string;
65
+ trailing?: ReactNode;
66
+ }
67
+
68
+ export function ManageToolbar({
69
+ search,
70
+ onSearchChange,
71
+ onNewClick,
72
+ newLabel,
73
+ newButton,
74
+ searchPlaceholder,
75
+ trailing,
76
+ }: ManageToolbarProps) {
77
+ return (
78
+ <div className="manage-toolbar">
79
+ <div className="manage-toolbar-search">
80
+ <Search size={15} className="manage-toolbar-search-icon" />
81
+ <input
82
+ onChange={(event) => onSearchChange(event.target.value)}
83
+ placeholder={searchPlaceholder ?? "Search"}
84
+ type="search"
85
+ value={search}
86
+ />
87
+ </div>
88
+ <div className="manage-toolbar-trailing">
89
+ {trailing}
90
+ {newButton}
91
+ {newLabel && onNewClick ? (
92
+ <button className="primary-button manage-toolbar-new" onClick={onNewClick} type="button">
93
+ <Plus size={15} />
94
+ {newLabel}
95
+ </button>
96
+ ) : null}
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ interface ManagePageHeaderProps {
103
+ icon: ComponentType<{ size?: number }>;
104
+ title: string;
105
+ subtitle?: string;
106
+ actions?: ReactNode;
107
+ }
108
+
109
+ export function ManagePageHeader({ icon: Icon, title, subtitle, actions }: ManagePageHeaderProps) {
110
+ return (
111
+ <header className="manage-header">
112
+ <div className="manage-header-title">
113
+ <div className="manage-header-icon">
114
+ <Icon size={20} />
115
+ </div>
116
+ <div>
117
+ <h2>{title}</h2>
118
+ {subtitle ? <p>{subtitle}</p> : null}
119
+ </div>
120
+ </div>
121
+ {actions ? <div className="manage-header-actions">{actions}</div> : null}
122
+ </header>
123
+ );
124
+ }
frontend/src/styles.css CHANGED
@@ -1936,3 +1936,175 @@ th {
1936
  gap: 8px;
1937
  margin-top: 14px;
1938
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1936
  gap: 8px;
1937
  margin-top: 14px;
1938
  }
1939
+
1940
+ /* Runn-style page header with icon circle */
1941
+ .manage-header {
1942
+ align-items: center;
1943
+ background: white;
1944
+ border-radius: 16px;
1945
+ border: 1px solid var(--line);
1946
+ display: flex;
1947
+ flex-wrap: wrap;
1948
+ gap: 14px;
1949
+ justify-content: space-between;
1950
+ margin-bottom: 16px;
1951
+ padding: 18px 22px;
1952
+ }
1953
+
1954
+ .manage-header-title {
1955
+ align-items: center;
1956
+ display: flex;
1957
+ gap: 14px;
1958
+ min-width: 0;
1959
+ }
1960
+
1961
+ .manage-header-icon {
1962
+ align-items: center;
1963
+ background: var(--primary-soft);
1964
+ border-radius: 999px;
1965
+ color: var(--primary);
1966
+ display: flex;
1967
+ flex-shrink: 0;
1968
+ height: 44px;
1969
+ justify-content: center;
1970
+ width: 44px;
1971
+ }
1972
+
1973
+ .manage-header-title h2 {
1974
+ font-size: 22px;
1975
+ letter-spacing: -0.01em;
1976
+ margin: 0;
1977
+ }
1978
+
1979
+ .manage-header-title p {
1980
+ color: var(--muted);
1981
+ font-size: 13px;
1982
+ margin: 4px 0 0;
1983
+ }
1984
+
1985
+ .manage-header-actions {
1986
+ align-items: center;
1987
+ display: flex;
1988
+ flex-wrap: wrap;
1989
+ gap: 10px;
1990
+ }
1991
+
1992
+ .manage-toolbar-trailing {
1993
+ align-items: center;
1994
+ display: flex;
1995
+ gap: 10px;
1996
+ }
1997
+
1998
+ /* Compact "Filter [Active]" pill */
1999
+ .filter-pill {
2000
+ align-items: center;
2001
+ background: white;
2002
+ border-radius: 8px;
2003
+ border: 1px solid var(--line);
2004
+ color: var(--muted);
2005
+ display: inline-flex;
2006
+ font-size: 12px;
2007
+ font-weight: 600;
2008
+ gap: 8px;
2009
+ padding: 0 10px 0 12px;
2010
+ text-transform: uppercase;
2011
+ letter-spacing: 0.05em;
2012
+ }
2013
+
2014
+ .filter-pill label {
2015
+ cursor: pointer;
2016
+ }
2017
+
2018
+ .filter-pill select {
2019
+ background: transparent;
2020
+ border: none;
2021
+ color: var(--text);
2022
+ cursor: pointer;
2023
+ font-family: inherit;
2024
+ font-size: 13px;
2025
+ font-weight: 600;
2026
+ padding: 8px 4px;
2027
+ text-transform: none;
2028
+ letter-spacing: normal;
2029
+ }
2030
+
2031
+ .filter-pill select:focus {
2032
+ outline: none;
2033
+ }
2034
+
2035
+ /* Cell with avatar + name */
2036
+ .cell-person {
2037
+ align-items: center;
2038
+ display: flex;
2039
+ gap: 10px;
2040
+ }
2041
+
2042
+ .cell-project {
2043
+ align-items: center;
2044
+ display: flex;
2045
+ gap: 10px;
2046
+ }
2047
+
2048
+ .project-swatch {
2049
+ border-radius: 6px;
2050
+ display: inline-block;
2051
+ flex-shrink: 0;
2052
+ height: 18px;
2053
+ width: 18px;
2054
+ }
2055
+
2056
+ .cell-tags {
2057
+ align-items: center;
2058
+ display: flex;
2059
+ flex-wrap: wrap;
2060
+ gap: 4px;
2061
+ }
2062
+
2063
+ .tag-pill {
2064
+ border-radius: 999px;
2065
+ font-size: 12px;
2066
+ font-weight: 600;
2067
+ padding: 3px 9px;
2068
+ }
2069
+
2070
+ .row-archived td {
2071
+ opacity: 0.6;
2072
+ }
2073
+
2074
+ .row-archived .link-text {
2075
+ text-decoration: line-through;
2076
+ }
2077
+
2078
+ /* Status badges */
2079
+ .badge.status-planning { background: rgba(124, 58, 237, 0.12); color: #6d28d9; border-color: transparent; }
2080
+ .badge.status-active { background: rgba(34, 197, 94, 0.14); color: #15803d; border-color: transparent; }
2081
+ .badge.status-on_hold { background: rgba(245, 158, 11, 0.15); color: #b45309; border-color: transparent; }
2082
+ .badge.status-completed { background: rgba(100, 116, 139, 0.18); color: #475569; border-color: transparent; }
2083
+
2084
+ /* Hub Details button */
2085
+ .hub-details-btn {
2086
+ border-radius: 999px;
2087
+ font-size: 12px;
2088
+ padding: 6px 14px;
2089
+ }
2090
+
2091
+ /* Tag picker in modal forms */
2092
+ .tag-picker {
2093
+ display: flex;
2094
+ flex-wrap: wrap;
2095
+ gap: 6px;
2096
+ margin-top: 4px;
2097
+ }
2098
+
2099
+ .checkbox-row {
2100
+ align-items: center;
2101
+ display: flex;
2102
+ flex-direction: row;
2103
+ gap: 10px;
2104
+ }
2105
+
2106
+ .checkbox-row input {
2107
+ height: auto;
2108
+ margin: 0;
2109
+ width: auto;
2110
+ }