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
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:
400
frontend/src/app/dashboard/reviews/[jobId]/page.tsx
Normal file
400
frontend/src/app/dashboard/reviews/[jobId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user