Gowrisankar Cursor commited on
Commit
ffae5d8
·
1 Parent(s): b29051a

Add admin sample data loading for testing (Runn-style explore)

Browse files

- seed_demo service wipes portal data (keeps users) and loads a curated IT dataset
- Admin API: GET /admin/seed-demo/status, POST /admin/seed-demo?replace=true
- Insights empty state and Manage → Users panel with Load/Reload sample data
- ENABLE_DEMO_DATA env guard; SPEC.md updated

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

SPEC.md CHANGED
@@ -129,6 +129,20 @@ role; user management requires `admin`.
129
  | GET | `/api/v1/reports/project-allocation?start&end` | Allocations broken out by project |
130
  | GET | `/api/v1/users` | List portal users |
131
  | POST | `/api/v1/users` | Create a user |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
 
133
  ---
134
 
@@ -169,8 +183,8 @@ podman run -d --replace -p 8080:80 -v portal-data:/data --name portal \
169
  ```
170
 
171
  Open `http://localhost:8080` and sign in with the admin credentials. The portal
172
- starts empty — add your IT team from the **People** page and create projects
173
- from the **Projects** page to begin scheduling.
174
 
175
  ---
176
 
 
129
  | GET | `/api/v1/reports/project-allocation?start&end` | Allocations broken out by project |
130
  | GET | `/api/v1/users` | List portal users |
131
  | POST | `/api/v1/users` | Create a user |
132
+ | GET | `/api/v1/admin/seed-demo/status` | Whether portal DB is empty (admin only) |
133
+ | POST | `/api/v1/admin/seed-demo?replace=true` | Wipe portal data (except users) and load sample IT dataset |
134
+
135
+ Set `ENABLE_DEMO_DATA=false` on the office Linux box to hide and disable these endpoints in production.
136
+
137
+ ### Sample / demo data (testing)
138
+
139
+ Admin-only fictional dataset for exploring the portal (similar to Runn’s trial explore flow):
140
+
141
+ - **9 people**, **5 projects**, roles, teams, tags, skills, allocations (including one overallocated person), leaves, and public holidays
142
+ - **Insights** (empty portal): “Load sample data” button with confirmation
143
+ - **Manage → Users**: “Load sample data” / “Reload sample data”
144
+ - **Hugging Face Spaces**: database is ephemeral — reload sample data after each deploy
145
+ - **Office Podman** (`portal-data` volume): data persists; reload only when you choose to reset demo data
146
 
147
  ---
148
 
 
183
  ```
184
 
185
  Open `http://localhost:8080` and sign in with the admin credentials. The portal
186
+ starts empty — use **Insights Load sample data** (admin) or add data manually
187
+ from **Manage → People** and **Manage Projects**.
188
 
189
  ---
190
 
backend/main.py CHANGED
@@ -7,7 +7,7 @@ from sqlalchemy.orm import Session
7
 
8
  from auth import ensure_default_admin, get_current_user
9
  from database import get_db, run_migrations
10
- from routers import allocations, leaves, people, projects, reports, taxonomy, users
11
 
12
 
13
  @asynccontextmanager
@@ -63,3 +63,4 @@ app.include_router(leaves.router)
63
  app.include_router(reports.router)
64
  app.include_router(taxonomy.router)
65
  app.include_router(users.router)
 
 
7
 
8
  from auth import ensure_default_admin, get_current_user
9
  from database import get_db, run_migrations
10
+ from routers import admin, allocations, leaves, people, projects, reports, taxonomy, users
11
 
12
 
13
  @asynccontextmanager
 
63
  app.include_router(reports.router)
64
  app.include_router(taxonomy.router)
65
  app.include_router(users.router)
