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:
parent
1d94bfc005
commit
f271343bc0
11 changed files with 393 additions and 310 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue