feat(review,teams): review foundation — segments, side-by-side editor, rebuild, XLIFF, team workspaces
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 2m14s

Foundations:
- TranslationSegment model + migration f7e8d9c0b1a2 (segments, workspaces,
  workspace_members, glossaries.workspace_id)
- SegmentRecorder injected into all 4 translators: unique (source,
  translation) pairs captured per job and persisted (best-effort)
- set_segment_overrides: human-reviewed translations applied verbatim on
  rebuild — top priority over TM and provider, zero API calls

Review API (routes/review_routes.py):
- GET /translations/{id}/segments (owner or job token)
- PATCH /segments/{id} edit/approve — feeds the per-user TM so approved
  translations are reused in later jobs
- POST /translations/{id}/rebuild — rebuild document with reviewed text
- GET/POST /translations/{id}/xliff — XLIFF 1.2 export/import (edited
  segments export their reviewed text)

Review editor (frontend /dashboard/reviews/[jobId]):
- side-by-side source/translation table, inline edit, approve (single or
  all), rebuild & download (auth blob), XLIFF export/import, 13 locales
- 'Relire et corriger' link on the translation-complete screen

Team workspaces (routes/workspace_routes.py + /dashboard/teams):
- Workspace/WorkspaceMember models, roles owner/admin/member
- create (Business plan), list with seat usage, invite by email with
  seat-limit enforcement (Business=5, Enterprise unlimited), removal
- shared glossaries: workspace members can use a glossary shared to their
  workspace (access check extended)

Tests: 1184 passed / 0 failed (11 new: recorder, overrides, docx
capture->rebuild e2e, XLIFF structure/escaping, seats, workspace CRUD,
shared glossary access)
This commit is contained in:
2026-08-29 19:04:32 +02:00
parent 526c87348f
commit b4e873ad2c
31 changed files with 2399 additions and 54 deletions

View File