66
+ app.include_router(admin.router)
backend/routers/admin.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException, Query
4
+ from sqlalchemy import func, select
5
+ from sqlalchemy.orm import Session
6
+
7
+ from auth import require_admin
8
+ from database import get_db
9
+ from models import Person, Project
10
+ from services.seed_demo import clear_portal_data, portal_has_data, seed_demo_data
11
+
12
+ router = APIRouter(prefix="/admin", tags=["admin"])
13
+
14
+
15
+ def _demo_enabled() -> bool:
16
+ return os.getenv("ENABLE_DEMO_DATA", "true").lower() in {"1", "true", "yes"}
17
+
18
+
19
+ def _require_demo_enabled() -> None:
20
+ if not _demo_enabled():
21
+ raise HTTPException(status_code=404, detail="Demo data endpoints are disabled")
22
+
23
+
24
+ @router.get("/seed-demo/status", dependencies=[Depends(require_admin)])
25
+ def demo_data_status(db: Session = Depends(get_db)) -> dict:
26
+ _require_demo_enabled()
27
+ people_count = db.scalar(select(func.count(Person.id))) or 0
28
+ projects_count = db.scalar(select(func.count(Project.id))) or 0
29
+ return {
30
+ "is_empty": people_count == 0 and projects_count == 0,
31
+ "people_count": int(people_count),
32
+ "projects_count": int(projects_count),
33
+ }
34
+
35
+
36
+ @router.post("/seed-demo", dependencies=[Depends(require_admin)])
37
+ def seed_demo(
38
+ replace: bool = Query(False, description="Wipe portal data (except users) before seeding"),
39
+ db: Session = Depends(get_db),
40
+ ) -> dict:
41
+ _require_demo_enabled()
42
+
43
+ if portal_has_data(db) and not replace:
44
+ raise HTTPException(
45
+ status_code=409,
46
+ detail="Portal already has data. Confirm replace=true to wipe and load sample data.",
47
+ )
48
+
49
+ clear_portal_data(db)
50
+ counts = seed_demo_data(db)
51
+ return {"replaced": True, "counts": counts}
backend/services/seed_demo.py ADDED
@@ -0,0 +1,303 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Demo / sample dataset for portal testing (admin-triggered only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date, timedelta
6
+
7
+ from sqlalchemy import delete, func, select
8
+ from sqlalchemy.orm import Session
9
+
10
+ from models import (
11
+ Allocation,
12
+ Leave,
13
+ Milestone,
14
+ Person,
15
+ PersonSkill,
16
+ Project,
17
+ Role,
18
+ Skill,
19
+ Tag,
20
+ Team,
21
+ person_tags,
22
+ project_tags,
23
+ )
24
+
25
+ AVATAR_COLORS = [
26
+ "#7c3aed",
27
+ "#6366f1",
28
+ "#0ea5e9",
29
+ "#16a34a",
30
+ "#f97316",
31
+ "#a855f7",
32
+ "#facc15",
33
+ "#ef4444",
34
+ ]
35
+
36
+
37
+ def portal_has_data(db: Session) -> bool:
38
+ people = db.scalar(select(func.count(Person.id))) or 0
39
+ projects = db.scalar(select(func.count(Project.id))) or 0
40
+ return people > 0 or projects > 0
41
+
42
+
43
+ def clear_portal_data(db: Session) -> None:
44
+ db.execute(delete(Allocation))
45
+ db.execute(delete(Leave))
46
+ db.execute(delete(Milestone))
47
+ db.execute(delete(PersonSkill))
48
+ db.execute(delete(person_tags))
49
+ db.execute(delete(project_tags))
50
+ db.execute(delete(Person))
51
+ db.execute(delete(Project))
52
+ db.execute(delete(Skill))
53
+ db.execute(delete(Tag))
54
+ db.execute(delete(Team))
55
+ db.execute(delete(Role))
56
+ db.flush()
57
+
58
+
59
+ def seed_demo_data(db: Session) -> dict[str, int]:
60
+ today = date.today()
61
+ month_start = today.replace(day=1)
62
+ quarter_end = month_start + timedelta(days=90)
63
+
64
+ roles = {
65
+ name: Role(name=name)
66
+ for name in ("Developer", "DevOps Engineer", "Project Manager")
67
+ }
68
+ db.add_all(roles.values())
69
+ db.flush()
70
+
71
+ teams = {
72
+ name: Team(name=name)
73
+ for name in ("SOM Dev", "RMS Dev", "Platform", "Helpdesk")
74
+ }
75
+ db.add_all(teams.values())
76
+ db.flush()
77
+
78
+ tags = {
79
+ "remote": Tag(name="remote", color="#0ea5e9"),
80
+ "on-call": Tag(name="on-call", color="#f97316"),
81
+ "priority": Tag(name="priority", color="#dc2626"),
82
+ }
83
+ db.add_all(tags.values())
84
+ db.flush()
85
+
86
+ skills = {}
87
+ for name, category in (
88
+ ("ADF", "Data"),
89
+ ("Kubernetes", "Infrastructure"),
90
+ ("React", "Frontend"),
91
+ ("FastAPI", "Backend"),
92
+ ("SAP", "ERP"),
93
+ ("Networking", "Infrastructure"),
94
+ ("Project Management", "Soft skills"),
95
+ ):
96
+ skills[name] = Skill(name=name, category=category)
97
+ db.add_all(skills.values())
98
+ db.flush()
99
+
100
+ people_specs = [
101
+ ("Alex Morgan", "alex.morgan@example.com", "Developer", "SOM Dev", ["remote"], ["ADF", "React"], 40),
102
+ ("Jordan Lee", "jordan.lee@example.com", "Developer", "SOM Dev", ["priority"], ["Kubernetes", "FastAPI"], 40),
103
+ ("Sam Taylor", "sam.taylor@example.com", "DevOps Engineer", "Platform", ["on-call"], ["Kubernetes", "Networking"], 40),
104
+ ("Riley Chen", "riley.chen@example.com", "Developer", "RMS Dev", [], ["React", "FastAPI"], 40),
105
+ ("Morgan Patel", "morgan.patel@example.com", "Developer", "RMS Dev", ["remote"], ["SAP", "ADF"], 40),
106
+ ("Casey Brooks", "casey.brooks@example.com", "Project Manager", "Helpdesk", [], ["Project Management"], 40),
107
+ ("Jamie Ortiz", "jamie.ortiz@example.com", "DevOps Engineer", "Platform", ["on-call", "priority"], ["Networking", "Kubernetes"], 40),
108
+ ("Taylor Kim", "taylor.kim@example.com", "Developer", "RMS Dev", ["remote"], ["React", "ADF"], 40),
109
+ # Intentionally overallocated in demo allocations below
110
+ ("Priya Nair", "priya.nair@example.com", "Developer", "SOM Dev", ["priority"], ["FastAPI", "React"], 40),
111
+ ]
112
+
113
+ people: list[Person] = []
114
+ for index, (name, email, role_name, team_name, tag_names, skill_names, capacity) in enumerate(
115
+ people_specs
116
+ ):
117
+ person = Person(
118
+ name=name,
119
+ email=email,
120
+ role_id=roles[role_name].id,
121
+ team_id=teams[team_name].id,
122
+ weekly_capacity_hrs=capacity,
123
+ start_date=month_start - timedelta(days=60),
124
+ avatar_color=AVATAR_COLORS[index % len(AVATAR_COLORS)],
125
+ )
126
+ person.tags = [tags[t] for t in tag_names]
127
+ db.add(person)
128
+ people.append(person)
129
+
130
+ db.flush()
131
+
132
+ for person, (_, _, _, _, _, skill_names, _) in zip(people, people_specs):
133
+ for skill_name in skill_names:
134
+ db.add(
135
+ PersonSkill(
136
+ person_id=person.id,
137
+ skill_id=skills[skill_name].id,
138
+ proficiency="intermediate",
139
+ )
140
+ )
141
+
142
+ project_specs = [
143
+ (
144
+ "Core Platform Upgrade",
145
+ "infrastructure",
146
+ "active",
147
+ "#0ea5e9",
148
+ ["priority"],
149
+ "Platform hardening and observability rollout.",
150
+ 0,
151
+ ),
152
+ (
153
+ "Internal Portal v2",
154
+ "development",
155
+ "active",
156
+ "#7c3aed",
157
+ ["remote"],
158
+ "Resource portal enhancements for IT planning.",
159
+ 0,
160
+ ),
161
+ (
162
+ "ERP Integration",
163
+ "development",
164
+ "planning",
165
+ "#16a34a",
166
+ [],
167
+ "SAP connector and data sync initiative.",
168
+ 4,
169
+ ),
170
+ (
171
+ "Helpdesk Automation",
172
+ "support",
173
+ "active",
174
+ "#f97316",
175
+ ["on-call"],
176
+ "Ticket routing and self-service improvements.",
177
+ 5,
178
+ ),
179
+ (
180
+ "Network Refresh",
181
+ "infrastructure",
182
+ "on_hold",
183
+ "#0f766e",
184
+ ["priority"],
185
+ "Switching and Wi-Fi refresh for offices.",
186
+ 2,
187
+ ),
188
+ ]
189
+
190
+ projects: list[Project] = []
191
+ for name, ptype, status, color, tag_names, description, owner_index in project_specs:
192
+ project = Project(
193
+ name=name,
194
+ description=description,
195
+ status=status,
196
+ type=ptype,
197
+ start_date=month_start,
198
+ end_date=quarter_end,
199
+ color=color,
200
+ owner_id=people[owner_index].id if owner_index < len(people) else None,
201
+ )
202
+ project.tags = [tags[t] for t in tag_names]
203
+ db.add(project)
204
+ projects.append(project)
205
+
206
+ db.flush()
207
+
208
+ milestone_specs = [
209
+ (0, "Discovery complete", 21, False),
210
+ (0, "Go-live", 75, False),
211
+ (1, "UI parity review", 28, True),
212
+ (1, "Beta release", 56, False),
213
+ (2, "Requirements signed off", 14, False),
214
+ (3, "Pilot launch", 42, False),
215
+ (4, "Vendor selection", 35, False),
216
+ ]
217
+ for project_index, mname, offset_days, completed in milestone_specs:
218
+ db.add(
219
+ Milestone(
220
+ project_id=projects[project_index].id,
221
+ name=mname,
222
+ due_date=month_start + timedelta(days=offset_days),
223
+ is_completed=completed,
224
+ )
225
+ )
226
+
227
+ allocation_specs = [
228
+ (people[0].id, projects[0].id, 50, 0, 56),
229
+ (people[1].id, projects[1].id, 60, 0, 70),
230
+ (people[2].id, projects[0].id, 40, 14, 42),
231
+ (people[3].id, projects[3].id, 70, 0, 84),
232
+ (people[4].id, projects[2].id, 30, 21, 49),
233
+ (people[5].id, projects[1].id, 25, 0, 28),
234
+ (people[6].id, projects[4].id, 55, 7, 63),
235
+ (people[7].id, projects[3].id, 45, 0, 56),
236
+ # Priya: overlapping high allocations for overallocated report
237
+ (people[8].id, projects[0].id, 80, 0, 56),
238
+ (people[8].id, projects[1].id, 70, 0, 56),
239
+ (people[8].id, projects[2].id, 50, 0, 56),
240
+ ]
241
+
242
+ for person_id, project_id, pct, start_offset, end_offset in allocation_specs:
243
+ db.add(
244
+ Allocation(
245
+ person_id=person_id,
246
+ project_id=project_id,
247
+ start_date=month_start + timedelta(days=start_offset),
248
+ end_date=month_start + timedelta(days=end_offset),
249
+ allocation_pct=pct,
250
+ )
251
+ )
252
+
253
+ leave_specs = [
254
+ (people[0].id, "annual", 10, 12, "Short break"),
255
+ (people[3].id, "sick", 5, 5, "Sick day"),
256
+ (people[5].id, "annual", 45, 49, "Planned leave"),
257
+ ]
258
+ for person_id, leave_type, start_off, end_off, note in leave_specs:
259
+ start = month_start + timedelta(days=start_off)
260
+ end = month_start + timedelta(days=end_off)
261
+ db.add(
262
+ Leave(
263
+ person_id=person_id,
264
+ leave_type=leave_type,
265
+ start_date=start,
266
+ end_date=end,
267
+ note=note,
268
+ approved=True,
269
+ )
270
+ )
271
+
272
+ holiday_specs = [
273
+ ("New Year", 1, 1),
274
+ ("Company Day", 45, 45),
275
+ ("Summer shutdown", 120, 121),
276
+ ]
277
+ for note, start_off, end_off in holiday_specs:
278
+ start = month_start + timedelta(days=start_off)
279
+ end = month_start + timedelta(days=end_off)
280
+ db.add(
281
+ Leave(
282
+ person_id=None,
283
+ leave_type="public_holiday",
284
+ start_date=start,
285
+ end_date=end,
286
+ note=note,
287
+ approved=True,
288
+ )
289
+ )
290
+
291
+ db.commit()
292
+
293
+ return {
294
+ "roles": len(roles),
295
+ "teams": len(teams),
296
+ "tags": len(tags),
297
+ "skills": len(skills),
298
+ "people": len(people),
299
+ "projects": len(projects),
300
+ "milestones": len(milestone_specs),
301
+ "allocations": len(allocation_specs),
302
+ "leaves": len(leave_specs) + len(holiday_specs),
303
+ }
frontend/src/api/admin.ts ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { api } from "./client";
2
+
3
+ export interface DemoDataStatus {
4
+ is_empty: boolean;
5
+ people_count: number;
6
+ projects_count: number;
7
+ }
8
+
9
+ export interface DemoSeedResult {
10
+ replaced: boolean;
11
+ counts: {
12
+ roles: number;
13
+ teams: number;
14
+ tags: number;
15
+ skills: number;
16
+ people: number;
17
+ projects: number;
18
+ milestones: number;
19
+ allocations: number;
20
+ leaves: number;
21
+ };
22
+ }
23
+
24
+ export const getDemoDataStatus = async () =>
25
+ (await api.get<DemoDataStatus>("/admin/seed-demo/status")).data;
26
+
27
+ export const seedDemoData = async (replace: boolean) =>
28
+ (await api.post<DemoSeedResult>("/admin/seed-demo", null, { params: { replace } })).data;
frontend/src/hooks/useDemoData.ts ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+
3
+ import { getDemoDataStatus, seedDemoData } from "../api/admin";
4
+ import { toastFromError, useToast } from "../components/ui/Toast";
5
+
6
+ export const DEMO_CONFIRM_MESSAGE =
7
+ "This will delete all people, projects, allocations, roles, teams, tags, skills, and holidays. Portal login users are kept. Continue?";
8
+
9
+ export function useDemoDataStatus(enabled = true) {
10
+ return useQuery({
11
+ queryKey: ["demo-data-status"],
12
+ queryFn: getDemoDataStatus,
13
+ enabled,
14
+ });
15
+ }
16
+
17
+ export function useSeedDemoData() {
18
+ const queryClient = useQueryClient();
19
+ const { push } = useToast();
20
+
21
+ return useMutation({
22
+ mutationFn: (replace: boolean) => seedDemoData(replace),
23
+ onSuccess: (result) => {
24
+ const { counts } = result;
25
+ push(
26
+ `Sample data loaded: ${counts.people} people, ${counts.projects} projects`,
27
+ "success",
28
+ );
29
+ queryClient.invalidateQueries();
30
+ },
31
+ onError: (error) => push(toastFromError(error, "Could not load sample data"), "error"),
32
+ });
33
+ }
34
+
35
+ export function useLoadDemoData(options?: { onSuccess?: () => void }) {
36
+ const seedMutation = useSeedDemoData();
37
+
38
+ const load = (replace = true) => {
39
+ if (!window.confirm(DEMO_CONFIRM_MESSAGE)) return;
40
+ seedMutation.mutate(replace, { onSuccess: options?.onSuccess });
41
+ };
42
+
43
+ return { load, isPending: seedMutation.isPending };
44
+ }
frontend/src/pages/Dashboard.tsx CHANGED
@@ -1,12 +1,18 @@
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>
@@ -93,13 +99,35 @@ export function DashboardPage() {
93
  </>
94
  )}
