feat: wire job wizard and dashboard to real backend API

- Job wizard now calls real API: create job → upload source → launch
- Dashboard and monitoring pages use live data instead of mock data
- Monitoring page polls every 3s while job is active
- Backend enriches job responses with client_name, created_by_name,
  source_line_count from eager-loaded relationships
- Frontend response mappers handle backend→frontend type differences
  (lowercase enum values, field name mapping, computed progress/stage)
- Source file parser accepts column aliases (Line type, Context notes)
  with case-insensitive matching for real-world Excel files
- Clients list endpoint accessible to all authenticated users
- Fixed uploadSource to use PUT, uploadSupplementary per-file
- Removed all hardcoded mock data from useJobs hook

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-10 14:18:47 -04:00
parent 1d94bfc005
commit f271343bc0
11 changed files with 393 additions and 310 deletions

View file

@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import get_db, require_role
from app.dependencies import get_current_user, get_db, require_role
from app.models.client import Client
from app.schemas.client import ClientCreate, ClientResponse, ClientUpdate
from app.schemas.common import PaginatedResponse
@ -34,9 +34,9 @@ async def list_clients(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_role(["admin"])),
current_user: dict = Depends(get_current_user),
) -> PaginatedResponse[ClientResponse]:
"""List all clients (admin only)."""
"""List all clients."""
count_result = await db.execute(select(func.count(Client.id)))
total = count_result.scalar() or 0
@ -58,9 +58,9 @@ async def list_clients(
async def get_client(
client_id: UUID,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_role(["admin"])),
current_user: dict = Depends(get_current_user),
) -> ClientResponse:
"""Get a client by ID (admin only)."""
"""Get a client by ID."""
result = await db.execute(select(Client).where(Client.id == client_id))
client = result.scalar_one_or_none()
if client is None:

View file