@@ -0,0 +1,400 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'next/navigation';
import {
Check,
CheckCheck,
Download,
FileDown,
FileUp,
Loader2,
Pencil,
Undo2,
} from 'lucide-react';
import { apiClient, API_BASE_URL } from '@/lib/apiClient';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/toast';
import { cn } from '@/lib/utils';
interface Segment {
id: string;
segment_index: number;
source_text: string;
translated_text: string;
status: 'pending' | 'approved' | 'edited';
reviewed_text: string | null;
}
interface SegmentsResponse {
data: {
job_id: string;
file_name: string | null;
status: string;
segments: Segment[];
counts: { total: number; pending: number; approved: number; edited: number };
};
}
interface RebuildResponse {
data: { job_id: string; rebuilt: boolean; segments_applied: number; download_url: string };
}
function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
async function downloadProtected(url: string, filename: string) {
const res = await fetch(`${API_BASE_URL}${url}`, { headers: authHeaders() });
if (!res.ok) throw new Error(`Téléchargement échoué (HTTP ${res.status})`);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(objectUrl);
}
export default function ReviewPage() {
const params = useParams<{ jobId: string }>();
const jobId = params?.jobId ?? '';
const notify = useToast();
const [segments, setSegments] = useState<Segment[]>([]);
const [fileName, setFileName] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState('');
const [savingId, setSavingId] = useState<string | null>(null);
const [isRebuilding, setIsRebuilding] = useState(false);
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const load = useCallback(async () => {
setIsLoading(true);
setLoadError(null);
try {
const res = await apiClient.get<SegmentsResponse>(
`/api/v1/translations/${jobId}/segments`
);
setSegments(res.data.segments);
setFileName(res.data.file_name);
} catch (err) {
setLoadError(err instanceof Error ? err.message : 'Erreur de chargement');
} finally {
setIsLoading(false);
}
}, [jobId]);
useEffect(() => {
if (jobId) load();
}, [jobId, load]);
const counts = useMemo(() => {
const pending = segments.filter((s) => s.status === 'pending').length;
const approved = segments.filter((s) => s.status === 'approved').length;
const edited = segments.filter((s) => s.status === 'edited').length;
return { total: segments.length, pending, approved, edited };
}, [segments]);
const patchSegment = async (
id: string,
body: { reviewed_text?: string; status?: string }
) => {
setSavingId(id);
try {
await apiClient.patch(`/api/v1/segments/${id}`, body);
setSegments((prev) =>
prev.map((s) =>
s.id === id
? {
...s,
reviewed_text: body.reviewed_text ?? s.reviewed_text,
status: (body.status ?? s.status) as Segment['status'],
}
: s
)
);
} catch (err) {
notify.error({
title: 'Erreur',
description: err instanceof Error ? err.message : 'Mise à jour impossible',
});
} finally {
setSavingId(null);
}
};
const approveAll = async () => {
const pending = segments.filter((s) => s.status === 'pending');
for (const seg of pending) {
// sequential is fine — server-side each is a tiny PATCH
await patchSegment(seg.id, { status: 'approved' });
}
notify.success({
title: 'Segments approuvés',
description: `${pending.length} segment(s) approuvé(s).`,
});
};
const rebuild = async () => {
setIsRebuilding(true);
try {
const res = await apiClient.post<RebuildResponse>(
`/api/v1/translations/${jobId}/rebuild`
);
await downloadProtected(
res.data.download_url,
`relu_${fileName ?? jobId}`
);
notify.success({
title: 'Document reconstruit',
description: `${res.data.segments_applied} segment(s) relu(s) appliqué(s).`,
});
} catch (err) {
notify.error({
title: 'Reconstruction impossible',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsRebuilding(false);
}
};
const exportXliff = async () => {
try {
await downloadProtected(
`/api/v1/translations/${jobId}/xliff`,
`${jobId}.xliff`
);
} catch (err) {
notify.error({
title: 'Export XLIFF échoué',
description: err instanceof Error ? err.message : undefined,
});
}
};
const importXliff = async (file: File) => {
setIsImporting(true);
try {
const text = await file.text();
const res = await fetch(
`${API_BASE_URL}/api/v1/translations/${jobId}/xliff`,
{
method: 'POST',
headers: { ...authHeaders() },
body: text,
}
);
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(
json?.detail?.message || json?.message || `HTTP ${res.status}`
);
}
const json = await res.json();
notify.success({
title: 'XLIFF importé',
description: `${json.data.segments_updated} segment(s) mis à jour.`,
});
await load();
} catch (err) {
notify.error({
title: 'Import XLIFF échoué',
description: err instanceof Error ? err.message : undefined,
});
} finally {
setIsImporting(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const statusBadge = (status: Segment['status']) => {
if (status === 'approved')
return <Badge className="bg-green-500/15 text-green-600 border-green-500/30">Approuvé</Badge>;
if (status === 'edited')
return <Badge className="bg-blue-500/15 text-blue-600 border-blue-500/30">Modifié</Badge>;
return <Badge variant="outline" className="text-muted-foreground">À relire</Badge>;
};
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-semibold text-foreground">Relecture</h1>
<p className="text-sm text-muted-foreground truncate max-w-xl">
{fileName ?? jobId} {counts.total} segments ({counts.pending} à
relire, {counts.approved} approuvés, {counts.edited} modifiés)
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={approveAll} disabled={counts.pending === 0}>
<CheckCheck className="size-3.5" /> Tout approuver
</Button>
<Button variant="outline" size="sm" onClick={exportXliff} disabled={!segments.length}>
<FileDown className="size-3.5" /> XLIFF
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isImporting}
>
{isImporting ? <Loader2 className="size-3.5 animate-spin" /> : <FileUp className="size-3.5" />}
Importer XLIFF
</Button>
<input
ref={fileInputRef}
type="file"
accept=".xliff,.xlf,application/xliff+xml"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) importXliff(f);
}}
/>
<Button size="sm" onClick={rebuild} disabled={isRebuilding || counts.approved + counts.edited === 0}>
{isRebuilding ? <Loader2 className="size-3.5 animate-spin" /> : <Download className="size-3.5" />}
Reconstruire et télécharger
</Button>
</div>
</div>
{loadError && (
<div className="rounded-lg border border-red-200/30 bg-red-500/10 px-4 py-3 text-sm text-red-500">
{loadError}
</div>
)}
{isLoading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="size-6 animate-spin text-muted-foreground" />
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th className="w-1/2 px-4 py-2.5 font-medium">Source</th>
<th className="w-1/2 px-4 py-2.5 font-medium">Traduction</th>
<th className="w-40 px-4 py-2.5 font-medium">Statut</th>
</tr>
</thead>
<tbody>
{segments.map((seg) => {
const isEditing = editingId === seg.id;
const finalText = seg.status === 'edited' && seg.reviewed_text
? seg.reviewed_text
: seg.translated_text;
return (
<tr key={seg.id} className="border-t border-border align-top">
<td className="px-4 py-3 text-muted-foreground">
{seg.source_text}
</td>
<td className="px-4 py-3">
{isEditing ? (
<div className="space-y-2">
<textarea
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
value={draft}
onChange={(e) => setDraft(e.target.value)}
/>
<div className="flex gap-2">
<Button
size="sm"
disabled={savingId === seg.id || !draft.trim()}
onClick={async () => {
await patchSegment(seg.id, {
reviewed_text: draft,
status: 'edited',
});
setEditingId(null);
}}
>
{savingId === seg.id ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
Enregistrer
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => setEditingId(null)}
>
Annuler
</Button>
</div>
</div>
) : (
<div
className={cn(
'whitespace-pre-wrap',
seg.status === 'edited' && 'text-blue-600 dark:text-blue-400'
)}
>
{finalText || <span className="italic text-muted-foreground"> vide </span>}
</div>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-col items-start gap-2">
{statusBadge(seg.status)}
{!isEditing && (
<div className="flex gap-1">
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title="Modifier la traduction"
onClick={() => {
setEditingId(seg.id);
setDraft(finalText);
}}
>
<Pencil className="size-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title="Approuver tel quel"
disabled={seg.status === 'approved' || savingId === seg.id}
onClick={() => patchSegment(seg.id, { status: 'approved' })}
>
<Check className="size-3" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
title="Remettre à relire"
disabled={seg.status === 'pending'}
onClick={() => patchSegment(seg.id, { status: 'pending' })}
>
<Undo2 className="size-3" />
</Button>
</div>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}