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

Match Runn-style Manage tables for Roles, Teams, Tags

Browse files

- Backend now returns active_people_count / active_projects_count for
every role, team, and tag so the Manage tables can show usage at a
glance.
- Manage page redesigned with a toolbar (search + "New" pill button),
Runn-style table columns, and a three-dot row menu offering
"Edit Details" and "Delete".
- Counts deep-link to People / Projects with the relevant filter
pre-applied; clear-filter chip is shown when a scoped filter is
active.
- Create and edit flows now use modal dialogs instead of permanent
inline forms.

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

backend/routers/taxonomy.py CHANGED
@@ -1,11 +1,11 @@
1
  from fastapi import APIRouter, Depends, HTTPException
2
- from sqlalchemy import select
3
  from sqlalchemy.exc import IntegrityError
4
  from sqlalchemy.orm import Session
5
 
6
  from auth import get_current_user, require_manager
7
  from database import get_db
8
- from models import Person, Role, Tag, Team
9
  from schemas import (
10
  RoleCreate,
11
  RoleRead,
@@ -21,13 +21,81 @@ from schemas import (
21
  router = APIRouter(dependencies=[Depends(get_current_user)])
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  @router.get("/roles", response_model=list[RoleRead])
25
- def list_roles(db: Session = Depends(get_db)) -> list[Role]:
26
- return list(db.scalars(select(Role).order_by(Role.name)))
 
27
 
28
 
29
  @router.post("/roles", response_model=RoleRead, dependencies=[Depends(require_manager)])
30
- def create_role(payload: RoleCreate, db: Session = Depends(get_db)) -> Role:
31
  role = Role(name=payload.name.strip())
32
  db.add(role)
33
  try:
@@ -36,11 +104,11 @@ def create_role(payload: RoleCreate, db: Session = Depends(get_db)) -> Role:
36
  db.rollback()
37
  raise HTTPException(status_code=409, detail="Role name already exists") from exc
38
  db.refresh(role)
39
- return role
40
 
41
 
42
  @router.patch("/roles/{role_id}", response_model=RoleRead, dependencies=[Depends(require_manager)])
43
- def update_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)) -> Role:
44
  role = db.get(Role, role_id)
45
  if not role:
46
  raise HTTPException(status_code=404, detail="Role not found")
@@ -52,7 +120,7 @@ def update_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)
52
  db.rollback()
53
  raise HTTPException(status_code=409, detail="Role name already exists") from exc
54
  db.refresh(role)
55
- return role
56
 
57
 
58
  @router.delete("/roles/{role_id}", dependencies=[Depends(require_manager)])
@@ -69,12 +137,13 @@ def delete_role(role_id: int, db: Session = Depends(get_db)) -> dict:
69
 
70
 
71
  @router.get("/teams", response_model=list[TeamRead])
72
- def list_teams(db: Session = Depends(get_db)) -> list[Team]:
73
- return list(db.scalars(select(Team).order_by(Team.name)))
 
74
 
75
 
76
  @router.post("/teams", response_model=TeamRead, dependencies=[Depends(require_manager)])
77
- def create_team(payload: TeamCreate, db: Session = Depends(get_db)) -> Team:
78
  team = Team(name=payload.name.strip())
79
  db.add(team)
80
  try:
@@ -83,11 +152,11 @@ def create_team(payload: TeamCreate, db: Session = Depends(get_db)) -> Team:
83
  db.rollback()
84
  raise HTTPException(status_code=409, detail="Team name already exists") from exc
85
  db.refresh(team)
86
- return team
87
 
88
 
89
  @router.patch("/teams/{team_id}", response_model=TeamRead, dependencies=[Depends(require_manager)])
90
- def update_team(team_id: int, payload: TeamUpdate, db: Session = Depends(get_db)) -> Team:
91
  team = db.get(Team, team_id)
92
  if not team:
93
  raise HTTPException(status_code=404, detail="Team not found")
@@ -99,7 +168,7 @@ def update_team(team_id: int, payload: TeamUpdate, db: Session = Depends(get_db)
99
  db.rollback()
100
  raise HTTPException(status_code=409, detail="Team name already exists") from exc
101
  db.refresh(team)
102
- return team
103
 
104
 
105
  @router.delete("/teams/{team_id}", dependencies=[Depends(require_manager)])
@@ -119,12 +188,13 @@ def delete_team(team_id: int, db: Session = Depends(get_db)) -> dict:
119
 
120
 
121
  @router.get("/tags", response_model=list[TagRead])
122
- def list_tags(db: Session = Depends(get_db)) -> list[Tag]:
123
- return list(db.scalars(select(Tag).order_by(Tag.name)))
 
124
 
125
 
126
  @router.post("/tags", response_model=TagRead, dependencies=[Depends(require_manager)])
127
- def create_tag(payload: TagCreate, db: Session = Depends(get_db)) -> Tag:
128
  tag = Tag(name=payload.name.strip(), color=payload.color)
129
  db.add(tag)
130
  try:
@@ -133,11 +203,11 @@ def create_tag(payload: TagCreate, db: Session = Depends(get_db)) -> Tag:
133
  db.rollback()
134
  raise HTTPException(status_code=409, detail="Tag name already exists") from exc
135
  db.refresh(tag)
136
- return tag
137
 
138
 
139
  @router.patch("/tags/{tag_id}", response_model=TagRead, dependencies=[Depends(require_manager)])
140
- def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db)) -> Tag:
141
  tag = db.get(Tag, tag_id)