95
 
96
- {!isLoading && data && data.active_people === 0 && data.active_projects === 0 ? (
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}
105
  </section>
 
1
  import { BarChart3 } from "lucide-react";
2
  import { Link } from "react-router-dom";
3
 
4
+ import { useAuth } from "../auth";
5
  import { EmptyState } from "../components/ui/EmptyState";
6
+ import { useLoadDemoData } from "../hooks/useDemoData";
7
  import { useDashboard } from "../hooks/usePortalData";
8
  import { ManagePageHeader } from "./manage/_shared";
9
 
10
  export function DashboardPage() {
11
+ const { user } = useAuth();
12
  const { data, isLoading, isError } = useDashboard();
13
+ const { load: loadDemoData, isPending: isSeeding } = useLoadDemoData();
14
+ const isAdmin = user?.role === "admin";
15
+ const isEmpty = !isLoading && data && data.active_people === 0 && data.active_projects === 0;
16
 
17
  return (
18
  <section>
 
99
  </>
100
  )}
101
 
102
+ {isEmpty ? (
103
+ <article className="panel banner demo-sample-banner">
104
+ {isAdmin ? (
105
+ <>
106
+ <h3>Explore the portal with sample data</h3>
107
+ <p>
108
+ Load a fictional IT team, projects, allocations, and holidays to try the planner,
109
+ schedule, and reports. Useful after a fresh deploy or on Hugging Face where the database
110
+ resets. Login users are not removed.
111
+ </p>
112
+ <button
113
+ className="primary-button"
114
+ disabled={isSeeding}
115
+ onClick={() => loadDemoData(true)}
116
+ type="button"
117
+ >
118
+ {isSeeding ? "Loading sample data…" : "Load sample data"}
119
+ </button>
120
+ </>
121
+ ) : (
122
+ <>
123
+ <h3>Welcome — your portal is empty.</h3>
124
+ <p>
125
+ Ask an admin to load sample data, or add your IT team in{" "}
126
+ <Link to="/manage/people">Manage → People</Link> and projects in{" "}
127
+ <Link to="/manage/projects">Manage → Projects</Link>.
128
+ </p>
129
+ </>
130
+ )}
131
  </article>
132
  ) : null}
133
  </section>
frontend/src/pages/manage/UsersSection.tsx CHANGED
@@ -7,6 +7,7 @@ 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
 
@@ -19,6 +20,8 @@ export function UsersSection() {
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,
@@ -102,6 +105,27 @@ export function UsersSection() {
102
  )}
103
  </article>
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  <Modal
106
  open={creating}
107
  onClose={() => setCreating(false)}
 
7
  import { EmptyState } from "../../components/ui/EmptyState";
8
  import { Modal } from "../../components/ui/Modal";
9
  import { toastFromError, useToast } from "../../components/ui/Toast";
10
+ import { useDemoDataStatus, useLoadDemoData } from "../../hooks/useDemoData";
11
  import { useUsers } from "../../hooks/usePortalData";
12
  import { ManagePageHeader, ManageToolbar } from "./_shared";
13
 
 
20
  const [creating, setCreating] = useState(false);
21
  const [form, setForm] = useState({ username: "", password: "", role: "viewer" });
22
  const isAdmin = user?.role === "admin";
23
+ const { data: demoStatus } = useDemoDataStatus(isAdmin);
24
+ const { load: loadDemoData, isPending: isSeeding } = useLoadDemoData();
25
 
26
  const createMutation = useMutation({
27
  mutationFn: createUser,
 
105
  )}
106
  </article>
107
 
108
+ <article className="panel demo-sample-panel">
109
+ <h3>Sample data for testing</h3>
110
+ <p className="muted-text">
111
+ Load a demo IT org (people, projects, roles, teams, tags, skills, allocations, holidays).
112
+ Portal login users are kept. On Hugging Face, use this after each deploy when the database is
113
+ empty.
114
+ </p>
115
+ <button
116
+ className="primary-button"
117
+ disabled={isSeeding}
118
+ onClick={() => loadDemoData(true)}
119
+ type="button"
120
+ >
121
+ {isSeeding
122
+ ? "Loading…"
123
+ : demoStatus?.is_empty
124
+ ? "Load sample data"
125
+ : "Reload sample data"}
126
+ </button>
127
+ </article>
128
+
129
  <Modal
130
  open={creating}
131
  onClose={() => setCreating(false)}
frontend/src/styles.css CHANGED
@@ -2124,6 +2124,27 @@ th {
2124
  width: auto;
2125
  }
2126
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2127
  /* Skills page — Runn-style SKILLS / PEOPLE tabs */
2128
  .skills-subnav {
2129
  background: white;
 
2124
  width: auto;
2125
  }
2126
 
2127
+ .demo-sample-banner {
2128
+ display: grid;
2129
+ gap: 12px;
2130
+ }
2131
+
2132
+ .demo-sample-banner .primary-button {
2133
+ justify-self: start;
2134
+ width: auto;
2135
+ }
2136
+
2137
+ .demo-sample-panel {
2138
+ display: grid;
2139
+ gap: 10px;
2140
+ margin-top: 16px;
2141
+ }
2142
+
2143
+ .demo-sample-panel .primary-button {
2144
+ justify-self: start;
2145
+ width: auto;
2146
+ }
2147
+
2148
  /* Skills page — Runn-style SKILLS / PEOPLE tabs */
2149
  .skills-subnav {
2150
  background: white;