@ -17,6 +17,15 @@ file_service = FileService()
audit_service = AuditService()
def _enrich_job_response(job) -> JobResponse:
"""Build JobResponse with computed fields from relationships."""
resp = JobResponse.model_validate(job)
resp.client_name = job.client.name if job.client else None
resp.created_by_name = job.creator.name if job.creator else None
resp.source_line_count = len(job.source_lines) if hasattr(job, "source_lines") and job.source_lines else 0
return resp
@router.post("", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def create_job(
body: JobCreate,
@ -29,7 +38,9 @@ async def create_job(
db, "create", "job", str(job.id), current_user["user_id"],
details={"campaign_name": body.campaign_name},
)
return JobResponse.model_validate(job)
# Re-fetch with relationships loaded for enrichment
job = await job_service.get_job(db, job.id)
return _enrich_job_response(job)
@router.get("", response_model=PaginatedResponse[JobListResponse])
@ -73,6 +84,8 @@ async def list_jobs(
locale_count=len(job.locale_instances) if job.locale_instances else 0,
created_at=job.created_at,
updated_at=job.updated_at,
client_name=job.client.name if job.client else None,
created_by_name=job.creator.name if job.creator else None,
)
items.append(item)
@ -92,7 +105,7 @@ async def get_job(
job = await job_service.get_job(db, job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return JobResponse.model_validate(job)
return _enrich_job_response(job)
@router.put("/{job_id}/source")
@ -158,7 +171,9 @@ async def launch_job(
await audit_service.log(
db, "launch", "job", str(job_id), current_user["user_id"],
)
return JobResponse.model_validate(job)
# Re-fetch with relationships for enrichment
job = await job_service.get_job(db, job_id)
return _enrich_job_response(job)
@router.post("/{job_id}/cancel", response_model=JobResponse)
@ -178,7 +193,8 @@ async def cancel_job(
await audit_service.log(
db, "cancel", "job", str(job_id), current_user["user_id"],
)
return JobResponse.model_validate(job)
job = await job_service.get_job(db, job_id)
return _enrich_job_response(job)
@router.post(

View file

@ -1,7 +1,8 @@
"""Parse source xlsx files into structured source line data.
Validates the expected 5 column headers (case sensitive):
EN_GB, Copy Type, Creative Guidance, Visual Ref, Char Limit
Validates the expected column headers with flexible alias support:
EN_GB (required), Copy Type / Line type, Creative Guidance / Context notes,
Visual Ref, Char Limit.
Skips rows where EN_GB is empty. Detects \\n in EN_GB for is_display_format.
"""
@ -10,7 +11,16 @@ from typing import Any
from openpyxl import load_workbook
REQUIRED_HEADERS = ["EN_GB", "Copy Type", "Creative Guidance", "Visual Ref", "Char Limit"]
# Map of canonical field name -> list of accepted header variations (case-insensitive)
HEADER_ALIASES: dict[str, list[str]] = {
"en_gb": ["en_gb"],
"copy_type": ["copy type", "line type", "copy_type", "linetype"],
"creative_guidance": ["creative guidance", "context notes", "creative_guidance", "context_notes", "guidance"],
"visual_ref": ["visual ref", "visual_ref", "visual reference"],
"char_limit": ["char limit", "char_limit", "character limit", "charlimit"],
}
REQUIRED_FIELDS = ["en_gb"]
class SourceFileParseError(Exception):
@ -18,6 +28,23 @@ class SourceFileParseError(Exception):
pass
def _resolve_headers(raw_headers: list[str]) -> dict[str, int]:
"""Match raw file headers to canonical field names using aliases.
Returns a dict mapping canonical field name -> column index.
"""
resolved: dict[str, int] = {}
lower_headers = [h.lower().strip() for h in raw_headers]
for field, aliases in HEADER_ALIASES.items():
for alias in aliases:
if alias in lower_headers:
resolved[field] = lower_headers.index(alias)
break
return resolved
def parse_source_file(file_path: str) -> list[dict[str, Any]]:
"""Parse a source xlsx file and return a list of source line dicts.
@ -29,7 +56,7 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]:
visual_ref, char_limit, is_display_format.
Raises:
SourceFileParseError: If headers are invalid or file cannot be read.
SourceFileParseError: If required headers are missing or file cannot be read.
"""
try:
wb = load_workbook(file_path, read_only=True, data_only=True)
@ -46,24 +73,22 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]:
if first_row is None:
raise SourceFileParseError("File is empty - no header row found")
headers = [str(cell).strip() if cell else "" for cell in first_row]
raw_headers = [str(cell).strip() if cell else "" for cell in first_row]
col_map = _resolve_headers(raw_headers)
# Validate all required headers exist (case sensitive)
for required in REQUIRED_HEADERS:
if required not in headers:
# Validate required fields
for field in REQUIRED_FIELDS:
if field not in col_map:
raise SourceFileParseError(
f"Missing required header '{required}'. "
f"Found headers: {headers}. "
f"Expected: {REQUIRED_HEADERS}"
f"Missing required column matching '{field}'. "
f"Found headers: {raw_headers}. "
f"Accepted aliases: {HEADER_ALIASES[field]}"
)
# Build column index map
col_map = {header: idx for idx, header in enumerate(headers)}
# Parse data rows
source_lines: list[dict[str, Any]] = []
for row in ws.iter_rows(min_row=2, values_only=True):
en_gb_idx = col_map["EN_GB"]
en_gb_idx = col_map["en_gb"]
en_gb_raw = row[en_gb_idx] if en_gb_idx < len(row) else None
# Skip rows where EN_GB is empty
@ -75,8 +100,8 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]:
# Detect display format: presence of \n in EN_GB text
is_display_format = "\n" in en_gb
def _get_cell(header: str) -> str | None:
idx = col_map.get(header)
def _get_cell(field: str) -> str | None:
idx = col_map.get(field)
if idx is None or idx >= len(row):
return None
val = row[idx]
@ -86,10 +111,10 @@ def parse_source_file(file_path: str) -> list[dict[str, Any]]:
source_lines.append({
"en_gb": en_gb,
"copy_type": _get_cell("Copy Type"),
"creative_guidance": _get_cell("Creative Guidance"),
"visual_ref": _get_cell("Visual Ref"),
"char_limit": _get_cell("Char Limit"),
"copy_type": _get_cell("copy_type"),
"creative_guidance": _get_cell("creative_guidance"),
"visual_ref": _get_cell("visual_ref"),
"char_limit": _get_cell("char_limit"),
"is_display_format": is_display_format,
})

View file

@ -72,6 +72,10 @@ class JobResponse(BaseModel):
locale_instances: list[LocaleInstanceResponse] = []
created_at: datetime
updated_at: datetime
# Enrichment fields (populated by API layer)
client_name: str | None = None
created_by_name: str | None = None
source_line_count: int = 0
model_config = {"from_attributes": True}
@ -90,5 +94,8 @@ class JobListResponse(BaseModel):
locale_count: int = 0
created_at: datetime
updated_at: datetime
# Enrichment fields (populated by API layer)
client_name: str | None = None
created_by_name: str | None = None
model_config = {"from_attributes": True}

View file

@ -49,10 +49,15 @@ class JobService:
return job
async def get_job(self, db: AsyncSession, job_id: UUID) -> Job | None:
"""Get a job by ID with locale instances."""
"""Get a job by ID with locale instances and related data."""
result = await db.execute(
select(Job)
.options(selectinload(Job.locale_instances))
.options(
selectinload(Job.locale_instances),
selectinload(Job.client),
selectinload(Job.creator),
selectinload(Job.source_lines),
)
.where(Job.id == job_id)
)
return result.scalar_one_or_none()
@ -70,7 +75,11 @@ class JobService:
page_size: int = 20,
) -> tuple[list[Job], int]:
"""List jobs with filters and pagination."""
query = select(Job).options(selectinload(Job.locale_instances))
query = select(Job).options(
selectinload(Job.locale_instances),
selectinload(Job.client),
selectinload(Job.creator),
)
if client_id:
query = query.where(Job.client_id == client_id)

View file

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useEffect } from "react";
import { useParams } from "next/navigation";
import { AppShell } from "@/components/layout/AppShell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@ -8,6 +8,7 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { LocaleInstanceCard } from "@/components/jobs/MonitoringHUD/LocaleInstanceCard";
import { useJob } from "@/hooks/useJobs";
import { cancelJob } from "@/lib/api";
import {
Clock,
User,
@ -34,9 +35,32 @@ const statusConfig: Record<
export default function JobMonitoringPage() {
const params = useParams();
const jobId = params.jobId as string;
const { job, loading } = useJob(jobId);
const { job, loading, error, refetch } = useJob(jobId);
if (loading || !job) {
// Poll for updates while job is active
useEffect(() => {
if (!job) return;
const activeStatuses = ["QUEUED", "RUNNING"];
if (!activeStatuses.includes(job.status)) return;
const interval = setInterval(() => {
refetch();
}, 3000);
return () => clearInterval(interval);
}, [job?.status, refetch]);
const handleCancel = async () => {
if (!job) return;
try {
await cancelJob(job.id);
refetch();
} catch {
// Error handled by interceptor
}
};
if (loading && !job) {
return (
<AppShell>
<div className="space-y-4">
@ -49,6 +73,19 @@ export default function JobMonitoringPage() {
);
}
if (error || !job) {
return (
<AppShell>
<Card>
<CardContent className="py-12 text-center">
<XCircle className="h-12 w-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500">{error || "Job not found"}</p>
</CardContent>
</Card>
</AppShell>
);
}
const status = statusConfig[job.status] || statusConfig.DRAFT;
return (
@ -91,7 +128,7 @@ export default function JobMonitoringPage() {
<User className="h-3.5 w-3.5" />
{job.created_by}
</span>
{job.total_lines && (
{(job.total_lines ?? 0) > 0 && (
<span className="flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
{job.total_lines} source lines
@ -102,8 +139,8 @@ export default function JobMonitoringPage() {
{/* Actions */}
<div className="flex gap-2">
{job.status === "RUNNING" && (
<Button variant="outline" size="sm" className="gap-1.5">
{(job.status === "RUNNING" || job.status === "QUEUED") && (
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleCancel}>
<StopCircle className="h-4 w-4" />
Cancel
</Button>
@ -126,15 +163,23 @@ export default function JobMonitoringPage() {
<h3 className="text-lg font-bold text-amazon-text mb-4">
Locale Progress
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{job.locale_instances.map((li) => (
<LocaleInstanceCard
key={li.id}
localeInstance={li}
jobId={job.id}
/>
))}
</div>
{job.locale_instances.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{job.locale_instances.map((li) => (
<LocaleInstanceCard
key={li.id}
localeInstance={li}
jobId={job.id}
/>
))}
</div>
) : (
<Card>
<CardContent className="py-8 text-center text-gray-400">
No locale instances yet.
</CardContent>
</Card>
)}
</div>
{/* Summary stats */}

View file

@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -15,6 +15,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { JobFormData } from "@/app/jobs/new/page";
import type { Client } from "@/lib/types";
import { getClients } from "@/lib/api";
const MAIN_LOCALES = [
"de-DE",
@ -29,13 +31,6 @@ const MAIN_LOCALES = [
const DERIVED_LOCALES = ["de-AT", "fr-BE", "nl-BE", "ca-ES"];
const CLIENTS = [
{ id: "client-001", name: "OMG" },
{ id: "client-002", name: "Mindshare" },
{ id: "client-003", name: "PHD" },
{ id: "client-004", name: "EssenceMediacom" },
];
const CHANNELS = ["MASS", "VALUE", "ONSITE", "OUTBOUND", "BDA", "UEFA"];
const SUB_CHANNELS = [
@ -56,6 +51,14 @@ interface StepConfigureProps {
}
export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) {
const [clients, setClients] = useState<Client[]>([]);
useEffect(() => {
getClients()
.then((data) => setClients(data))
.catch(() => {});
}, []);
const locales =
data.job_type === "MAIN" ? MAIN_LOCALES : DERIVED_LOCALES;
@ -89,7 +92,7 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) {
<Select
value={data.client_id}
onValueChange={(val) => {
const client = CLIENTS.find((c) => c.id === val);
const client = clients.find((c) => c.id === val);
onChange({
client_id: val,
client_name: client?.name || "",
@ -100,7 +103,7 @@ export function StepConfigure({ data, onChange, onNext }: StepConfigureProps) {
<SelectValue placeholder="Select client" />
</SelectTrigger>
<SelectContent>
{CLIENTS.map((c) => (
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>

View file

@ -11,12 +11,14 @@ import {
CollapsibleContent,
} from "@/components/ui/collapsible";
import type { JobFormData } from "@/app/jobs/new/page";
import { createJob, uploadSource, uploadSupplementary, launchJob } from "@/lib/api";
import {
ChevronDown,
Rocket,
FileSpreadsheet,
File,
AlertTriangle,
AlertCircle,
Globe,
Check,
} from "lucide-react";
@ -30,33 +32,73 @@ interface StepReviewProps {
export function StepReview({ data, onBack }: StepReviewProps) {
const router = useRouter();
const [launching, setLaunching] = useState(false);
const [launchStatus, setLaunchStatus] = useState("");
const [error, setError] = useState<string | null>(null);
const [overrideOpen, setOverrideOpen] = useState(false);
const [filesOpen, setFilesOpen] = useState(true);
const handleLaunch = async () => {
setLaunching(true);
// Simulate launch delay
await new Promise((resolve) => setTimeout(resolve, 1500));
router.push("/jobs/job-001");
setError(null);
try {
// 1. Create job
setLaunchStatus("Creating job...");
const job = await createJob({
client_id: data.client_id,
job_ref: data.job_ref,
campaign_name: data.campaign_name,
programme: data.programme.toLowerCase(),
channel: data.channel.toLowerCase(),
sub_channel: data.sub_channel ? data.sub_channel.toLowerCase() : null,
job_type: data.job_type.toLowerCase(),
locale_codes: data.locales,
context_prompt: data.context_override || undefined,
});
// 2. Upload source file
if (data.source_file) {
setLaunchStatus("Uploading source file...");
await uploadSource(job.id, data.source_file);
}
// 3. Upload supplementary files
for (let i = 0; i < data.supplementary_files.length; i++) {
setLaunchStatus(`Uploading supplementary file ${i + 1}/${data.supplementary_files.length}...`);
await uploadSupplementary(job.id, data.supplementary_files[i]);
}
// 4. Launch the job
setLaunchStatus("Launching pipeline...");
await launchJob(job.id);
// 5. Navigate to monitoring
router.push(`/jobs/${job.id}`);
} catch (err: unknown) {
const axiosErr = err as { response?: { data?: { detail?: string } }; message?: string };
setError(
axiosErr?.response?.data?.detail ||
axiosErr?.message ||
"Failed to launch job. Please try again."
);
setLaunching(false);
setLaunchStatus("");
}
};
// Mock files that would be loaded per locale
const globalFiles = [
"Global_TM.tmx",
"Brand_Guidelines_v3.pdf",
"Glossary_Master.xlsx",
];
const localeFiles: Record<string, string[]> = {};
data.locales.forEach((locale) => {
localeFiles[locale] = [
`TM_${locale}.tmx`,
`StyleGuide_${locale}.pdf`,
];
});
return (
<div className="space-y-6">
{/* Error display */}
{error && (
<div className="flex items-start gap-2 text-sm text-amazon-error bg-amazon-error/5 border border-amazon-error/20 rounded-lg p-4">
<AlertCircle className="h-4 w-4 mt-0.5 shrink-0" />
<div>
<p className="font-medium">Launch failed</p>
<p className="mt-1">{error}</p>
</div>
</div>
)}
{/* Configuration Summary */}
<Card>
<CardHeader>
@ -149,8 +191,7 @@ export function StepReview({ data, onBack }: StepReviewProps) {
<div className="flex-1">
<p className="text-sm font-medium">{data.source_file.name}</p>
<p className="text-xs text-gray-500">
{(data.source_file.size / 1024).toFixed(1)} KB | 42 lines | 8
display formats
{(data.source_file.size / 1024).toFixed(1)} KB
</p>
</div>
</div>
@ -227,7 +268,7 @@ export function StepReview({ data, onBack }: StepReviewProps) {
<CollapsibleTrigger className="flex items-center justify-between w-full">
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5 text-amazon-teal" />
Files to be Loaded
Target Locales ({data.locales.length})
</CardTitle>
<ChevronDown
className={cn(
@ -238,44 +279,17 @@ export function StepReview({ data, onBack }: StepReviewProps) {
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-4">
{/* Global files */}
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2">
Global Files
</p>
<div className="space-y-1">
{globalFiles.map((f) => (
<div
key={f}
className="flex items-center gap-2 text-sm p-1.5"
>
<Check className="h-3.5 w-3.5 text-amazon-success" />
{f}
</div>
))}
</div>
</div>
{/* Per-locale files */}
{data.locales.map((locale) => (
<div key={locale}>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2">
<CardContent>
<div className="flex flex-wrap gap-2">
{data.locales.map((locale) => (
<Badge key={locale} variant="gray" className="text-sm py-1 px-3">
{locale}
</p>
<div className="space-y-1">
{(localeFiles[locale] || []).map((f) => (
<div
key={f}
className="flex items-center gap-2 text-sm p-1.5"
>
<Check className="h-3.5 w-3.5 text-amazon-success" />
{f}
</div>
))}
</div>
</div>
))}
</Badge>
))}
</div>
<p className="text-xs text-gray-400 mt-3">
TM and reference files will be automatically resolved per locale when the pipeline runs.
</p>
</CardContent>
</CollapsibleContent>
</Card>
@ -283,7 +297,7 @@ export function StepReview({ data, onBack }: StepReviewProps) {
{/* Actions */}
<div className="flex items-center justify-between pt-2">
<Button variant="outline" onClick={onBack}>
<Button variant="outline" onClick={onBack} disabled={launching}>
Back
</Button>
<Button
@ -295,7 +309,7 @@ export function StepReview({ data, onBack }: StepReviewProps) {
{launching ? (
<>
<div className="h-4 w-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Launching...
{launchStatus || "Launching..."}
</>
) : (
<>

View file

@ -4,164 +4,23 @@ import { useState, useEffect, useCallback } from "react";
import type { Job, JobFilters, PaginatedResponse } from "@/lib/types";
import { getJobs, getJob } from "@/lib/api";
// Mock data for development
const mockJobs: Job[] = [
{
id: "job-001",
job_ref: "OMG-2026-0044",
campaign_name: "DDA 26 (BFW)",
client_id: "client-001",
client_name: "OMG",
programme: "RETAIL" as Job["programme"],
channel: "MASS" as Job["channel"],
sub_channel: "TV_OLV" as Job["sub_channel"],
job_type: "MAIN" as Job["job_type"],
locales: ["de-DE", "fr-FR", "it-IT", "es-ES"],
status: "RUNNING" as Job["status"],
created_by: "Sarah Chen",
created_at: "2026-04-08T14:30:00Z",
updated_at: "2026-04-10T09:15:00Z",
total_lines: 42,
locale_instances: [
{ id: "li-1", job_id: "job-001", locale_code: "de-DE", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 15420, tokens_output: 12830 },
{ id: "li-2", job_id: "job-001", locale_code: "fr-FR", status: "TRANSLATING" as never, progress: 67, current_stage: "Translating", tokens_input: 10200, tokens_output: 8540 },
{ id: "li-3", job_id: "job-001", locale_code: "it-IT", status: "LOADING_FILES" as never, progress: 25, current_stage: "Loading Files", tokens_input: 3100, tokens_output: 0 },
{ id: "li-4", job_id: "job-001", locale_code: "es-ES", status: "QUEUED" as never, progress: 0, current_stage: "Queued", tokens_input: 0, tokens_output: 0 },
],
},
{
id: "job-002",
job_ref: "OMG-2026-0043",
campaign_name: "Spring Prime Day 2026",
client_id: "client-001",
client_name: "OMG",
programme: "PRIME" as Job["programme"],
channel: "ONSITE" as Job["channel"],
sub_channel: "DISPLAY" as Job["sub_channel"],
job_type: "MAIN" as Job["job_type"],
locales: ["de-DE", "fr-FR", "it-IT", "es-ES", "nl-NL", "sv-SE"],
status: "COMPLETED" as Job["status"],
created_by: "James Miller",
created_at: "2026-04-05T10:00:00Z",
updated_at: "2026-04-06T16:42:00Z",
completed_at: "2026-04-06T16:42:00Z",
total_lines: 28,
locale_instances: [
{ id: "li-5", job_id: "job-002", locale_code: "de-DE", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 8900, tokens_output: 7200, duration_seconds: 245 },
{ id: "li-6", job_id: "job-002", locale_code: "fr-FR", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 8900, tokens_output: 7400, duration_seconds: 268 },
{ id: "li-7", job_id: "job-002", locale_code: "it-IT", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 8900, tokens_output: 7600, duration_seconds: 251 },
{ id: "li-8", job_id: "job-002", locale_code: "es-ES", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 8900, tokens_output: 7100, duration_seconds: 230 },
{ id: "li-9", job_id: "job-002", locale_code: "nl-NL", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 8900, tokens_output: 6900, duration_seconds: 212 },
{ id: "li-10", job_id: "job-002", locale_code: "sv-SE", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 8900, tokens_output: 7000, duration_seconds: 228 },
],
},
{
id: "job-003",
job_ref: "MND-2026-0012",
campaign_name: "UEFA Champions League Promo",
client_id: "client-002",
client_name: "Mindshare",
programme: "BRAND" as Job["programme"],
channel: "BDA" as Job["channel"],
sub_channel: "SOCIAL" as Job["sub_channel"],
job_type: "MAIN" as Job["job_type"],
locales: ["de-DE", "fr-FR", "es-ES"],
status: "ERROR" as Job["status"],
created_by: "Sarah Chen",
created_at: "2026-04-09T08:20:00Z",
updated_at: "2026-04-09T09:45:00Z",
total_lines: 18,
error_message: "TM file not found for fr-FR locale",
locale_instances: [
{ id: "li-11", job_id: "job-003", locale_code: "de-DE", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 5600, tokens_output: 4800, duration_seconds: 142 },
{ id: "li-12", job_id: "job-003", locale_code: "fr-FR", status: "ERROR" as never, progress: 15, current_stage: "Error", tokens_input: 1200, tokens_output: 0, error_message: "TM file not found" },
{ id: "li-13", job_id: "job-003", locale_code: "es-ES", status: "QUEUED" as never, progress: 0, current_stage: "Queued", tokens_input: 0, tokens_output: 0 },
],
},
{
id: "job-004",
job_ref: "OMG-2026-0045",
campaign_name: "Always On - Q2 2026",
client_id: "client-001",
client_name: "OMG",
programme: "RETAIL" as Job["programme"],
channel: "OUTBOUND" as Job["channel"],
sub_channel: "CRM" as Job["sub_channel"],
job_type: "DERIVED" as Job["job_type"],
locales: ["de-AT", "fr-BE", "nl-BE"],
status: "QUEUED" as Job["status"],
created_by: "James Miller",
created_at: "2026-04-10T07:00:00Z",
updated_at: "2026-04-10T07:00:00Z",
total_lines: 35,
locale_instances: [
{ id: "li-14", job_id: "job-004", locale_code: "de-AT", status: "QUEUED" as never, progress: 0, current_stage: "Queued", tokens_input: 0, tokens_output: 0 },
{ id: "li-15", job_id: "job-004", locale_code: "fr-BE", status: "QUEUED" as never, progress: 0, current_stage: "Queued", tokens_input: 0, tokens_output: 0 },
{ id: "li-16", job_id: "job-004", locale_code: "nl-BE", status: "QUEUED" as never, progress: 0, current_stage: "Queued", tokens_input: 0, tokens_output: 0 },
],
},
{
id: "job-005",
job_ref: "PHD-2026-0008",
campaign_name: "Summer Sale Landing Pages",
client_id: "client-003",
client_name: "PHD",
programme: "RETAIL" as Job["programme"],
channel: "ONSITE" as Job["channel"],
sub_channel: null,
job_type: "MAIN" as Job["job_type"],
locales: ["de-DE", "fr-FR", "it-IT", "es-ES", "pl-PL", "pt-PT"],
status: "COMPLETED" as Job["status"],
created_by: "Emily Brown",
created_at: "2026-04-03T11:15:00Z",
updated_at: "2026-04-04T08:30:00Z",
completed_at: "2026-04-04T08:30:00Z",
total_lines: 56,
locale_instances: [
{ id: "li-17", job_id: "job-005", locale_code: "de-DE", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 18200, tokens_output: 15600, duration_seconds: 480 },
{ id: "li-18", job_id: "job-005", locale_code: "fr-FR", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 18200, tokens_output: 15900, duration_seconds: 492 },
{ id: "li-19", job_id: "job-005", locale_code: "it-IT", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 18200, tokens_output: 16100, duration_seconds: 501 },
{ id: "li-20", job_id: "job-005", locale_code: "es-ES", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 18200, tokens_output: 15800, duration_seconds: 475 },
{ id: "li-21", job_id: "job-005", locale_code: "pl-PL", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 18200, tokens_output: 16400, duration_seconds: 515 },
{ id: "li-22", job_id: "job-005", locale_code: "pt-PT", status: "COMPLETED" as never, progress: 100, current_stage: "Complete", tokens_input: 18200, tokens_output: 15500, duration_seconds: 468 },
],
},
];
export function useJobs(filters?: JobFilters) {
const [jobs, setJobs] = useState<Job[]>(mockJobs);
const [loading, setLoading] = useState(false);
const [jobs, setJobs] = useState<Job[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [total, setTotal] = useState(mockJobs.length);
const [total, setTotal] = useState(0);
const fetchJobs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await getJobs(filters);
const response: PaginatedResponse<Job> = await getJobs(filters);
setJobs(response.items);
setTotal(response.total);
} catch {
// Use mock data on API failure
let filtered = [...mockJobs];
if (filters?.search) {
const search = filters.search.toLowerCase();
filtered = filtered.filter(
(j) =>
j.campaign_name.toLowerCase().includes(search) ||
j.job_ref.toLowerCase().includes(search)
);
}
if (filters?.status?.length) {
filtered = filtered.filter((j) =>
filters.status!.includes(j.status)
);
}
if (filters?.client_id) {
filtered = filtered.filter((j) => j.client_id === filters.client_id);
}
setJobs(filtered);
setTotal(filtered.length);
setError("Failed to load jobs");
setJobs([]);
setTotal(0);
} finally {
setLoading(false);
}
@ -180,19 +39,15 @@ export function useJob(jobId: string) {
const [error, setError] = useState<string | null>(null);
const fetchJob = useCallback(async () => {
if (!jobId) return;
setLoading(true);
setError(null);
try {
const data = await getJob(jobId);
setJob(data);
} catch {
// Use mock data on API failure
const mockJob = mockJobs.find((j) => j.id === jobId);
if (mockJob) {
setJob(mockJob);
} else {
setJob(mockJobs[0]);
}
setError("Failed to load job");
setJob(null);
} finally {
setLoading(false);
}

View file

@ -4,6 +4,7 @@ import type {
LoginRequest,
LoginResponse,
Job,
LocaleInstance,
CreateJobRequest,
JobFilters,
OutputRow,
@ -41,38 +42,137 @@ api.interceptors.response.use(
if (error.response?.status === 401) {
clearAuth();
if (typeof window !== "undefined") {
window.location.href = "/login";
const basePath = process.env.NEXT_PUBLIC_BASE_PATH || "";
window.location.href = `${basePath}/login`;
}
}
return Promise.reject(error);
}
);
// Auth
// ─── Response Mappers ────────────────────────────────────────────────
function mapJobStatus(status: string): string {
const map: Record<string, string> = {
created: "DRAFT",
validating: "QUEUED",
queued: "QUEUED",
running: "RUNNING",
partial_complete: "PARTIAL",
complete: "COMPLETED",
error: "ERROR",
exported: "COMPLETED",
};
return map[status] || "DRAFT";
}
function mapLocaleStatus(status: string): string {
const map: Record<string, string> = {
queued: "QUEUED",
running: "TRANSLATING",
complete: "COMPLETED",
error: "ERROR",
};
return map[status] || "QUEUED";
}
function mapLocaleInstance(li: Record<string, unknown>): LocaleInstance {
const status = mapLocaleStatus(li.status as string);
return {
id: li.id as string,
job_id: li.job_id as string,
locale_code: li.locale_code as string,
status: status as LocaleInstance["status"],
progress: status === "COMPLETED" ? 100 : status === "ERROR" ? 0 : status === "QUEUED" ? 0 : 50,
current_stage:
status === "COMPLETED" ? "Complete" :
status === "ERROR" ? "Error" :
status === "QUEUED" ? "Queued" :
"Processing",
tokens_input: (li.token_usage as number) || 0,
tokens_output: 0,
started_at: li.started_at as string | undefined,
completed_at: li.completed_at as string | undefined,
error_message: li.error_log as string | undefined,
};
}
/* eslint-disable @typescript-eslint/no-explicit-any */
function mapJobResponse(data: any): Job {
const instances = data.locale_instances?.map(mapLocaleInstance) || [];
return {
id: data.id,
job_ref: data.job_ref || "",
campaign_name: data.campaign_name,
client_id: data.client_id,
client_name: data.client_name || "",
programme: (data.programme?.toUpperCase() || "RETAIL") as Job["programme"],
channel: (data.channel?.toUpperCase() || "") as Job["channel"],
sub_channel: data.sub_channel ? (data.sub_channel.toUpperCase() as Job["sub_channel"]) : null,
job_type: (data.job_type?.toUpperCase() || "MAIN") as Job["job_type"],
locales: instances.map((li: LocaleInstance) => li.locale_code),
status: mapJobStatus(data.status) as Job["status"],
created_by: data.created_by_name || data.created_by || "",
created_at: data.created_at,
updated_at: data.updated_at,
locale_instances: instances,
total_lines: data.source_line_count || 0,
context_override: data.context_prompt,
};
}
function mapJobListResponse(data: any): Job {
return {
id: data.id,
job_ref: data.job_ref || "",
campaign_name: data.campaign_name,
client_id: data.client_id,
client_name: data.client_name || "",
programme: (data.programme?.toUpperCase() || "RETAIL") as Job["programme"],
channel: (data.channel?.toUpperCase() || "") as Job["channel"],
sub_channel: data.sub_channel ? (data.sub_channel.toUpperCase() as Job["sub_channel"]) : null,
job_type: "MAIN" as Job["job_type"],
locales: [],
status: mapJobStatus(data.status) as Job["status"],
created_by: data.created_by_name || data.created_by || "",
created_at: data.created_at,
updated_at: data.updated_at,
locale_instances: [],
total_lines: 0,
};
}
/* eslint-enable @typescript-eslint/no-explicit-any */
// ─── Auth ────────────────────────────────────────────────────────────
export async function login(data: LoginRequest): Promise<LoginResponse> {
const response = await api.post<LoginResponse>("/auth/login", data);
return response.data;
}
// Jobs
// ─── Jobs ────────────────────────────────────────────────────────────
export async function getJobs(
filters?: JobFilters,
page = 1,
perPage = 20
pageSize = 20
): Promise<PaginatedResponse<Job>> {
const params = { ...filters, page, per_page: perPage };
const response = await api.get<PaginatedResponse<Job>>("/jobs", { params });
return response.data;
const params = { ...filters, page, page_size: pageSize };
const response = await api.get("/jobs", { params });
return {
...response.data,
items: response.data.items.map(mapJobListResponse),
};
}
export async function getJob(jobId: string): Promise<Job> {
const response = await api.get<Job>(`/jobs/${jobId}`);
return response.data;
const response = await api.get(`/jobs/${jobId}`);
return mapJobResponse(response.data);
}
export async function createJob(data: CreateJobRequest): Promise<Job> {
const response = await api.post<Job>("/jobs", data);
return response.data;
const response = await api.post("/jobs", data);
return mapJobResponse(response.data);
}
export async function uploadSource(
@ -81,7 +181,7 @@ export async function uploadSource(
): Promise<ValidationResult> {
const formData = new FormData();
formData.append("file", file);
const response = await api.post<ValidationResult>(
const response = await api.put<ValidationResult>(
`/jobs/${jobId}/source`,
formData,
{ headers: { "Content-Type": "multipart/form-data" } }
@ -91,10 +191,10 @@ export async function uploadSource(
export async function uploadSupplementary(
jobId: string,
files: File[]
): Promise<{ uploaded: number }> {
file: File
): Promise<{ message: string; file_path: string }> {
const formData = new FormData();
files.forEach((f) => formData.append("files", f));
formData.append("file", file);
const response = await api.post(`/jobs/${jobId}/supplementary`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
@ -102,13 +202,13 @@ export async function uploadSupplementary(
}
export async function launchJob(jobId: string): Promise<Job> {
const response = await api.post<Job>(`/jobs/${jobId}/launch`);
return response.data;
const response = await api.post(`/jobs/${jobId}/launch`);
return mapJobResponse(response.data);
}
export async function cancelJob(jobId: string): Promise<Job> {
const response = await api.post<Job>(`/jobs/${jobId}/cancel`);
return response.data;
const response = await api.post(`/jobs/${jobId}/cancel`);
return mapJobResponse(response.data);
}
export async function rerunLocale(
@ -118,7 +218,8 @@ export async function rerunLocale(
await api.post(`/jobs/${jobId}/locales/${localeCode}/rerun`);
}
// Output
// ─── Output ──────────────────────────────────────────────────────────
export async function getOutput(
jobId: string,
localeCode: string
@ -140,7 +241,8 @@ export async function downloadOutput(
return response.data;
}
// Feedback
// ─── Feedback ────────────────────────────────────────────────────────
export async function submitFeedback(
outputRowId: string,
data: Partial<Feedback>
@ -152,7 +254,8 @@ export async function submitFeedback(
return response.data;
}
// Users
// ─── Users ───────────────────────────────────────────────────────────
export async function getUsers(): Promise<User[]> {
const response = await api.get<User[]>("/users");
return response.data;
@ -177,10 +280,12 @@ export async function deleteUser(userId: string): Promise<void> {
await api.delete(`/users/${userId}`);
}
// Clients
// ─── Clients ─────────────────────────────────────────────────────────
export async function getClients(): Promise<Client[]> {
const response = await api.get<Client[]>("/clients");
return response.data;
const response = await api.get("/clients");
// Backend returns paginated response
return response.data.items || response.data;
}
export async function createClient(data: Partial<Client>): Promise<Client> {
@ -196,7 +301,8 @@ export async function updateClient(
return response.data;
}
// TM Files
// ─── TM Files ────────────────────────────────────────────────────────
export async function getTMFiles(): Promise<TMFile[]> {
const response = await api.get<TMFile[]>("/files/tm");
return response.data;
@ -221,7 +327,8 @@ export async function deleteTMFile(fileId: string): Promise<void> {
await api.delete(`/files/tm/${fileId}`);
}
// Reference Files
// ─── Reference Files ─────────────────────────────────────────────────
export async function getReferenceFiles(): Promise<ReferenceFile[]> {
const response = await api.get<ReferenceFile[]>("/files/reference");
return response.data;
@ -248,7 +355,8 @@ export async function deleteReferenceFile(fileId: string): Promise<void> {
await api.delete(`/files/reference/${fileId}`);
}
// Analytics
// ─── Analytics ───────────────────────────────────────────────────────
export async function getAnalytics(params?: {
from?: string;
to?: string;
@ -258,10 +366,11 @@ export async function getAnalytics(params?: {
return response.data;
}
// Audit
// ─── Audit ───────────────────────────────────────────────────────────
export async function getAuditLogs(params?: {
page?: number;
per_page?: number;
page_size?: number;
action?: string;
}): Promise<PaginatedResponse<Record<string, unknown>>> {
const response = await api.get("/audit", { params });

View file

@ -208,12 +208,12 @@ export interface CreateJobRequest {
job_ref: string;
campaign_name: string;
client_id: string;
programme: Programme;
channel: Channel;
sub_channel?: SubChannel | null;
job_type: JobType;
locales: string[];
context_override?: string;
programme: string;
channel: string;
sub_channel?: string | null;
job_type: string;
locale_codes: string[];
context_prompt?: string;
}
export interface JobFilters {
@ -228,7 +228,7 @@ export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
page_size: number;
pages: number;
}