142
  if not tag:
143
  raise HTTPException(status_code=404, detail="Tag not found")
@@ -151,7 +221,7 @@ def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db)) -
151
  db.rollback()
152
  raise HTTPException(status_code=409, detail="Tag name already exists") from exc
153
  db.refresh(tag)
154
- return tag
155
 
156
 
157
  @router.delete("/tags/{tag_id}", dependencies=[Depends(require_manager)])
 
1
  from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy import distinct, func, select
3
  from sqlalchemy.exc import IntegrityError
4
  from sqlalchemy.orm import Session
5
 
6
  from auth import get_current_user, require_manager
7
  from database import get_db
8
+ from models import Allocation, Person, Project, Role, Tag, Team, person_tags, project_tags
9
  from schemas import (
10
  RoleCreate,
11
  RoleRead,
 
21
  router = APIRouter(dependencies=[Depends(get_current_user)])
22
 
23
 
24
+ def _role_payload(role: Role, db: Session) -> dict:
25
+ people_count = db.scalar(
26
+ select(func.count(Person.id)).where(Person.role_id == role.id, Person.is_active.is_(True))
27
+ ) or 0
28
+ projects_count = db.scalar(
29
+ select(func.count(distinct(Allocation.project_id)))
30
+ .join(Person, Person.id == Allocation.person_id)
31
+ .join(Project, Project.id == Allocation.project_id)
32
+ .where(
33
+ Person.role_id == role.id,
34
+ Person.is_active.is_(True),
35
+ Project.is_active.is_(True),
36
+ )
37
+ ) or 0
38
+ return {
39
+ "id": role.id,
40
+ "name": role.name,
41
+ "created_at": role.created_at,
42
+ "active_people_count": int(people_count),
43
+ "active_projects_count": int(projects_count),
44
+ }
45
+
46
+
47
+ def _team_payload(team: Team, db: Session) -> dict:
48
+ people_count = db.scalar(
49
+ select(func.count(Person.id)).where(Person.team_id == team.id, Person.is_active.is_(True))
50
+ ) or 0
51
+ projects_count = db.scalar(
52
+ select(func.count(distinct(Allocation.project_id)))
53
+ .join(Person, Person.id == Allocation.person_id)
54
+ .join(Project, Project.id == Allocation.project_id)
55
+ .where(
56
+ Person.team_id == team.id,
57
+ Person.is_active.is_(True),
58
+ Project.is_active.is_(True),
59
+ )
60
+ ) or 0
61
+ return {
62
+ "id": team.id,
63
+ "name": team.name,
64
+ "created_at": team.created_at,
65
+ "active_people_count": int(people_count),
66
+ "active_projects_count": int(projects_count),
67
+ }
68
+
69
+
70
+ def _tag_payload(tag: Tag, db: Session) -> dict:
71
+ people_count = db.scalar(
72
+ select(func.count(person_tags.c.person_id))
73
+ .join(Person, Person.id == person_tags.c.person_id)
74
+ .where(person_tags.c.tag_id == tag.id, Person.is_active.is_(True))
75
+ ) or 0
76
+ projects_count = db.scalar(
77
+ select(func.count(project_tags.c.project_id))
78
+ .join(Project, Project.id == project_tags.c.project_id)
79
+ .where(project_tags.c.tag_id == tag.id, Project.is_active.is_(True))
80
+ ) or 0
81
+ return {
82
+ "id": tag.id,
83
+ "name": tag.name,
84
+ "color": tag.color,
85
+ "created_at": tag.created_at,
86
+ "active_people_count": int(people_count),
87
+ "active_projects_count": int(projects_count),
88
+ }
89
+
90
+
91
  @router.get("/roles", response_model=list[RoleRead])
92
+ def list_roles(db: Session = Depends(get_db)) -> list[dict]:
93
+ roles = list(db.scalars(select(Role).order_by(Role.name)))
94
+ return [_role_payload(role, db) for role in roles]
95
 
96
 
97
  @router.post("/roles", response_model=RoleRead, dependencies=[Depends(require_manager)])
98
+ def create_role(payload: RoleCreate, db: Session = Depends(get_db)) -> dict:
99
  role = Role(name=payload.name.strip())
100
  db.add(role)
101
  try:
 
104
  db.rollback()
105
  raise HTTPException(status_code=409, detail="Role name already exists") from exc
106
  db.refresh(role)
107
+ return _role_payload(role, db)
108
 
109
 
110
  @router.patch("/roles/{role_id}", response_model=RoleRead, dependencies=[Depends(require_manager)])
111
+ def update_role(role_id: int, payload: RoleUpdate, db: Session = Depends(get_db)) -> dict:
112
  role = db.get(Role, role_id)
113
  if not role:
114
  raise HTTPException(status_code=404, detail="Role not found")
 
120
  db.rollback()
121
  raise HTTPException(status_code=409, detail="Role name already exists") from exc
122
  db.refresh(role)
123
+ return _role_payload(role, db)
124
 
125
 
126
  @router.delete("/roles/{role_id}", dependencies=[Depends(require_manager)])
 
137
 
138
 
139
  @router.get("/teams", response_model=list[TeamRead])
140
+ def list_teams(db: Session = Depends(get_db)) -> list[dict]:
141
+ teams = list(db.scalars(select(Team).order_by(Team.name)))
142
+ return [_team_payload(team, db) for team in teams]
143
 
144
 
145
  @router.post("/teams", response_model=TeamRead, dependencies=[Depends(require_manager)])
146
+ def create_team(payload: TeamCreate, db: Session = Depends(get_db)) -> dict:
147
  team = Team(name=payload.name.strip())
148
  db.add(team)
149
  try:
 
152
  db.rollback()
153
  raise HTTPException(status_code=409, detail="Team name already exists") from exc
154
  db.refresh(team)
155
+ return _team_payload(team, db)
156
 
157
 
158
  @router.patch("/teams/{team_id}", response_model=TeamRead, dependencies=[Depends(require_manager)])
159
+ def update_team(team_id: int, payload: TeamUpdate, db: Session = Depends(get_db)) -> dict:
160
  team = db.get(Team, team_id)
161
  if not team:
162
  raise HTTPException(status_code=404, detail="Team not found")
 
168
  db.rollback()
169
  raise HTTPException(status_code=409, detail="Team name already exists") from exc
170
  db.refresh(team)
171
+ return _team_payload(team, db)
172
 
173
 
174
  @router.delete("/teams/{team_id}", dependencies=[Depends(require_manager)])
 
188
 
189
 
190
  @router.get("/tags", response_model=list[TagRead])
191
+ def list_tags(db: Session = Depends(get_db)) -> list[dict]:
192
+ tags = list(db.scalars(select(Tag).order_by(Tag.name)))
193
+ return [_tag_payload(tag, db) for tag in tags]
194
 
195
 
196
  @router.post("/tags", response_model=TagRead, dependencies=[Depends(require_manager)])
197
+ def create_tag(payload: TagCreate, db: Session = Depends(get_db)) -> dict:
198
  tag = Tag(name=payload.name.strip(), color=payload.color)
199
  db.add(tag)
200
  try:
 
203
  db.rollback()
204
  raise HTTPException(status_code=409, detail="Tag name already exists") from exc
205
  db.refresh(tag)
206
+ return _tag_payload(tag, db)
207
 
208
 
209
  @router.patch("/tags/{tag_id}", response_model=TagRead, dependencies=[Depends(require_manager)])
210
+ def update_tag(tag_id: int, payload: TagUpdate, db: Session = Depends(get_db)) -> dict:
211
  tag = db.get(Tag, tag_id)
212
  if not tag:
213
  raise HTTPException(status_code=404, detail="Tag not found")
 
221
  db.rollback()
222
  raise HTTPException(status_code=409, detail="Tag name already exists") from exc
223
  db.refresh(tag)
224
+ return _tag_payload(tag, db)
225
 
226
 
227
  @router.delete("/tags/{tag_id}", dependencies=[Depends(require_manager)])
backend/schemas.py CHANGED
@@ -40,6 +40,8 @@ class RoleRead(RoleBase):
40
 
41
  id: int
42
  created_at: datetime
 
 
43
 
44
 
45
  class TeamBase(BaseModel):
@@ -59,6 +61,8 @@ class TeamRead(TeamBase):
59
 
60
  id: int
61
  created_at: datetime
 
 
62
 
63
 
64
  class TagBase(BaseModel):
@@ -80,6 +84,8 @@ class TagRead(TagBase):
80
 
81
  id: int
82
  created_at: datetime
 
 
83
 
84
 
85
  class PersonBase(BaseModel):
 
40
 
41
  id: int
42
  created_at: datetime
43
+ active_people_count: int = 0
44
+ active_projects_count: int = 0
45
 
46
 
47
  class TeamBase(BaseModel):
 
61
 
62
  id: int
63
  created_at: datetime
64
+ active_people_count: int = 0
65
+ active_projects_count: int = 0
66
 
67
 
68
  class TagBase(BaseModel):
 
84
 
85
  id: int
86
  created_at: datetime
87
+ active_people_count: int = 0
88
+ active_projects_count: int = 0
89
 
90
 
91
  class PersonBase(BaseModel):
frontend/src/pages/Manage.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useMutation, useQueryClient } from "@tanstack/react-query";
2
- import { useState, type FormEvent } from "react";
3
- import { NavLink, Navigate, useParams } from "react-router-dom";
 
4
 
5
  import { createPublicHoliday } from "../api/leaves";
6
  import { createSkill } from "../api/people";
@@ -11,10 +12,14 @@ import {
11
  deleteRole,
12
  deleteTag,
13
  deleteTeam,
 
 
 
14
  } from "../api/taxonomy";
15
  import { createUser } from "../api/users";
16
  import { useAuth } from "../auth";
17
  import { EmptyState } from "../components/ui/EmptyState";
 
18
  import { toastFromError, useToast } from "../components/ui/Toast";
19
  import {
20
  usePublicHolidays,
@@ -24,6 +29,7 @@ import {
24
  useTeams,
25
  useUsers,
26
  } from "../hooks/usePortalData";
 
27
 
28
  const TAG_PALETTE = ["#7c3aed", "#0ea5e9", "#16a34a", "#f97316", "#dc2626", "#facc15", "#0f766e", "#a855f7"];
29
 
@@ -105,88 +111,297 @@ export function ManagePage() {
105
  );
106
  }
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  function RolesSection() {
109
  const queryClient = useQueryClient();
110
  const { push } = useToast();
111
  const { data: roles = [] } = useRoles();
 
 
 
112
  const [name, setName] = useState("");
113
 
 
 
 
 
 
114
  const createMutation = useMutation({
115
  mutationFn: createRole,
116
  onSuccess: () => {
117
  push("Role added", "success");
 
 
118
  setName("");
119
- queryClient.invalidateQueries({ queryKey: ["roles"] });
120
  },
121
  onError: (error) => push(toastFromError(error, "Could not add role"), "error"),
122
  });
123
 
 
 
 
 
 
 
 
 
 
 
124
  const deleteMutation = useMutation({
125
  mutationFn: deleteRole,
126
  onSuccess: () => {
127
  push("Role removed", "success");
128
- queryClient.invalidateQueries({ queryKey: ["roles"] });
129
  },
130
  onError: (error) => push(toastFromError(error, "Could not remove role"), "error"),
131
  });
132
 
 
 
 
 
 
 
133
  return (
134
- <article className="panel">
 
 
 
 
 
 
 
 
 
 
 
135
  {roles.length === 0 ? (
136
  <EmptyState title="No roles yet" description="Create a role to start assigning team members." />
137
  ) : (
138
- <table className="data-table">
139
  <thead>
140
  <tr>
141
  <th>Name</th>
142
- <th style={{ width: 120, textAlign: "right" }}>Actions</th>
 
 
143
  </tr>
144
  </thead>
145
  <tbody>
146
- {roles.map((role) => (
147
  <tr key={role.id}>
148
- <td><strong>{role.name}</strong></td>
149
- <td style={{ textAlign: "right" }}>
150
  <button
151
- className="ghost-button danger"
152
- onClick={() => {
153
- if (window.confirm(`Remove role "${role.name}"?`)) deleteMutation.mutate(role.id);
154
- }}
155
  type="button"
156
  >
157
- Remove
158
  </button>
159
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  </tr>
161
  ))}
162
  </tbody>
163
  </table>
164
  )}
165
 
166
- <form
167
- className="form-grid manage-inline-form"
168
- onSubmit={(event) => {
169
- event.preventDefault();
170
- if (name.trim()) createMutation.mutate({ name: name.trim() });
171
- }}
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  <label className="span-2">
174
- New role
175
  <input
 
176
  onChange={(event) => setName(event.target.value)}
177
- placeholder="e.g. Senior DevOps Engineer"
178
- required
179
  type="text"
180
  value={name}
181
  />
182
  </label>
183
- <div className="span-2">
184
- <button className="primary-button" disabled={createMutation.isPending} type="submit">
185
- Add role
186
- </button>
187
- </div>
188
- </form>
189
- </article>
190
  );
191
  }
