feat: Client → Team → Project isolation system with Project Manager role
Backend:
- New UserRole.PROJECT_MANAGER with pm_client_ids[] on User model
- New models: Client (slug-based), Team (member_user_ids[]), Project (client-scoped)
- Job model gains project_id field
- New GET/POST/PATCH/DELETE /clients, /clients/{id}/teams, /clients/{id}/projects,
/clients/{id}/pm routes (admin-only client CRUD; PM or admin for teams/projects)
- get_accessible_project_ids() helper: staff→all, PM→their clients' projects,
CLIENT→projects from teams they belong to (with legacy owner fallback)
- list_jobs, get_job, bulk_download, get_vtt_content, delete_job all use new isolation
Frontend:
- UserRole type gains 'project_manager'
- Job, JobCreateRequest gain project_id field
- Client, Team, Project, PMUser types added
- ApiClient: full client/team/project/PM CRUD methods
- useClients hook with all query/mutation hooks
- Admin pages: ClientList + ClientDetail (teams, members, projects, PM assignment)
- NewJob form: client + project picker (shown when clients exist)
- Sidebar: Clients nav item for admin and project_manager roles
- Routes: /admin/clients and /admin/clients/:clientId behind RoleGate
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
f7d4624fc7
commit
2b721d182b
18 changed files with 1552 additions and 42 deletions
|
|
@ -36,6 +36,12 @@ SENDGRID_API_KEY=...
|
|||
EMAIL_FROM=support@yourdomain.com
|
||||
CLIENT_BASE_URL=https://app.yourdomain.com
|
||||
|
||||
# AI Cost Tracker
|
||||
COST_TRACKER_BASE_URL=https://optical-dev.oliver.solutions/cost-tracker/v1
|
||||
COST_TRACKER_API_KEY=...
|
||||
COST_TRACKER_SOURCE_APP=video-accessibility
|
||||
COST_TRACKER_ENABLED=true
|
||||
|
||||
# Observability
|
||||
SENTRY_DSN=...
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=...
|
||||
435
backend/app/api/v1/routes_clients.py
Normal file
435
backend/app/api/v1/routes_clients.py
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
"""
|
||||
Client / Team / Project management.
|
||||
|
||||
Access rules:
|
||||
- Client CRUD → Admin only
|
||||
- PM assignment on client → Admin only
|
||||
- Team CRUD + membership → Admin or PM of that client
|
||||
- Project CRUD → Admin or PM of that client
|
||||
- List projects (read) → Admin, PM, or any team member of the client
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from motor.motor_asyncio import AsyncIOMotorDatabase
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import get_current_user, require_pm_for_client, require_roles
|
||||
from ...models.client import (
|
||||
Client,
|
||||
ClientCreate,
|
||||
ClientUpdate,
|
||||
Project,
|
||||
ProjectCreate,
|
||||
ProjectUpdate,
|
||||
Team,
|
||||
TeamCreate,
|
||||
TeamUpdate,
|
||||
)
|
||||
from ...models.user import User, UserRole
|
||||
|
||||
router = APIRouter(prefix="/clients", tags=["clients"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def _get_client_or_404(client_id: str, db: AsyncIOMotorDatabase) -> dict:
|
||||
doc = await db.clients.find_one({"_id": client_id})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Client not found")
|
||||
return doc
|
||||
|
||||
|
||||
async def _get_team_or_404(team_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
|
||||
doc = await db.teams.find_one({"_id": team_id, "client_id": client_id})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
return doc
|
||||
|
||||
|
||||
async def _get_project_or_404(project_id: str, client_id: str, db: AsyncIOMotorDatabase) -> dict:
|
||||
doc = await db.projects.find_one({"_id": project_id, "client_id": client_id})
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
return doc
|
||||
|
||||
|
||||
def _client_from_doc(doc: dict) -> Client:
|
||||
return Client(**{**doc, "_id": str(doc["_id"])})
|
||||
|
||||
|
||||
def _team_from_doc(doc: dict) -> Team:
|
||||
return Team(**{**doc, "_id": str(doc["_id"])})
|
||||
|
||||
|
||||
def _project_from_doc(doc: dict) -> Project:
|
||||
return Project(**{**doc, "_id": str(doc["_id"])})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client endpoints (Admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("", response_model=list[Client])
|
||||
async def list_clients(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
if current_user.role == UserRole.ADMIN:
|
||||
docs = await db.clients.find().to_list(None)
|
||||
elif current_user.role == UserRole.PROJECT_MANAGER:
|
||||
client_ids = current_user.pm_client_ids or []
|
||||
docs = await db.clients.find({"_id": {"$in": client_ids}}).to_list(None)
|
||||
else:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
return [_client_from_doc(d) for d in docs]
|
||||
|
||||
|
||||
@router.post("", response_model=Client)
|
||||
async def create_client(
|
||||
body: ClientCreate,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
if await db.clients.find_one({"slug": body.slug}):
|
||||
raise HTTPException(status_code=409, detail="Slug already exists")
|
||||
now = _now()
|
||||
client_id = str(ObjectId())
|
||||
await db.clients.insert_one({
|
||||
"_id": client_id,
|
||||
"name": body.name,
|
||||
"slug": body.slug,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
doc = await db.clients.find_one({"_id": client_id})
|
||||
return _client_from_doc(doc)
|
||||
|
||||
|
||||
@router.get("/{client_id}", response_model=Client)
|
||||
async def get_client(
|
||||
client_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
if current_user.role not in (UserRole.ADMIN, UserRole.PROJECT_MANAGER):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
if current_user.role == UserRole.PROJECT_MANAGER and client_id not in (current_user.pm_client_ids or []):
|
||||
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
||||
doc = await _get_client_or_404(client_id, db)
|
||||
return _client_from_doc(doc)
|
||||
|
||||
|
||||
@router.patch("/{client_id}", response_model=Client)
|
||||
async def update_client(
|
||||
client_id: str,
|
||||
body: ClientUpdate,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
update: dict = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
||||
if not update:
|
||||
raise HTTPException(status_code=422, detail="No fields to update")
|
||||
if "slug" in update and await db.clients.find_one({"slug": update["slug"], "_id": {"$ne": client_id}}):
|
||||
raise HTTPException(status_code=409, detail="Slug already exists")
|
||||
update["updated_at"] = _now()
|
||||
await db.clients.update_one({"_id": client_id}, {"$set": update})
|
||||
doc = await db.clients.find_one({"_id": client_id})
|
||||
return _client_from_doc(doc)
|
||||
|
||||
|
||||
@router.delete("/{client_id}", status_code=204)
|
||||
async def deactivate_client(
|
||||
client_id: str,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
await db.clients.update_one({"_id": client_id}, {"$set": {"is_active": False, "updated_at": _now()}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PM assignment (Admin only)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class AssignPMRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
@router.post("/{client_id}/pm", status_code=204)
|
||||
async def assign_pm(
|
||||
client_id: str,
|
||||
body: AssignPMRequest,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
user_doc = await db.users.find_one({"_id": body.user_id})
|
||||
if not user_doc:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.users.update_one(
|
||||
{"_id": body.user_id},
|
||||
{
|
||||
"$addToSet": {"pm_client_ids": client_id},
|
||||
"$set": {"role": UserRole.PROJECT_MANAGER.value, "updated_at": _now()},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{client_id}/pm/{user_id}", status_code=204)
|
||||
async def remove_pm(
|
||||
client_id: str,
|
||||
user_id: str,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
await db.users.update_one(
|
||||
{"_id": user_id},
|
||||
{"$pull": {"pm_client_ids": client_id}, "$set": {"updated_at": _now()}},
|
||||
)
|
||||
# Downgrade role if no more PM assignments
|
||||
user_doc = await db.users.find_one({"_id": user_id})
|
||||
if user_doc and not user_doc.get("pm_client_ids"):
|
||||
await db.users.update_one(
|
||||
{"_id": user_id},
|
||||
{"$set": {"role": UserRole.CLIENT.value, "updated_at": _now()}},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{client_id}/pm", response_model=list[dict])
|
||||
async def list_pms(
|
||||
client_id: str,
|
||||
current_user: User = Depends(require_roles(UserRole.ADMIN)),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
users = await db.users.find(
|
||||
{"pm_client_ids": client_id},
|
||||
{"_id": 1, "email": 1, "full_name": 1, "role": 1},
|
||||
).to_list(None)
|
||||
return [{"id": str(u["_id"]), "email": u["email"], "full_name": u["full_name"], "role": u["role"]} for u in users]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Team endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{client_id}/teams", response_model=list[Team])
|
||||
async def list_teams(
|
||||
client_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_client_access(current_user, client_id)
|
||||
docs = await db.teams.find({"client_id": client_id}).to_list(None)
|
||||
return [_team_from_doc(d) for d in docs]
|
||||
|
||||
|
||||
@router.post("/{client_id}/teams", response_model=Team)
|
||||
async def create_team(
|
||||
client_id: str,
|
||||
body: TeamCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
now = _now()
|
||||
team_id = str(ObjectId())
|
||||
await db.teams.insert_one({
|
||||
"_id": team_id,
|
||||
"name": body.name,
|
||||
"client_id": client_id,
|
||||
"member_user_ids": [],
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
doc = await db.teams.find_one({"_id": team_id})
|
||||
return _team_from_doc(doc)
|
||||
|
||||
|
||||
@router.patch("/{client_id}/teams/{team_id}", response_model=Team)
|
||||
async def update_team(
|
||||
client_id: str,
|
||||
team_id: str,
|
||||
body: TeamUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
await _get_team_or_404(team_id, client_id, db)
|
||||
update = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
||||
if not update:
|
||||
raise HTTPException(status_code=422, detail="No fields to update")
|
||||
update["updated_at"] = _now()
|
||||
await db.teams.update_one({"_id": team_id}, {"$set": update})
|
||||
doc = await db.teams.find_one({"_id": team_id})
|
||||
return _team_from_doc(doc)
|
||||
|
||||
|
||||
@router.delete("/{client_id}/teams/{team_id}", status_code=204)
|
||||
async def delete_team(
|
||||
client_id: str,
|
||||
team_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
await _get_team_or_404(team_id, client_id, db)
|
||||
await db.teams.delete_one({"_id": team_id})
|
||||
|
||||
|
||||
# Team membership
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
@router.post("/{client_id}/teams/{team_id}/members", status_code=204)
|
||||
async def add_team_member(
|
||||
client_id: str,
|
||||
team_id: str,
|
||||
body: AddMemberRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
await _get_team_or_404(team_id, client_id, db)
|
||||
if not await db.users.find_one({"_id": body.user_id}):
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
await db.teams.update_one(
|
||||
{"_id": team_id},
|
||||
{"$addToSet": {"member_user_ids": body.user_id}, "$set": {"updated_at": _now()}},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{client_id}/teams/{team_id}/members/{user_id}", status_code=204)
|
||||
async def remove_team_member(
|
||||
client_id: str,
|
||||
team_id: str,
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
await _get_team_or_404(team_id, client_id, db)
|
||||
await db.teams.update_one(
|
||||
{"_id": team_id},
|
||||
{"$pull": {"member_user_ids": user_id}, "$set": {"updated_at": _now()}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Project endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/{client_id}/projects", response_model=list[Project])
|
||||
async def list_projects(
|
||||
client_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_client_access(current_user, client_id)
|
||||
docs = await db.projects.find({"client_id": client_id}).to_list(None)
|
||||
return [_project_from_doc(d) for d in docs]
|
||||
|
||||
|
||||
@router.post("/{client_id}/projects", response_model=Project)
|
||||
async def create_project(
|
||||
client_id: str,
|
||||
body: ProjectCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
now = _now()
|
||||
project_id = str(ObjectId())
|
||||
await db.projects.insert_one({
|
||||
"_id": project_id,
|
||||
"name": body.name,
|
||||
"client_id": client_id,
|
||||
"is_active": True,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
})
|
||||
doc = await db.projects.find_one({"_id": project_id})
|
||||
return _project_from_doc(doc)
|
||||
|
||||
|
||||
@router.patch("/{client_id}/projects/{project_id}", response_model=Project)
|
||||
async def update_project(
|
||||
client_id: str,
|
||||
project_id: str,
|
||||
body: ProjectUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
await _get_project_or_404(project_id, client_id, db)
|
||||
update = {k: v for k, v in body.model_dump(exclude_none=True).items()}
|
||||
if not update:
|
||||
raise HTTPException(status_code=422, detail="No fields to update")
|
||||
update["updated_at"] = _now()
|
||||
await db.projects.update_one({"_id": project_id}, {"$set": update})
|
||||
doc = await db.projects.find_one({"_id": project_id})
|
||||
return _project_from_doc(doc)
|
||||
|
||||
|
||||
@router.delete("/{client_id}/projects/{project_id}", status_code=204)
|
||||
async def archive_project(
|
||||
client_id: str,
|
||||
project_id: str,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
await _get_client_or_404(client_id, db)
|
||||
_assert_pm_or_admin(current_user, client_id)
|
||||
await _get_project_or_404(project_id, client_id, db)
|
||||
await db.projects.update_one(
|
||||
{"_id": project_id},
|
||||
{"$set": {"is_active": False, "updated_at": _now()}},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal access-check helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _assert_pm_or_admin(user: User, client_id: str) -> None:
|
||||
if user.role == UserRole.ADMIN:
|
||||
return
|
||||
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
||||
|
||||
|
||||
def _assert_client_access(user: User, client_id: str) -> None:
|
||||
"""Allow admin, PM of the client, or any user (team membership checked at job level)."""
|
||||
if user.role in (UserRole.ADMIN, UserRole.REVIEWER, UserRole.PRODUCTION, UserRole.LINGUIST):
|
||||
return
|
||||
if user.role == UserRole.PROJECT_MANAGER and client_id in (user.pm_client_ids or []):
|
||||
return
|
||||
# CLIENT role users can list projects so they can pick one on job creation
|
||||
if user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
|
|
@ -8,7 +8,7 @@ from motor.motor_asyncio import AsyncIOMotorDatabase
|
|||
|
||||
from ...core.config import settings
|
||||
from ...core.database import get_database
|
||||
from ...core.dependencies import get_current_user, require_roles
|
||||
from ...core.dependencies import get_accessible_project_ids, get_current_user, require_roles
|
||||
from ...core.logging import get_logger
|
||||
from ...lib.vtt import VTTEditor
|
||||
from ...models.job import JobStatus, RequestedOutputs
|
||||
|
|
@ -70,6 +70,7 @@ async def create_job(
|
|||
requested_outputs: str = Form(...), # JSON string
|
||||
file: UploadFile = File(...),
|
||||
brand_context: Optional[str] = Form(None),
|
||||
project_id: Optional[str] = Form(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncIOMotorDatabase = Depends(get_database),
|
||||
):
|
||||
|
|
@ -120,6 +121,7 @@ async def create_job(
|
|||
}]
|
||||
},
|
||||
"brand_context": brand_context or None,
|
||||
"project_id": project_id or None,
|
||||
"created_at": datetime.utcnow(),
|
||||
"updated_at": datetime.utcnow()
|
||||
}
|
||||
|
|
@ -448,13 +450,15 @@ async def bulk_download_jobs(
|
|||
detail="No jobs found"
|
||||
)
|
||||
|
||||
# Filter by permissions (clients can only download their own jobs)
|
||||
# Filter by permissions
|
||||
_accessible_pids = await get_accessible_project_ids(current_user, db)
|
||||
valid_jobs = []
|
||||
for job_doc in jobs:
|
||||
# Check ownership for client users
|
||||
if (current_user.role == UserRole.CLIENT and
|
||||
job_doc["client_id"] != str(current_user.id)):
|
||||
continue
|
||||
if _accessible_pids is not None:
|
||||
_jpid = job_doc.get("project_id")
|
||||
_own_legacy = job_doc.get("client_id") == str(current_user.id) and not _jpid
|
||||
if not _own_legacy and not (_jpid and _jpid in _accessible_pids):
|
||||
continue
|
||||
|
||||
# Check downloadable status
|
||||
if job_doc["status"] not in DOWNLOADABLE_STATUSES:
|
||||
|
|
@ -514,8 +518,18 @@ async def list_jobs(
|
|||
else:
|
||||
query["status"] = status
|
||||
|
||||
if mine or current_user.role == UserRole.CLIENT:
|
||||
if mine:
|
||||
query["client_id"] = str(current_user.id)
|
||||
else:
|
||||
project_ids = await get_accessible_project_ids(current_user, db)
|
||||
if project_ids is not None:
|
||||
# CLIENT / PM: jobs in accessible projects OR legacy jobs owned by this user
|
||||
legacy_filter = {"client_id": str(current_user.id), "project_id": None}
|
||||
if project_ids:
|
||||
query["$or"] = [{"project_id": {"$in": project_ids}}, legacy_filter]
|
||||
else:
|
||||
# No teams / no projects yet — show only own legacy jobs
|
||||
query.update(legacy_filter)
|
||||
|
||||
# Get total count
|
||||
total = await db.jobs.count_documents(query)
|
||||
|
|
@ -591,12 +605,13 @@ async def get_job(
|
|||
)
|
||||
|
||||
# Check access permissions
|
||||
if (current_user.role == UserRole.CLIENT and
|
||||
job_doc["client_id"] != str(current_user.id)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
|
||||
accessible = await get_accessible_project_ids(current_user, db)
|
||||
job_project_id = job_doc.get("project_id")
|
||||
is_own_legacy = job_doc.get("client_id") == str(current_user.id) and not job_project_id
|
||||
in_project = accessible is None or (job_project_id and job_project_id in (accessible or []))
|
||||
if not is_own_legacy and not in_project:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Check task status if task_id exists
|
||||
task_id = job_doc.get("task_id")
|
||||
|
|
@ -1035,13 +1050,13 @@ async def get_job_downloads(
|
|||
detail="Job not found"
|
||||
)
|
||||
|
||||
# Check access permissions (only client or admin/reviewer can download)
|
||||
if (current_user.role == UserRole.CLIENT and
|
||||
job_doc["client_id"] != str(current_user.id)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
# Check access permissions
|
||||
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
|
||||
_ap = await get_accessible_project_ids(current_user, db)
|
||||
_jpid = job_doc.get("project_id")
|
||||
_own = job_doc.get("client_id") == str(current_user.id) and not _jpid
|
||||
if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Allow downloads for jobs that have outputs available
|
||||
# (PENDING_QC, APPROVED_ENGLISH, TRANSLATING, COMPLETED, etc.)
|
||||
|
|
@ -1163,12 +1178,12 @@ async def get_job_vtt_content(
|
|||
)
|
||||
|
||||
# Check access permissions
|
||||
if (current_user.role == UserRole.CLIENT and
|
||||
job_doc["client_id"] != str(current_user.id)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
|
||||
_ap = await get_accessible_project_ids(current_user, db)
|
||||
_jpid = job_doc.get("project_id")
|
||||
_own = job_doc.get("client_id") == str(current_user.id) and not _jpid
|
||||
if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
# Default to source language if not specified
|
||||
target_language = language or job_doc["source"].get("language", "en")
|
||||
|
|
@ -1519,13 +1534,13 @@ async def delete_job(
|
|||
detail="Job not found"
|
||||
)
|
||||
|
||||
# Check permissions: clients can only delete their own jobs, admins/reviewers can delete any
|
||||
if (current_user.role == UserRole.CLIENT and
|
||||
job_doc["client_id"] != str(current_user.id)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied"
|
||||
)
|
||||
# Check permissions: clients and PMs can only delete accessible jobs
|
||||
if current_user.role in (UserRole.CLIENT, UserRole.PROJECT_MANAGER):
|
||||
_ap = await get_accessible_project_ids(current_user, db)
|
||||
_jpid = job_doc.get("project_id")
|
||||
_own = job_doc.get("client_id") == str(current_user.id) and not _jpid
|
||||
if _ap is not None and not _own and not (_jpid and _jpid in (_ap or [])):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
|
||||
|
||||
logger.info(f"Deleting job {job_id} requested by {current_user.email}")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import asyncio
|
||||
import time
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
|
@ -9,6 +11,7 @@ from ...core.logging import get_logger
|
|||
from ...services.gemini_tts import gemini_tts_service
|
||||
from ...services.elevenlabs_voices import elevenlabs_voice_service
|
||||
from ...services.tts import tts_service
|
||||
from ...services import cost_tracker
|
||||
from ...core.dependencies import get_current_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
|
@ -212,13 +215,15 @@ async def preview_voice(
|
|||
Generate a voice preview audio sample with all TTS settings applied.
|
||||
Returns MP3 audio data.
|
||||
"""
|
||||
user_id: str = current_user.get("email") or current_user.get("sub") or "unknown"
|
||||
|
||||
if request.provider == "elevenlabs":
|
||||
return await _preview_elevenlabs(request)
|
||||
return await _preview_elevenlabs(request, user_id)
|
||||
|
||||
return await _preview_gemini(request)
|
||||
return await _preview_gemini(request, user_id)
|
||||
|
||||
|
||||
async def _preview_gemini(request: VoicePreviewRequest) -> Response:
|
||||
async def _preview_gemini(request: VoicePreviewRequest, user_id: str) -> Response:
|
||||
"""Generate a Gemini TTS voice preview."""
|
||||
# Validate voice name
|
||||
if request.voice_name not in settings.gemini_tts_voices:
|
||||
|
|
@ -240,12 +245,18 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response:
|
|||
else:
|
||||
style_prompt = settings.gemini_tts_style_prompts.get(request.style_preset, "")
|
||||
|
||||
sample_text = settings.gemini_tts_preview_samples.get(
|
||||
request.language,
|
||||
settings.gemini_tts_preview_samples.get("en", "This is a voice preview.")
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(
|
||||
f"Generating Gemini voice preview: voice={request.voice_name}, language={request.language}, "
|
||||
f"model={request.model}, speed={request.speed}x, style={request.style_preset}"
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
audio_data = await gemini_tts_service.synthesize_preview(
|
||||
voice_name=request.voice_name,
|
||||
language=request.language,
|
||||
|
|
@ -253,6 +264,16 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response:
|
|||
speed=request.speed,
|
||||
style_prompt=style_prompt
|
||||
)
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
|
||||
model_id = settings.gemini_tts_models.get(request.model, settings.gemini_tts_model)
|
||||
asyncio.create_task(cost_tracker.aio_record(
|
||||
model=model_id,
|
||||
provider="google",
|
||||
user_external_id=user_id,
|
||||
chars=len(sample_text),
|
||||
latency_ms=elapsed_ms,
|
||||
))
|
||||
|
||||
return Response(
|
||||
content=audio_data,
|
||||
|
|
@ -270,7 +291,7 @@ async def _preview_gemini(request: VoicePreviewRequest) -> Response:
|
|||
) from e
|
||||
|
||||
|
||||
async def _preview_elevenlabs(request: VoicePreviewRequest) -> Response:
|
||||
async def _preview_elevenlabs(request: VoicePreviewRequest, user_id: str) -> Response:
|
||||
"""Generate an ElevenLabs TTS voice preview."""
|
||||
if not tts_service.elevenlabs_available:
|
||||
raise HTTPException(
|
||||
|
|
@ -293,12 +314,22 @@ async def _preview_elevenlabs(request: VoicePreviewRequest) -> Response:
|
|||
f"stability={stability}, similarity_boost={similarity_boost}"
|
||||
)
|
||||
|
||||
t0 = time.monotonic()
|
||||
audio_data = await tts_service._synthesize_text_elevenlabs(
|
||||
text=sample_text,
|
||||
voice_id=request.voice_name,
|
||||
stability=stability,
|
||||
similarity_boost=similarity_boost,
|
||||
)
|
||||
elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
|
||||
asyncio.create_task(cost_tracker.aio_record(
|
||||
model="eleven_multilingual_v2",
|
||||
provider="elevenlabs",
|
||||
user_external_id=user_id,
|
||||
chars=len(sample_text),
|
||||
latency_ms=elapsed_ms,
|
||||
))
|
||||
|
||||
return Response(
|
||||
content=audio_data,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ from .security import decode_token
|
|||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Roles that see all jobs (no tenant isolation)
|
||||
STAFF_ROLES = {UserRole.ADMIN, UserRole.REVIEWER, UserRole.LINGUIST, UserRole.PRODUCTION}
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
|
|
@ -86,3 +89,60 @@ async def get_current_user_optional(
|
|||
return User(**user_doc)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
async def get_accessible_project_ids(
|
||||
user: User,
|
||||
db: AsyncIOMotorDatabase,
|
||||
) -> Optional[list[str]]:
|
||||
"""
|
||||
Returns project IDs the user may access, or None meaning "see everything".
|
||||
|
||||
- Staff / Admin → None (unrestricted)
|
||||
- PM → all projects in their assigned client(s)
|
||||
- CLIENT → projects belonging to clients of any team the user is in
|
||||
"""
|
||||
if user.role in STAFF_ROLES:
|
||||
return None
|
||||
|
||||
if user.role == UserRole.PROJECT_MANAGER:
|
||||
client_ids = user.pm_client_ids or []
|
||||
if not client_ids:
|
||||
return []
|
||||
projects = await db.projects.find(
|
||||
{"client_id": {"$in": client_ids}, "is_active": True},
|
||||
{"_id": 1},
|
||||
).to_list(None)
|
||||
return [str(p["_id"]) for p in projects]
|
||||
|
||||
# CLIENT role: find teams the user belongs to → their clients → projects
|
||||
teams = await db.teams.find(
|
||||
{"member_user_ids": str(user.id)},
|
||||
{"client_id": 1},
|
||||
).to_list(None)
|
||||
client_ids = list({t["client_id"] for t in teams})
|
||||
if not client_ids:
|
||||
return []
|
||||
projects = await db.projects.find(
|
||||
{"client_id": {"$in": client_ids}, "is_active": True},
|
||||
{"_id": 1},
|
||||
).to_list(None)
|
||||
return [str(p["_id"]) for p in projects]
|
||||
|
||||
|
||||
def require_pm_for_client(client_id_param: str = "client_id"):
|
||||
"""Dependency: ensures the current user is an Admin or PM for the given client."""
|
||||
async def checker(
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
if current_user.role == UserRole.ADMIN:
|
||||
return current_user
|
||||
if current_user.role != UserRole.PROJECT_MANAGER:
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
client_id = request.path_params.get(client_id_param)
|
||||
if client_id not in (current_user.pm_client_ids or []):
|
||||
raise HTTPException(status_code=403, detail="Not a manager for this client")
|
||||
return current_user
|
||||
|
||||
return checker
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from sentry_sdk.integrations.celery import CeleryIntegration
|
|||
|
||||
from .api.v1.routes_admin import router as admin_router
|
||||
from .api.v1.routes_auth import router as auth_router
|
||||
from .api.v1.routes_clients import router as clients_router
|
||||
from .api.v1.routes_files import router as files_router
|
||||
from .api.v1.routes_jobs import router as jobs_router
|
||||
from .api.v1.routes_review_notes import router as review_notes_router
|
||||
|
|
@ -248,6 +249,7 @@ async def validation_middleware(request, call_next):
|
|||
|
||||
# Include routers
|
||||
app.include_router(auth_router, prefix="/api/v1")
|
||||
app.include_router(clients_router, prefix="/api/v1")
|
||||
app.include_router(files_router, prefix="/api/v1")
|
||||
app.include_router(jobs_router, prefix="/api/v1")
|
||||
app.include_router(review_notes_router, prefix="/api/v1")
|
||||
|
|
|
|||
80
backend/app/models/client.py
Normal file
80
backend/app/models/client.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from datetime import datetime
|
||||
from typing import Optional, Annotated
|
||||
|
||||
from bson import ObjectId
|
||||
from pydantic import BaseModel, Field, BeforeValidator
|
||||
|
||||
|
||||
def validate_object_id(v) -> str:
|
||||
if isinstance(v, ObjectId):
|
||||
return str(v)
|
||||
if isinstance(v, str):
|
||||
return v
|
||||
raise ValueError("Invalid ObjectId")
|
||||
|
||||
|
||||
PyObjectId = Annotated[str, BeforeValidator(validate_object_id)]
|
||||
|
||||
|
||||
class Client(BaseModel):
|
||||
id: Optional[PyObjectId] = Field(None, alias="_id")
|
||||
name: str
|
||||
slug: str # lowercase, URL-safe identifier, unique
|
||||
is_active: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ClientCreate(BaseModel):
|
||||
name: str
|
||||
slug: str
|
||||
|
||||
|
||||
class ClientUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
slug: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class Team(BaseModel):
|
||||
id: Optional[PyObjectId] = Field(None, alias="_id")
|
||||
name: str
|
||||
client_id: str
|
||||
member_user_ids: list[str] = []
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class TeamCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class TeamUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
id: Optional[PyObjectId] = Field(None, alias="_id")
|
||||
name: str
|
||||
client_id: str
|
||||
is_active: bool = True
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
populate_by_name = True
|
||||
|
||||
|
||||
class ProjectCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class ProjectUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
|
@ -167,6 +167,7 @@ class Job(BaseModel):
|
|||
ai: Optional[AISection] = None
|
||||
error: Optional[dict[str, Any]] = None
|
||||
tts_rewrites: Optional[list[dict[str, Any]]] = None # Track auto-rewritten TTS cues
|
||||
project_id: Optional[str] = None # Platform project this job belongs to (Client → Project → Job)
|
||||
brand_context: Optional[str] = None # Brand names present in the video for accurate product identification
|
||||
cost_tracker_project_id: Optional[str] = None # External project ID for AI cost attribution
|
||||
created_at: Optional[datetime] = None
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ class UserRole(str, Enum):
|
|||
REVIEWER = "reviewer"
|
||||
LINGUIST = "linguist"
|
||||
PRODUCTION = "production"
|
||||
PROJECT_MANAGER = "project_manager"
|
||||
ADMIN = "admin"
|
||||
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ class User(BaseModel):
|
|||
role: UserRole = UserRole.CLIENT
|
||||
auth_provider: AuthProvider = AuthProvider.LOCAL
|
||||
is_active: bool = True
|
||||
pm_client_ids: list[str] = [] # Client IDs where this user is Project Manager (admin-assigned)
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
|
@ -63,3 +65,4 @@ class UserUpdate(BaseModel):
|
|||
full_name: Optional[str] = None
|
||||
role: Optional[UserRole] = None
|
||||
is_active: Optional[bool] = None
|
||||
pm_client_ids: Optional[list[str]] = None
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ import { FinalList } from './routes/admin/FinalList';
|
|||
import { FinalDetail } from './routes/admin/FinalDetail';
|
||||
import { UserList } from './routes/admin/UserList';
|
||||
import { UserDetail } from './routes/admin/UserDetail';
|
||||
import { ClientList } from './routes/admin/ClientList';
|
||||
import { ClientDetail } from './routes/admin/ClientDetail';
|
||||
import { Downloads } from './routes/Downloads';
|
||||
import { RequireAuth } from './components/Auth/RequireAuth';
|
||||
import { RoleGate } from './components/Auth/RoleGate';
|
||||
|
|
@ -107,6 +109,20 @@ function AppContent() {
|
|||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/admin/clients" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['admin', 'project_manager']}>
|
||||
<ClientList />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/admin/clients/:clientId" element={
|
||||
<AuthenticatedRoute>
|
||||
<RoleGate allowedRoles={['admin', 'project_manager']}>
|
||||
<ClientDetail />
|
||||
</RoleGate>
|
||||
</AuthenticatedRoute>
|
||||
} />
|
||||
<Route path="/downloads/:id" element={
|
||||
<AuthenticatedRoute>
|
||||
<Downloads />
|
||||
|
|
|
|||
|
|
@ -52,6 +52,12 @@ export function Sidebar({ onMobileClose }: SidebarProps) {
|
|||
icon: '👥',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
label: 'Clients',
|
||||
href: '/admin/clients',
|
||||
icon: '🏢',
|
||||
roles: ['admin', 'project_manager'],
|
||||
},
|
||||
];
|
||||
|
||||
const filteredItems = sidebarItems.filter(item =>
|
||||
|
|
|
|||
154
frontend/src/hooks/useClients.ts
Normal file
154
frontend/src/hooks/useClients.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { apiClient } from '../lib/api';
|
||||
import type { ClientCreateRequest, ProjectCreateRequest, TeamCreateRequest } from '../types/api';
|
||||
|
||||
// ── Clients ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useClients() {
|
||||
return useQuery({
|
||||
queryKey: ['clients'],
|
||||
queryFn: () => apiClient.listClients(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useClient(clientId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['clients', clientId],
|
||||
queryFn: () => apiClient.getClient(clientId),
|
||||
enabled: !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateClient() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ClientCreateRequest) => apiClient.createClient(data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateClient(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: Partial<ClientCreateRequest & { is_active: boolean }>) =>
|
||||
apiClient.updateClient(clientId, data),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['clients'] });
|
||||
qc.invalidateQueries({ queryKey: ['clients', clientId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── PMs ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function usePMs(clientId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['clients', clientId, 'pm'],
|
||||
queryFn: () => apiClient.listPMs(clientId),
|
||||
enabled: !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAssignPM(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => apiClient.assignPM(clientId, userId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemovePM(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (userId: string) => apiClient.removePM(clientId, userId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'pm'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Teams ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useTeams(clientId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['clients', clientId, 'teams'],
|
||||
queryFn: () => apiClient.listTeams(clientId),
|
||||
enabled: !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateTeam(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: TeamCreateRequest) => apiClient.createTeam(clientId, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTeam(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ teamId, name }: { teamId: string; name: string }) =>
|
||||
apiClient.updateTeam(clientId, teamId, name),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTeam(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (teamId: string) => apiClient.deleteTeam(clientId, teamId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddTeamMember(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) =>
|
||||
apiClient.addTeamMember(clientId, teamId, userId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveTeamMember(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ teamId, userId }: { teamId: string; userId: string }) =>
|
||||
apiClient.removeTeamMember(clientId, teamId, userId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'teams'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Projects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useProjects(clientId: string) {
|
||||
return useQuery({
|
||||
queryKey: ['clients', clientId, 'projects'],
|
||||
queryFn: () => apiClient.listProjects(clientId),
|
||||
enabled: !!clientId,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateProject(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: ProjectCreateRequest) => apiClient.createProject(clientId, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProject(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ projectId, data }: { projectId: string; data: Partial<{ name: string; is_active: boolean }> }) =>
|
||||
apiClient.updateProject(clientId, projectId, data),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useArchiveProject(clientId: string) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (projectId: string) => apiClient.archiveProject(clientId, projectId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['clients', clientId, 'projects'] }),
|
||||
});
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ export interface FileListItem {
|
|||
export interface SharedJobSettings {
|
||||
requestedOutputs: RequestedOutputs;
|
||||
brandContext?: string;
|
||||
projectId?: string;
|
||||
}
|
||||
|
||||
interface UseMultiUploadOptions {
|
||||
|
|
@ -108,6 +109,7 @@ export function useMultiUpload(options: UseMultiUploadOptions = {}): UseMultiUpl
|
|||
title: item.autoTitle,
|
||||
requested_outputs: settings.requestedOutputs,
|
||||
brand_context: settings.brandContext,
|
||||
project_id: settings.projectId,
|
||||
},
|
||||
item.file,
|
||||
(progressEvent) => {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,13 @@ import type {
|
|||
ReviewNotesListResponse,
|
||||
AccessibleVideoEditState,
|
||||
PausePointData,
|
||||
Client,
|
||||
Team,
|
||||
Project,
|
||||
PMUser,
|
||||
ClientCreateRequest,
|
||||
TeamCreateRequest,
|
||||
ProjectCreateRequest,
|
||||
} from '../types/api';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
|
||||
|
|
@ -162,6 +169,9 @@ class ApiClient {
|
|||
if (data.brand_context) {
|
||||
formData.append('brand_context', data.brand_context);
|
||||
}
|
||||
if (data.project_id) {
|
||||
formData.append('project_id', data.project_id);
|
||||
}
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await this.client.post('/jobs', formData, {
|
||||
|
|
@ -432,9 +442,14 @@ class ApiClient {
|
|||
}
|
||||
|
||||
// Accessible Video QC Editing endpoints
|
||||
async getAccessibleVideoEditState(jobId: string, language: string): Promise<AccessibleVideoEditState> {
|
||||
const response = await this.client.get(`/jobs/${jobId}/accessible-video/${language}/edit-state`);
|
||||
return response.data;
|
||||
async getAccessibleVideoEditState(jobId: string, language: string): Promise<AccessibleVideoEditState | null> {
|
||||
try {
|
||||
const response = await this.client.get(`/jobs/${jobId}/accessible-video/${language}/edit-state`);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 404) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async updatePausePoint(
|
||||
|
|
@ -484,6 +499,87 @@ class ApiClient {
|
|||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// ── Client / Team / Project ──────────────────────────────────────────────
|
||||
|
||||
async listClients(): Promise<Client[]> {
|
||||
const r = await this.client.get('/clients');
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createClient(data: ClientCreateRequest): Promise<Client> {
|
||||
const r = await this.client.post('/clients', data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateClient(clientId: string, data: Partial<ClientCreateRequest & { is_active: boolean }>): Promise<Client> {
|
||||
const r = await this.client.patch(`/clients/${clientId}`, data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async getClient(clientId: string): Promise<Client> {
|
||||
const r = await this.client.get(`/clients/${clientId}`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async listPMs(clientId: string): Promise<PMUser[]> {
|
||||
const r = await this.client.get(`/clients/${clientId}/pm`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async assignPM(clientId: string, userId: string): Promise<void> {
|
||||
await this.client.post(`/clients/${clientId}/pm`, { user_id: userId });
|
||||
}
|
||||
|
||||
async removePM(clientId: string, userId: string): Promise<void> {
|
||||
await this.client.delete(`/clients/${clientId}/pm/${userId}`);
|
||||
}
|
||||
|
||||
async listTeams(clientId: string): Promise<Team[]> {
|
||||
const r = await this.client.get(`/clients/${clientId}/teams`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createTeam(clientId: string, data: TeamCreateRequest): Promise<Team> {
|
||||
const r = await this.client.post(`/clients/${clientId}/teams`, data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateTeam(clientId: string, teamId: string, name: string): Promise<Team> {
|
||||
const r = await this.client.patch(`/clients/${clientId}/teams/${teamId}`, { name });
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async deleteTeam(clientId: string, teamId: string): Promise<void> {
|
||||
await this.client.delete(`/clients/${clientId}/teams/${teamId}`);
|
||||
}
|
||||
|
||||
async addTeamMember(clientId: string, teamId: string, userId: string): Promise<void> {
|
||||
await this.client.post(`/clients/${clientId}/teams/${teamId}/members`, { user_id: userId });
|
||||
}
|
||||
|
||||
async removeTeamMember(clientId: string, teamId: string, userId: string): Promise<void> {
|
||||
await this.client.delete(`/clients/${clientId}/teams/${teamId}/members/${userId}`);
|
||||
}
|
||||
|
||||
async listProjects(clientId: string): Promise<Project[]> {
|
||||
const r = await this.client.get(`/clients/${clientId}/projects`);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async createProject(clientId: string, data: ProjectCreateRequest): Promise<Project> {
|
||||
const r = await this.client.post(`/clients/${clientId}/projects`, data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async updateProject(clientId: string, projectId: string, data: Partial<{ name: string; is_active: boolean }>): Promise<Project> {
|
||||
const r = await this.client.patch(`/clients/${clientId}/projects/${projectId}`, data);
|
||||
return r.data;
|
||||
}
|
||||
|
||||
async archiveProject(clientId: string, projectId: string): Promise<void> {
|
||||
await this.client.delete(`/clients/${clientId}/projects/${projectId}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
|
|
|
|||
369
frontend/src/routes/admin/ClientDetail.tsx
Normal file
369
frontend/src/routes/admin/ClientDetail.tsx
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import { useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
useClient,
|
||||
useTeams, useCreateTeam, useUpdateTeam, useDeleteTeam,
|
||||
useAddTeamMember, useRemoveTeamMember,
|
||||
useProjects, useCreateProject, useUpdateProject, useArchiveProject,
|
||||
usePMs, useAssignPM, useRemovePM,
|
||||
} from '../../hooks/useClients';
|
||||
import { useUsers } from '../../hooks/useUsers';
|
||||
import { useAuthStore } from '../../stores/authStore';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import type { Team, Project } from '../../types/api';
|
||||
|
||||
export function ClientDetail() {
|
||||
const { clientId } = useParams<{ clientId: string }>();
|
||||
const { user } = useAuthStore();
|
||||
const toast = useToastContext();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
|
||||
const { data: client, isLoading: clientLoading } = useClient(clientId!);
|
||||
const { data: teams = [] } = useTeams(clientId!);
|
||||
const { data: projects = [] } = useProjects(clientId!);
|
||||
const { data: pms = [] } = usePMs(clientId!);
|
||||
const { data: usersResp } = useUsers({ size: 200, active_only: true });
|
||||
const allUsers = usersResp?.users ?? [];
|
||||
|
||||
const createTeam = useCreateTeam(clientId!);
|
||||
const updateTeam = useUpdateTeam(clientId!);
|
||||
const deleteTeam = useDeleteTeam(clientId!);
|
||||
const addMember = useAddTeamMember(clientId!);
|
||||
const removeMember = useRemoveTeamMember(clientId!);
|
||||
|
||||
const createProject = useCreateProject(clientId!);
|
||||
const updateProject = useUpdateProject(clientId!);
|
||||
const archiveProject = useArchiveProject(clientId!);
|
||||
|
||||
const assignPM = useAssignPM(clientId!);
|
||||
const removePM = useRemovePM(clientId!);
|
||||
|
||||
// UI state
|
||||
const [newTeamName, setNewTeamName] = useState('');
|
||||
const [editingTeam, setEditingTeam] = useState<Team | null>(null);
|
||||
const [expandedTeam, setExpandedTeam] = useState<string | null>(null);
|
||||
const [addMemberTeamId, setAddMemberTeamId] = useState<string | null>(null);
|
||||
const [selectedMemberUserId, setSelectedMemberUserId] = useState('');
|
||||
|
||||
const [newProjectName, setNewProjectName] = useState('');
|
||||
const [editingProject, setEditingProject] = useState<Project | null>(null);
|
||||
|
||||
const [pmUserId, setPmUserId] = useState('');
|
||||
|
||||
if (clientLoading) {
|
||||
return <div className="container mx-auto px-4 py-8 animate-pulse"><div className="h-8 bg-gray-200 rounded w-1/3" /></div>;
|
||||
}
|
||||
if (!client) {
|
||||
return <div className="container mx-auto px-4 py-8 text-red-500">Client not found</div>;
|
||||
}
|
||||
|
||||
const pmUserIds = new Set(pms.map(p => p.id));
|
||||
|
||||
const handleCreateTeam = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newTeamName.trim()) return;
|
||||
try {
|
||||
await createTeam.mutateAsync({ name: newTeamName.trim() });
|
||||
setNewTeamName('');
|
||||
toast.success('Team created');
|
||||
} catch { toast.error('Failed to create team'); }
|
||||
};
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newProjectName.trim()) return;
|
||||
try {
|
||||
await createProject.mutateAsync({ name: newProjectName.trim() });
|
||||
setNewProjectName('');
|
||||
toast.success('Project created');
|
||||
} catch { toast.error('Failed to create project'); }
|
||||
};
|
||||
|
||||
const handleAssignPM = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!pmUserId) return;
|
||||
try {
|
||||
await assignPM.mutateAsync(pmUserId);
|
||||
setPmUserId('');
|
||||
toast.success('PM assigned');
|
||||
} catch { toast.error('Failed to assign PM'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-5xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">← <a href="/admin/clients" className="hover:text-blue-600">Clients</a></p>
|
||||
<h1 className="text-2xl font-bold text-gray-900">{client.name}</h1>
|
||||
<p className="text-xs text-gray-400 font-mono mt-0.5">{client.slug}</p>
|
||||
</div>
|
||||
|
||||
{/* Project Managers (admin only) */}
|
||||
{isAdmin && (
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">Project Managers</h2>
|
||||
<div className="space-y-2 mb-4">
|
||||
{pms.length === 0 && <p className="text-sm text-gray-400">No PMs assigned</p>}
|
||||
{pms.map(pm => (
|
||||
<div key={pm.id} className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-800">{pm.full_name}</p>
|
||||
<p className="text-xs text-gray-400">{pm.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await removePM.mutateAsync(pm.id);
|
||||
toast.success('PM removed');
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleAssignPM} className="flex gap-2">
|
||||
<select
|
||||
value={pmUserId}
|
||||
onChange={e => setPmUserId(e.target.value)}
|
||||
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select user to assign as PM…</option>
|
||||
{allUsers.filter(u => !pmUserIds.has(u.id)).map(u => (
|
||||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!pmUserId || assignPM.isPending}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Assign
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Teams */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">Teams</h2>
|
||||
<div className="space-y-3 mb-4">
|
||||
{teams.length === 0 && <p className="text-sm text-gray-400">No teams yet</p>}
|
||||
{teams.map(team => (
|
||||
<div key={team.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setExpandedTeam(expandedTeam === team.id ? null : team.id)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-800">{team.name}</span>
|
||||
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
|
||||
{team.member_user_ids.length} members
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setEditingTeam(team)}
|
||||
className="text-xs text-gray-500 hover:text-blue-600"
|
||||
>
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Delete team "${team.name}"?`)) return;
|
||||
await deleteTeam.mutateAsync(team.id);
|
||||
toast.success('Team deleted');
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<span className="text-gray-400">{expandedTeam === team.id ? '▲' : '▼'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{expandedTeam === team.id && (
|
||||
<div className="border-t border-gray-100 p-3 bg-gray-50">
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{team.member_user_ids.length === 0 && (
|
||||
<p className="text-xs text-gray-400">No members</p>
|
||||
)}
|
||||
{team.member_user_ids.map(uid => {
|
||||
const u = allUsers.find(u => u.id === uid);
|
||||
return (
|
||||
<div key={uid} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-700">{u ? `${u.full_name} (${u.email})` : uid}</span>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await removeMember.mutateAsync({ teamId: team.id, userId: uid });
|
||||
toast.success('Member removed');
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{addMemberTeamId === team.id ? (
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={selectedMemberUserId}
|
||||
onChange={e => setSelectedMemberUserId(e.target.value)}
|
||||
className="flex-1 border border-gray-300 rounded px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
<option value="">Select user…</option>
|
||||
{allUsers.filter(u => !team.member_user_ids.includes(u.id)).map(u => (
|
||||
<option key={u.id} value={u.id}>{u.full_name} ({u.email})</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
disabled={!selectedMemberUserId}
|
||||
onClick={async () => {
|
||||
await addMember.mutateAsync({ teamId: team.id, userId: selectedMemberUserId });
|
||||
setSelectedMemberUserId('');
|
||||
setAddMemberTeamId(null);
|
||||
toast.success('Member added');
|
||||
}}
|
||||
className="px-3 py-1 bg-blue-600 text-white text-xs rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button onClick={() => setAddMemberTeamId(null)} className="text-xs text-gray-500">Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { setAddMemberTeamId(team.id); setSelectedMemberUserId(''); }}
|
||||
className="text-xs text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
+ Add member
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleCreateTeam} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newTeamName}
|
||||
onChange={e => setNewTeamName(e.target.value)}
|
||||
placeholder="New team name…"
|
||||
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTeamName.trim() || createTeam.isPending}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
+ Team
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Projects */}
|
||||
<section className="bg-white rounded-xl border border-gray-200 p-5">
|
||||
<h2 className="text-base font-semibold text-gray-800 mb-4">Projects</h2>
|
||||
<div className="space-y-2 mb-4">
|
||||
{projects.length === 0 && <p className="text-sm text-gray-400">No projects yet</p>}
|
||||
{projects.map(project => (
|
||||
<div key={project.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
{editingProject?.id === project.id ? (
|
||||
<form
|
||||
className="flex gap-2 flex-1"
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
await updateProject.mutateAsync({ projectId: project.id, data: { name: editingProject.name } });
|
||||
setEditingProject(null);
|
||||
toast.success('Project updated');
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editingProject.name}
|
||||
onChange={e => setEditingProject({ ...editingProject, name: e.target.value })}
|
||||
className="flex-1 border border-gray-300 rounded px-2 py-1 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="submit" className="text-xs text-blue-600">Save</button>
|
||||
<button type="button" onClick={() => setEditingProject(null)} className="text-xs text-gray-500">Cancel</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-800">{project.name}</span>
|
||||
{!project.is_active && (
|
||||
<span className="text-xs bg-gray-200 text-gray-500 px-2 py-0.5 rounded-full">Archived</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setEditingProject(project)} className="text-xs text-gray-500 hover:text-blue-600">
|
||||
Rename
|
||||
</button>
|
||||
{project.is_active && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm(`Archive project "${project.name}"?`)) return;
|
||||
await archiveProject.mutateAsync(project.id);
|
||||
toast.success('Project archived');
|
||||
}}
|
||||
className="text-xs text-red-500 hover:text-red-700"
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<form onSubmit={handleCreateProject} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newProjectName}
|
||||
onChange={e => setNewProjectName(e.target.value)}
|
||||
placeholder="New project name…"
|
||||
className="flex-1 border border-gray-300 rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newProjectName.trim() || createProject.isPending}
|
||||
className="px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
+ Project
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Rename team modal */}
|
||||
{editingTeam && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<form
|
||||
className="bg-white rounded-xl p-6 w-full max-w-sm shadow-xl"
|
||||
onSubmit={async e => {
|
||||
e.preventDefault();
|
||||
await updateTeam.mutateAsync({ teamId: editingTeam.id, name: editingTeam.name });
|
||||
setEditingTeam(null);
|
||||
toast.success('Team renamed');
|
||||
}}
|
||||
>
|
||||
<h3 className="text-base font-semibold mb-4">Rename Team</h3>
|
||||
<input
|
||||
type="text"
|
||||
value={editingTeam.name}
|
||||
onChange={e => setEditingTeam({ ...editingTeam, name: e.target.value })}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button type="button" onClick={() => setEditingTeam(null)} className="flex-1 border border-gray-300 text-sm rounded-lg px-4 py-2 hover:bg-gray-50">Cancel</button>
|
||||
<button type="submit" className="flex-1 bg-blue-600 text-white text-sm rounded-lg px-4 py-2 hover:bg-blue-700">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
frontend/src/routes/admin/ClientList.tsx
Normal file
138
frontend/src/routes/admin/ClientList.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useClients, useCreateClient } from '../../hooks/useClients';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
|
||||
export function ClientList() {
|
||||
const { data: clients, isLoading } = useClients();
|
||||
const createClient = useCreateClient();
|
||||
const toast = useToastContext();
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [slug, setSlug] = useState('');
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await createClient.mutateAsync({ name, slug });
|
||||
toast.success('Client created');
|
||||
setShowCreate(false);
|
||||
setName('');
|
||||
setSlug('');
|
||||
} catch {
|
||||
toast.error('Failed to create client');
|
||||
}
|
||||
};
|
||||
|
||||
const autoSlug = (v: string) =>
|
||||
v.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[...Array(4)].map((_, i) => <div key={i} className="h-14 bg-gray-200 rounded" />)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8 max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Clients</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">Manage client organisations, their teams and projects</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreate(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
+ New Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{clients && clients.length === 0 && (
|
||||
<div className="text-center py-16 bg-gray-50 rounded-xl border border-dashed border-gray-300">
|
||||
<p className="text-gray-500 text-sm">No clients yet. Create your first client to get started.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{clients?.map(client => (
|
||||
<Link
|
||||
key={client.id}
|
||||
to={`/admin/clients/${client.id}`}
|
||||
className="flex items-center justify-between p-4 bg-white rounded-xl border border-gray-200 hover:border-blue-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{client.name}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5 font-mono">{client.slug}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!client.is_active && (
|
||||
<span className="px-2 py-0.5 text-xs bg-gray-100 text-gray-500 rounded-full">Inactive</span>
|
||||
)}
|
||||
<span className="text-gray-400 text-sm">→</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Create modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-2xl p-6 w-full max-w-md shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-4">New Client</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => {
|
||||
setName(e.target.value);
|
||||
setSlug(autoSlug(e.target.value));
|
||||
}}
|
||||
required
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="e.g. Ford Motor Company"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Slug</label>
|
||||
<input
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={e => setSlug(e.target.value)}
|
||||
required
|
||||
pattern="[a-z0-9\-]+"
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="ford-motor-company"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers and hyphens only</p>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreate(false)}
|
||||
className="flex-1 px-4 py-2 border border-gray-300 text-sm rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createClient.isPending}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{createClient.isPending ? 'Creating…' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import { useCreateJob } from '../../hooks/useJob';
|
|||
import { useMultiUpload } from '../../hooks/useMultiUpload';
|
||||
import { useToastContext } from '../../contexts/ToastContext';
|
||||
import { generateTitleFromFilename } from '../../lib/fileUtils';
|
||||
import { useClients, useProjects } from '../../hooks/useClients';
|
||||
import type { JobCreateRequest, TTSPreferences, AccessibleVideoMethod } from '../../types/api';
|
||||
|
||||
const jobSchema = z.object({
|
||||
|
|
@ -38,6 +39,10 @@ export function NewJob() {
|
|||
|
||||
// Shared state
|
||||
const [brandContext, setBrandContext] = useState('');
|
||||
const [selectedClientId, setSelectedClientId] = useState('');
|
||||
const [selectedProjectId, setSelectedProjectId] = useState('');
|
||||
const { data: clients = [] } = useClients();
|
||||
const { data: projects = [] } = useProjects(selectedClientId);
|
||||
const [showVoiceSettings, setShowVoiceSettings] = useState(false);
|
||||
const [ttsPreferences, setTtsPreferences] = useState<TTSPreferences>({
|
||||
provider: 'gemini',
|
||||
|
|
@ -136,6 +141,7 @@ export function NewJob() {
|
|||
translation_mode: data.translation_mode,
|
||||
},
|
||||
brand_context: brandContext.trim() || undefined,
|
||||
project_id: selectedProjectId || undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -216,6 +222,7 @@ export function NewJob() {
|
|||
translation_mode: data.translation_mode,
|
||||
},
|
||||
brandContext: brandContext.trim() || undefined,
|
||||
projectId: selectedProjectId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -262,6 +269,7 @@ export function NewJob() {
|
|||
translation_mode: data.translation_mode,
|
||||
},
|
||||
brandContext: brandContext.trim() || undefined,
|
||||
projectId: selectedProjectId || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -690,6 +698,42 @@ export function NewJob() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Project */}
|
||||
{clients.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Client <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedClientId}
|
||||
onChange={e => { setSelectedClientId(e.target.value); setSelectedProjectId(''); }}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">— No client —</option>
|
||||
{clients.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{selectedClientId && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Project <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<select
|
||||
value={selectedProjectId}
|
||||
onChange={e => setSelectedProjectId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<option value="">— No project —</option>
|
||||
{projects.filter(p => p.is_active).map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Brand Context */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ export type JobStatus =
|
|||
| "pending_final_review"
|
||||
| "completed";
|
||||
|
||||
export type UserRole = "client" | "reviewer" | "linguist" | "production" | "admin";
|
||||
export type UserRole = "client" | "reviewer" | "linguist" | "production" | "project_manager" | "admin";
|
||||
export type AuthProvider = "local" | "microsoft";
|
||||
|
||||
export interface User {
|
||||
|
|
@ -202,6 +202,7 @@ export interface Job {
|
|||
ai?: AISection;
|
||||
error?: Record<string, unknown>;
|
||||
tts_rewrites?: TTSRewriteItem[];
|
||||
project_id?: string;
|
||||
cost_tracker_project_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -246,7 +247,8 @@ export interface MicrosoftLoginResponse {
|
|||
export interface JobCreateRequest {
|
||||
title: string;
|
||||
requested_outputs: RequestedOutputs;
|
||||
brand_context?: string; // Comma-separated brand names present in the video
|
||||
brand_context?: string;
|
||||
project_id?: string;
|
||||
}
|
||||
|
||||
export interface JobListResponse {
|
||||
|
|
@ -342,6 +344,56 @@ export interface UpdateUserRequest {
|
|||
full_name?: string;
|
||||
role?: UserRole;
|
||||
is_active?: boolean;
|
||||
pm_client_ids?: string[];
|
||||
}
|
||||
|
||||
// Client / Team / Project types
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
client_id: string;
|
||||
member_user_ids: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
client_id: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PMUser {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface ClientCreateRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface TeamCreateRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ProjectCreateRequest {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordResponse {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue