diff --git a/backend/app/api/v1/jobs.py b/backend/app/api/v1/jobs.py index 3c9e0c5..f6f85a1 100644 --- a/backend/app/api/v1/jobs.py +++ b/backend/app/api/v1/jobs.py @@ -108,6 +108,82 @@ async def get_job( return _enrich_job_response(job) +@router.post("/validate-source") +async def validate_source( + file: UploadFile = File(...), + current_user: dict = Depends(get_current_user), +) -> dict: + """Validate a source xlsx file without saving. Returns line count, columns, etc.""" + import tempfile, shutil + from app.pipeline.modules.source_file_parser import parse_source_file, _resolve_headers, HEADER_ALIASES + + if not file.filename or not file.filename.endswith(".xlsx"): + raise HTTPException(status_code=400, detail="Only .xlsx files are accepted") + + # Write to temp file for parsing + with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: + shutil.copyfileobj(file.file, tmp) + tmp_path = tmp.name + + try: + from openpyxl import load_workbook + wb = load_workbook(tmp_path, read_only=True, data_only=True) + ws = wb.active + if ws is None: + raise HTTPException(status_code=400, detail="Workbook has no active sheet") + + # Read headers + first_row = next(ws.iter_rows(min_row=1, max_row=1, values_only=True), None) + if first_row is None: + raise HTTPException(status_code=400, detail="File is empty") + + raw_headers = [str(cell).strip() if cell else "" for cell in first_row] + col_map = _resolve_headers(raw_headers) + wb.close() + + # Parse lines + parsed_lines = parse_source_file(tmp_path) + + # Build column check results + columns = [] + for field, aliases in HEADER_ALIASES.items(): + columns.append({ + "name": field, + "found": field in col_map, + "required": field == "en_gb", + }) + + display_format_count = sum(1 for l in parsed_lines if l.get("is_display_format")) + char_limit_missing = sum(1 for l in parsed_lines if not l.get("char_limit")) + + warnings = [] + if char_limit_missing > 0: + warnings.append(f"{char_limit_missing} rows have empty char_limit values") + + return { + "valid": True, + "total_lines": len(parsed_lines), + "display_format_count": display_format_count, + "columns": columns, + "errors": [], + "warnings": warnings, + } + except HTTPException: + raise + except Exception as e: + return { + "valid": False, + "total_lines": 0, + "display_format_count": 0, + "columns": [], + "errors": [str(e)], + "warnings": [], + } + finally: + import os + os.unlink(tmp_path) + + @router.put("/{job_id}/source") async def upload_source( job_id: UUID, diff --git a/frontend/src/components/jobs/JobWizard/StepUpload.tsx b/frontend/src/components/jobs/JobWizard/StepUpload.tsx index 846cdb7..dbfc266 100644 --- a/frontend/src/components/jobs/JobWizard/StepUpload.tsx +++ b/frontend/src/components/jobs/JobWizard/StepUpload.tsx @@ -5,8 +5,9 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Textarea } from "@/components/ui/textarea"; -import { Label } from "@/components/ui/label"; import type { JobFormData } from "@/app/jobs/new/page"; +import type { ValidationResult } from "@/lib/types"; +import { validateSource } from "@/lib/api"; import { Upload, FileSpreadsheet, @@ -15,6 +16,7 @@ import { XCircle, AlertTriangle, File, + Loader2, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -25,25 +27,30 @@ interface StepUploadProps { onNext: () => void; } -// Mock validation result -const mockValidation = { - valid: true, - total_lines: 42, - display_format_count: 8, - columns: [ - { name: "copy_type", found: true, required: true }, - { name: "source_text", found: true, required: true }, - { name: "char_limit", found: true, required: false }, - { name: "display_format", found: true, required: false }, - { name: "notes", found: true, required: false }, - ], - errors: [], - warnings: ["2 rows have empty char_limit values"], -}; - export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) { const [dragActive, setDragActive] = useState(false); - const [validated, setValidated] = useState(false); + const [validating, setValidating] = useState(false); + const [validation, setValidation] = useState(null); + const [validationError, setValidationError] = useState(null); + + const handleValidate = useCallback( + async (file: File) => { + onChange({ source_file: file }); + setValidating(true); + setValidation(null); + setValidationError(null); + + try { + const result = await validateSource(file); + setValidation(result); + } catch { + setValidationError("Failed to validate file. Please try again."); + } finally { + setValidating(false); + } + }, + [onChange] + ); const handleSourceDrop = useCallback( (e: React.DragEvent) => { @@ -51,22 +58,20 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) setDragActive(false); const file = e.dataTransfer.files[0]; if (file && (file.name.endsWith(".xlsx") || file.name.endsWith(".xls"))) { - onChange({ source_file: file }); - setValidated(true); + handleValidate(file); } }, - [onChange] + [handleValidate] ); const handleSourceSelect = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { - onChange({ source_file: file }); - setValidated(true); + handleValidate(file); } }, - [onChange] + [handleValidate] ); const handleSupplementarySelect = useCallback( @@ -85,6 +90,12 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) }); }; + const clearSource = () => { + onChange({ source_file: null }); + setValidation(null); + setValidationError(null); + }; + return (
{/* Source file upload */} @@ -141,33 +152,54 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)

+ {/* Validating spinner */} + {validating && ( +
+ + Validating file... +
+ )} + + {/* Validation error */} + {validationError && ( +
+ + {validationError} +
+ )} + {/* Validation results */} - {validated && ( + {validation && (
-
- - Validation passed -
+ {validation.valid ? ( +
+ + Validation passed +
+ ) : ( +
+ + Validation failed +
+ )} +

- {mockValidation.total_lines} + {validation.total_lines}

Total Lines

- {mockValidation.display_format_count} + {validation.display_format_count}

Display Formats @@ -175,8 +207,8 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps)

- {mockValidation.columns.filter((c) => c.found).length}/ - {mockValidation.columns.length} + {validation.columns.filter((c) => c.found).length}/ + {validation.columns.length}

Columns Found

@@ -184,7 +216,7 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) {/* Column checks */}
- {mockValidation.columns.map((col) => ( + {validation.columns.map((col) => (
+ {/* Errors */} + {validation.errors.length > 0 && ( +
+ +
+ {validation.errors.map((e, i) => ( +

{e}

+ ))} +
+
+ )} + {/* Warnings */} - {mockValidation.warnings.length > 0 && ( + {validation.warnings.length > 0 && (
- {mockValidation.warnings.map((w, i) => ( + {validation.warnings.map((w, i) => (

{w}

))}
@@ -302,7 +346,10 @@ export function StepUpload({ data, onChange, onBack, onNext }: StepUploadProps) -
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ad1bd7b..93d812d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -240,6 +240,19 @@ export async function uploadSource( return response.data; } +export async function validateSource( + file: File +): Promise { + const formData = new FormData(); + formData.append("file", file); + const response = await api.post( + "/jobs/validate-source", + formData, + { headers: { "Content-Type": "multipart/form-data" } } + ); + return response.data; +} + export async function uploadSupplementary( jobId: string, file: File