192
 
@@ -194,84 +409,210 @@ function TeamsSection() {
194
  const queryClient = useQueryClient();
195
  const { push } = useToast();
196
  const { data: teams = [] } = useTeams();
 
 
 
197
  const [name, setName] = useState("");
198
 
 
 
 
 
 
199
  const createMutation = useMutation({
200
  mutationFn: createTeam,
201
  onSuccess: () => {
202
  push("Team added", "success");
 
 
203
  setName("");
204
- queryClient.invalidateQueries({ queryKey: ["teams"] });
205
  },
206
  onError: (error) => push(toastFromError(error, "Could not add team"), "error"),
207
  });
208
 
 
 
 
 
 
 
 
 
 
 
209
  const deleteMutation = useMutation({
210
  mutationFn: deleteTeam,
211
  onSuccess: () => {
212
  push("Team removed", "success");
213
- queryClient.invalidateQueries({ queryKey: ["teams"] });
214
  },
215
  onError: (error) => push(toastFromError(error, "Could not remove team"), "error"),
216
  });
217
 
 
 
 
 
 
 
218
  return (
219
- <article className="panel">
 
 
 
 
 
 
 
 
 
 
220
  {teams.length === 0 ? (
221
  <EmptyState title="No teams yet" description="Create a team to group people by department or function." />
222
  ) : (
223
- <table className="data-table">
224
  <thead>
225
  <tr>
226
  <th>Name</th>
227
- <th style={{ width: 120, textAlign: "right" }}>Actions</th>
 
 
228
  </tr>
229
  </thead>
230
  <tbody>
231
- {teams.map((team) => (
232
  <tr key={team.id}>
233
- <td><strong>{team.name}</strong></td>
234
- <td style={{ textAlign: "right" }}>
235
  <button
236
- className="ghost-button danger"
237
- onClick={() => {
238
- if (window.confirm(`Remove team "${team.name}"?`)) deleteMutation.mutate(team.id);
239
- }}
240
  type="button"
241
  >
242
- Remove
243
  </button>
244
  </td>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  </tr>
246
  ))}
247
  </tbody>
248
  </table>
249
  )}
250
 
251
- <form
252
- className="form-grid manage-inline-form"
253
- onSubmit={(event) => {
254
- event.preventDefault();
255
- if (name.trim()) createMutation.mutate({ name: name.trim() });
256
- }}
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  >
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  <label className="span-2">
259
- New team
260
  <input
 
261
  onChange={(event) => setName(event.target.value)}
262
- placeholder="e.g. Platform"
263
- required
264
  type="text"
265
  value={name}
266
  />
267
  </label>
268
- <div className="span-2">
269
- <button className="primary-button" disabled={createMutation.isPending} type="submit">
270
- Add team
271
- </button>
272
- </div>
273
- </form>
274
- </article>
275
  );
276
  }
277
 
@@ -279,93 +620,234 @@ function TagsSection() {
279
  const queryClient = useQueryClient();
280
  const { push } = useToast();
281
  const { data: tags = [] } = useTags();
 
 
 
282
  const [form, setForm] = useState({ name: "", color: TAG_PALETTE[0] });
283
 
 
 
 
 
 
 
284
  const createMutation = useMutation({
285
  mutationFn: createTag,
286
  onSuccess: () => {
287
  push("Tag added", "success");
 
 
288
  setForm({ name: "", color: TAG_PALETTE[0] });
289
- queryClient.invalidateQueries({ queryKey: ["tags"] });
290
  },
291
  onError: (error) => push(toastFromError(error, "Could not add tag"), "error"),
292
  });
293
 
 
 
 
 
 
 
 
 
 
 
294
  const deleteMutation = useMutation({
295
  mutationFn: deleteTag,
296
  onSuccess: () => {
297
  push("Tag removed", "success");
298
- queryClient.invalidateQueries({ queryKey: ["tags"] });
299
  },
300
  onError: (error) => push(toastFromError(error, "Could not remove tag"), "error"),
301
  });
302
 
 
 
 
 
 
 
303
  return (
304
- <article className="panel">
 
 
 
 
 
 
 
 
 
 
305
  {tags.length === 0 ? (
306
  <EmptyState title="No tags yet" description="Create your first tag and apply it to people or projects." />
307
  ) : (
308
- <div className="badge-row" style={{ marginBottom: 18 }}>
309
- {tags.map((tag) => (
310
- <span
311
- key={tag.id}
312
- className="badge tag-chip"
313
- style={{ background: tag.color, borderColor: tag.color, color: "white" }}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  >
315
- {tag.name}
316
- <button
317
- className="tag-remove"
318
- onClick={() => {
319
- if (window.confirm(`Remove tag "${tag.name}"?`)) deleteMutation.mutate(tag.id);
320
- }}
321
- type="button"
322
- >
323
- ×
324
- </button>
325
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  ))}
327
  </div>
328
- )}
 
 
 
329
 
330
- <form
331
- className="form-grid manage-inline-form"
332
- onSubmit={(event) => {
333
- event.preventDefault();
334
- if (form.name.trim()) createMutation.mutate({ name: form.name.trim(), color: form.color });
335
- }}
336
- >
337
- <label>
338
- Tag name
339
- <input
340
- onChange={(event) => setForm({ ...form, name: event.target.value })}
341
- placeholder="e.g. on-call"
342
- required
343
- type="text"
344
- value={form.name}
345
- />
346
- </label>
347
- <label>
348
- Color
349
- <div className="color-picker">
350
- {TAG_PALETTE.map((color) => (
351
- <button
352
- aria-label={`Use color ${color}`}
353
- className={`color-swatch ${form.color === color ? "selected" : ""}`}
354
- key={color}
355
- onClick={() => setForm({ ...form, color })}
356
- style={{ background: color }}
357
- type="button"
358
- />
359
- ))}
360
- </div>
361
- </label>
362
- <div className="span-2">
363
- <button className="primary-button" disabled={createMutation.isPending} type="submit">
364
- Add tag
365
  </button>
366
- </div>
367
- </form>
368
- </article>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  );
370
  }
371
 
 
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";
 
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,
 
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
 
 
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
 
 
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
 
 
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
 
frontend/src/pages/People.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { useMutation, useQueryClient } from "@tanstack/react-query";
2
  import { addMonths, addWeeks, format, startOfMonth, startOfWeek } from "date-fns";
3
  import { useEffect, useMemo, useState, type FormEvent } from "react";
 
4
 
5
  import { createLeave } from "../api/leaves";
6
  import { archivePerson, createPerson, restorePerson, updatePerson } from "../api/people";
@@ -71,6 +72,7 @@ const cellTone = (free: number, capacity: number): CellTone => {
71
  export function PeoplePage() {
72
  const queryClient = useQueryClient();
73
  const { push } = useToast();
 
74
  const [showArchived, setShowArchived] = useState(false);
75
  const { data: people = [], isLoading } = usePeople(showArchived);
76
  const { data: skills = [] } = useSkills();
@@ -78,12 +80,36 @@ export function PeoplePage() {
78
  const { data: teams = [] } = useTeams();
79
  const { data: allTags = [] } = useTags();
80
 
 
 
 
 
 
 
81
  const [search, setSearch] = useState("");
82
  const [groupBy, setGroupBy] = useState<GroupBy>("team");
83
  const [sortBy, setSortBy] = useState<SortBy>("first");
84
  const [showChart, setShowChart] = useState(true);
85
  const [showTentative, setShowTentative] = useState(true);
86
  const [tagFilter, setTagFilter] = useState<Set<number>>(new Set());
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  const [range, setRange] = useState<RangeValue>("month");
88
  const [anchor, setAnchor] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
89
  const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
@@ -113,6 +139,8 @@ export function PeoplePage() {
113
  const filtered = useMemo(() => {
114
  const term = search.trim().toLowerCase();
115
  return people.filter((person) => {
 
 
116
  if (tagFilter.size > 0) {
117
  const personTagIds = new Set(person.tags.map((t) => t.id));
118
  const overlap = Array.from(tagFilter).some((id) => personTagIds.has(id));
@@ -127,7 +155,11 @@ export function PeoplePage() {
127
  person.tags.some((t) => t.name.toLowerCase().includes(term))
128
  );
129
  });
130
- }, [people, search, tagFilter]);
 
 
 
 
131
 
132
  const sorted = useMemo(() => {
133
  const copy = [...filtered];
@@ -474,6 +506,17 @@ export function PeoplePage() {
474
  ) : null}
475
  </div>
476
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
477
  </header>
478
 
479
  {showChart ? (
 
1
  import { useMutation, useQueryClient } from "@tanstack/react-query";
2
  import { addMonths, addWeeks, format, startOfMonth, startOfWeek } from "date-fns";
3
  import { useEffect, useMemo, useState, type FormEvent } from "react";
4
+ import { useSearchParams } from "react-router-dom";
5
 
6
  import { createLeave } from "../api/leaves";
7
  import { archivePerson, createPerson, restorePerson, updatePerson } from "../api/people";
 
72
  export function PeoplePage() {
73
  const queryClient = useQueryClient();
74
  const { push } = useToast();
75
+ const [searchParams, setSearchParams] = useSearchParams();
76
  const [showArchived, setShowArchived] = useState(false);
77
  const { data: people = [], isLoading } = usePeople(showArchived);
78
  const { data: skills = [] } = useSkills();
 
80
  const { data: teams = [] } = useTeams();
81
  const { data: allTags = [] } = useTags();
82
 
83
+ const roleParam = searchParams.get("role");
84
+ const teamParam = searchParams.get("team");
85
+ const tagParam = searchParams.get("tag");
86
+ const roleFilter = roleParam ? Number(roleParam) : null;
87
+ const teamFilter = teamParam ? Number(teamParam) : null;
88
+
89
  const [search, setSearch] = useState("");
90
  const [groupBy, setGroupBy] = useState<GroupBy>("team");
91
  const [sortBy, setSortBy] = useState<SortBy>("first");
92
  const [showChart, setShowChart] = useState(true);
93
  const [showTentative, setShowTentative] = useState(true);
94
  const [tagFilter, setTagFilter] = useState<Set<number>>(new Set());
95
+
96
+ useEffect(() => {
97
+ if (tagParam) {
98
+ const id = Number(tagParam);
99
+ if (!Number.isNaN(id)) {
100
+ setTagFilter((prev) => (prev.has(id) ? prev : new Set([...prev, id])));
101
+ }
102
+ }
103
+ }, [tagParam]);
104
+
105
+ const clearScopedFilters = () => {
106
+ const next = new URLSearchParams(searchParams);
107
+ next.delete("role");
108
+ next.delete("team");
109
+ next.delete("tag");
110
+ setSearchParams(next, { replace: true });
111
+ setTagFilter(new Set());
112
+ };
113
  const [range, setRange] = useState<RangeValue>("month");
114
  const [anchor, setAnchor] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
115
  const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
 
139
  const filtered = useMemo(() => {
140
  const term = search.trim().toLowerCase();
141
  return people.filter((person) => {
142
+ if (roleFilter && person.role.id !== roleFilter) return false;
143
+ if (teamFilter && person.team?.id !== teamFilter) return false;
144
  if (tagFilter.size > 0) {
145
  const personTagIds = new Set(person.tags.map((t) => t.id));
146
  const overlap = Array.from(tagFilter).some((id) => personTagIds.has(id));
 
155
  person.tags.some((t) => t.name.toLowerCase().includes(term))
156
  );
157
  });
158
+ }, [people, search, tagFilter, roleFilter, teamFilter]);
159
+
160
+ const activeRole = roleFilter ? roles.find((r) => r.id === roleFilter) : null;
161
+ const activeTeam = teamFilter ? teams.find((t) => t.id === teamFilter) : null;
162
+ const hasScopedFilter = Boolean(activeRole || activeTeam || tagParam);
163
 
164
  const sorted = useMemo(() => {
165
  const copy = [...filtered];
 
506
  ) : null}
507
  </div>
508
  ) : null}
509
+
510
+ {hasScopedFilter ? (
511
+ <div className="filter-scope-bar">
512
+ <span className="muted-text">Filtered by:</span>
513
+ {activeRole ? <span className="badge muted">Role · {activeRole.name}</span> : null}
514
+ {activeTeam ? <span className="badge muted">Team · {activeTeam.name}</span> : null}
515
+ <button className="ghost-button" onClick={clearScopedFilters} type="button">
516
+ Clear filter
517
+ </button>
518
+ </div>
519
+ ) : null}
520
  </header>
521
 
522
  {showChart ? (
frontend/src/pages/Projects.tsx CHANGED
@@ -6,7 +6,8 @@ import {
6
  parseISO,
7
  startOfMonth,
8
  } from "date-fns";
9
- import { useMemo, useState, type FormEvent } from "react";
 
10
 
11
  import {
12
  archiveProject,
@@ -68,6 +69,17 @@ export function ProjectsPage() {
68
  const [form, setForm] = useState<ProjectInput>(emptyForm);
69
  const [milestoneProject, setMilestoneProject] = useState<Project | null>(null);
70
  const [tagFilter, setTagFilter] = useState<Set<number>>(new Set());
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  const filtered = useMemo(() => {
73
  const term = search.trim().toLowerCase();
 
6
  parseISO,
7
  startOfMonth,
8
  } from "date-fns";
9
+ import { useEffect, useMemo, useState, type FormEvent } from "react";
10
+ import { useSearchParams } from "react-router-dom";
11
 
12
  import {
13
  archiveProject,
 
69
  const [form, setForm] = useState<ProjectInput>(emptyForm);
70
  const [milestoneProject, setMilestoneProject] = useState<Project | null>(null);
71
  const [tagFilter, setTagFilter] = useState<Set<number>>(new Set());
72
+ const [searchParams] = useSearchParams();
73
+ const tagParam = searchParams.get("tag");
74
+
75
+ useEffect(() => {
76
+ if (tagParam) {
77
+ const id = Number(tagParam);
78
+ if (!Number.isNaN(id)) {
79
+ setTagFilter((prev) => (prev.has(id) ? prev : new Set([...prev, id])));
80
+ }
81
+ }
82
+ }, [tagParam]);
83
 
84
  const filtered = useMemo(() => {
85
  const term = search.trim().toLowerCase();
frontend/src/styles.css CHANGED
@@ -1728,3 +1728,211 @@ th {
1728
  .tag-remove:hover {
1729
  opacity: 0.75;
1730
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1728
  .tag-remove:hover {
1729
  opacity: 0.75;
1730
  }
1731
+
1732
+ /* Runn-style Manage tables */
1733
+ .manage-card {
1734
+ background: white;
1735
+ border-radius: 14px;
1736
+ border: 1px solid var(--line);
1737
+ display: grid;
1738
+ overflow: hidden;
1739
+ }
1740
+
1741
+ .manage-toolbar {
1742
+ align-items: center;
1743
+ background: #fafafa;
1744
+ border-bottom: 1px solid var(--line);
1745
+ display: flex;
1746
+ gap: 12px;
1747
+ justify-content: space-between;
1748
+ padding: 14px 18px;
1749
+ }
1750
+
1751
+ .manage-toolbar-search {
1752
+ align-items: center;
1753
+ background: white;
1754
+ border-radius: 8px;
1755
+ border: 1px solid var(--line);
1756
+ display: flex;
1757
+ flex: 1 1 320px;
1758
+ gap: 8px;
1759
+ max-width: 360px;
1760
+ padding: 0 10px;
1761
+ }
1762
+
1763
+ .manage-toolbar-search-icon {
1764
+ color: var(--muted);
1765
+ flex-shrink: 0;
1766
+ }
1767
+
1768
+ .manage-toolbar-search input {
1769
+ background: transparent;
1770
+ border: none;
1771
+ font-family: inherit;
1772
+ font-size: 14px;
1773
+ outline: none;
1774
+ padding: 9px 0;
1775
+ width: 100%;
1776
+ }
1777
+
1778
+ .manage-toolbar-new {
1779
+ align-items: center;
1780
+ border-radius: 999px;
1781
+ display: inline-flex;
1782
+ font-size: 13px;
1783
+ gap: 6px;
1784
+ padding: 9px 16px;
1785
+ }
1786
+
1787
+ .runn-table {
1788
+ border-radius: 0;
1789
+ }
1790
+
1791
+ .runn-table th,
1792
+ .runn-table td {
1793
+ padding: 14px 18px;
1794
+ }
1795
+
1796
+ .runn-table th {
1797
+ background: white;
1798
+ border-bottom: 1px solid var(--line);
1799
+ }
1800
+
1801
+ .runn-table tbody tr:hover {
1802
+ background: rgba(124, 58, 237, 0.04);
1803
+ }
1804
+
1805
+ .runn-table .num {
1806
+ text-align: left;
1807
+ width: 160px;
1808
+ }
1809
+
1810
+ .runn-table th.num {
1811
+ color: var(--muted);
1812
+ }
1813
+
1814
+ .runn-table .row-actions {
1815
+ text-align: right;
1816
+ white-space: nowrap;
1817
+ width: 56px;
1818
+ }
1819
+
1820
+ .link-text {
1821
+ background: transparent;
1822
+ border: none;
1823
+ color: var(--text);
1824
+ cursor: pointer;
1825
+ font-family: inherit;
1826
+ font-size: 14px;
1827
+ font-weight: 600;
1828
+ padding: 0;
1829
+ text-align: left;
1830
+ }
1831
+
1832
+ .link-text:hover {
1833
+ color: var(--primary);
1834
+ text-decoration: underline;
1835
+ }
1836
+
1837
+ .tag-name-cell {
1838
+ align-items: center;
1839
+ display: inline-flex;
1840
+ gap: 8px;
1841
+ }
1842
+
1843
+ .count-link {
1844
+ color: var(--primary);
1845
+ font-weight: 600;
1846
+ text-decoration: underline;
1847
+ text-decoration-color: transparent;
1848
+ text-underline-offset: 3px;
1849
+ }
1850
+
1851
+ .count-link:hover {
1852
+ text-decoration-color: currentColor;
1853
+ }
1854
+
1855
+ .count-static {
1856
+ color: var(--text);
1857
+ font-weight: 600;
1858
+ }
1859
+
1860
+ .count-zero {
1861
+ color: var(--muted);
1862
+ }
1863
+
1864
+ .row-menu {
1865
+ display: inline-block;
1866
+ position: relative;
1867
+ }
1868
+
1869
+ .row-menu-trigger {
1870
+ align-items: center;
1871
+ background: transparent;
1872
+ border-radius: 6px;
1873
+ border: none;
1874
+ color: var(--muted);
1875
+ cursor: pointer;
1876
+ display: inline-flex;
1877
+ height: 30px;
1878
+ justify-content: center;
1879
+ padding: 0;
1880
+ width: 30px;
1881
+ }
1882
+
1883
+ .row-menu-trigger:hover {
1884
+ background: var(--primary-soft);
1885
+ color: var(--primary);
1886
+ }
1887
+
1888
+ .row-menu-popover {
1889
+ background: white;
1890
+ border-radius: 10px;
1891
+ border: 1px solid var(--line);
1892
+ box-shadow: 0 12px 32px rgba(15, 23, 42, 0.14);
1893
+ display: grid;
1894
+ min-width: 170px;
1895
+ padding: 4px;
1896
+ position: absolute;
1897
+ right: 0;
1898
+ top: calc(100% + 4px);
1899
+ z-index: 40;
1900
+ }
1901
+
1902
+ .row-menu-item {
1903
+ align-items: center;
1904
+ background: transparent;
1905
+ border-radius: 6px;
1906
+ border: none;
1907
+ color: var(--text);
1908
+ cursor: pointer;
1909
+ display: flex;
1910
+ font-family: inherit;
1911
+ font-size: 13px;
1912
+ font-weight: 600;
1913
+ gap: 10px;
1914
+ padding: 8px 10px;
1915
+ text-align: left;
1916
+ }
1917
+
1918
+ .row-menu-item:hover {
1919
+ background: var(--primary-soft);
1920
+ color: var(--primary);
1921
+ }
1922
+
1923
+ .row-menu-item.danger {
1924
+ color: var(--danger, #dc2626);
1925
+ }
1926
+
1927
+ .row-menu-item.danger:hover {
1928
+ background: rgba(220, 38, 38, 0.08);
1929
+ color: var(--danger, #dc2626);
1930
+ }
1931
+
1932
+ .filter-scope-bar {
1933
+ align-items: center;
1934
+ display: flex;
1935
+ flex-wrap: wrap;
1936
+ gap: 8px;
1937
+ margin-top: 14px;
1938
+ }
frontend/src/types/index.ts CHANGED
@@ -13,6 +13,8 @@ export interface Role {
13
  id: number;
14
  name: string;
15
  created_at: string;
 
 
16
  }
17
 
18
  export interface RoleInput {
@@ -23,6 +25,8 @@ export interface Team {
23
  id: number;
24
  name: string;
25
  created_at: string;
 
 
26
  }
27
 
28
  export interface TeamInput {
@@ -34,6 +38,8 @@ export interface Tag {
34
  name: string;
35
  color: string;
36
  created_at: string;
 
 
37
  }
38
 
39
  export interface TagInput {
 
13
  id: number;
14
  name: string;
15
  created_at: string;
16
+ active_people_count: number;
17
+ active_projects_count: number;
18
  }
19
 
20
  export interface RoleInput {
 
25
  id: number;
26
  name: string;
27
  created_at: string;
28
+ active_people_count: number;
29
+ active_projects_count: number;
30
  }
31
 
32
  export interface TeamInput {
 
38
  name: string;
39
  color: string;
40
  created_at: string;
41
+ active_people_count: number;
42
+ active_projects_count: number;
43
  }
44
 
45
  export interface TagInput {