Spaces:
Running
Running
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 +16 -2
- backend/main.py +2 -1
- backend/routers/admin.py +51 -0
- backend/services/seed_demo.py +303 -0
- frontend/src/api/admin.ts +28 -0
- frontend/src/hooks/useDemoData.ts +44 -0
- frontend/src/pages/Dashboard.tsx +35 -7
- frontend/src/pages/manage/UsersSection.tsx +24 -0
- frontend/src/styles.css +21 -0
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 —
|
| 173 |
-
from
|
| 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 |
-
{
|
| 97 |
-
<article className="panel banner">
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;
|