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:
@@ -1,4 +1,4 @@
|
||||
import { FileText, BookText, User, type LucideIcon } from 'lucide-react';
|
||||
import { FileText, BookText, User, Users, type LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface NavItem {
|
||||
labelKey: string;
|
||||
@@ -9,8 +9,9 @@ export interface NavItem {
|
||||
|
||||
export const baseNavItems: NavItem[] = [
|
||||
{ labelKey: 'dashboard.nav.translate', href: '/dashboard/translate', icon: FileText },
|
||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||
{ labelKey: 'dashboard.nav.glossaries', href: '/dashboard/glossaries', icon: BookText, proOnly: true },
|
||||
{ labelKey: 'dashboard.nav.teams', href: '/dashboard/teams', icon: Users, proOnly: true },
|
||||
{ labelKey: 'dashboard.nav.profile', href: '/dashboard/profile', icon: User },
|
||||
// API Keys nav item temporarily removed per request — uncomment to restore.
|
||||
// { labelKey: 'dashboard.nav.apiKeys', href: '/dashboard/api-keys', icon: Key, proOnly: true },
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
267
frontend/src/app/dashboard/teams/page.tsx
Normal file
267
frontend/src/app/dashboard/teams/page.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Users, Plus, UserPlus, Trash2, Loader2, Crown } from 'lucide-react';
|
||||
import { apiClient } from '@/lib/apiClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/toast';
|
||||
|
||||
interface WorkspaceMember {
|
||||
id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
owner_id: string;
|
||||
member_count: number;
|
||||
members: WorkspaceMember[];
|
||||
my_role?: string;
|
||||
seat_limit?: number; // -1 = illimité
|
||||
}
|
||||
|
||||
export default function TeamsPage() {
|
||||
const notify = useToast();
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [needsBusiness, setNeedsBusiness] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState<Record<string, string>>({});
|
||||
const [inviteRole, setInviteRole] = useState<Record<string, string>>({});
|
||||
const [busyWs, setBusyWs] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get<{ data: Workspace[] }>('/api/v1/workspaces');
|
||||
setWorkspaces(res.data);
|
||||
setNeedsBusiness(false);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '';
|
||||
if (message.includes('Business') || message.includes('PLAN_REQUIRED')) {
|
||||
setNeedsBusiness(true);
|
||||
} else {
|
||||
notify.error({ title: 'Erreur', description: message || 'Chargement impossible' });
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [notify]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const createWorkspace = async () => {
|
||||
if (!newName.trim()) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await apiClient.post('/api/v1/workspaces', { name: newName.trim() });
|
||||
setNewName('');
|
||||
notify.success({ title: 'Espace créé', description: 'Vous êtes propriétaire.' });
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Création impossible',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const inviteMember = async (wsId: string) => {
|
||||
const email = (inviteEmail[wsId] || '').trim();
|
||||
if (!email) return;
|
||||
setBusyWs(wsId);
|
||||
try {
|
||||
await apiClient.post(`/api/v1/workspaces/${wsId}/members`, {
|
||||
email,
|
||||
role: inviteRole[wsId] || 'member',
|
||||
});
|
||||
setInviteEmail((p) => ({ ...p, [wsId]: '' }));
|
||||
notify.success({ title: 'Membre ajouté', description: email });
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Ajout impossible',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setBusyWs(null);
|
||||
}
|
||||
};
|
||||
|
||||
const removeMember = async (wsId: string, userId: string) => {
|
||||
setBusyWs(wsId);
|
||||
try {
|
||||
await apiClient.delete(`/api/v1/workspaces/${wsId}/members/${userId}`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title: 'Retrait impossible',
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setBusyWs(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-blue-600/20">
|
||||
<Users className="size-5 text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-foreground">Espaces de travail</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Partagez vos glossaires avec votre équipe (plan Business — 5 sièges).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{needsBusiness ? (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Les espaces de travail nécessitent le plan Business. Passez au plan
|
||||
supérieur pour collaborer avec votre équipe.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-lg border border-border bg-card p-4">
|
||||
<div className="flex-1 min-w-56 space-y-1.5">
|
||||
<Label htmlFor="ws-name">Nouvel espace de travail</Label>
|
||||
<Input
|
||||
id="ws-name"
|
||||
placeholder="Ex. Équipe traduction"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={createWorkspace} disabled={isCreating || !newName.trim()}>
|
||||
{isCreating ? <Loader2 className="size-4 animate-spin" /> : <Plus className="size-4" />}
|
||||
Créer
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{workspaces.map((ws) => (
|
||||
<div key={ws.id} className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-foreground">{ws.name}</span>
|
||||
{ws.my_role === 'owner' && (
|
||||
<Badge className="gap-1 bg-amber-500/15 text-amber-600 border-amber-500/30">
|
||||
<Crown className="size-3" /> Propriétaire
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{ws.member_count}
|
||||
{ws.seat_limit === -1 ? ' sièges (illimité)' : ` / ${ws.seat_limit ?? '?'} sièges`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{ws.members.map((m) => (
|
||||
<li key={m.id} className="flex items-center justify-between px-3 py-2 text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{m.user_id.slice(0, 8)}…</span>
|
||||
<Badge variant="outline">{m.role}</Badge>
|
||||
</span>
|
||||
{m.role !== 'owner' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-red-500"
|
||||
disabled={busyWs === ws.id || ws.my_role === 'member'}
|
||||
onClick={() => removeMember(ws.id, m.user_id)}
|
||||
title="Retirer du workspace"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{(ws.my_role === 'owner' || ws.my_role === 'admin') && (
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<div className="flex-1 min-w-48 space-y-1.5">
|
||||
<Label htmlFor={`invite-${ws.id}`}>Ajouter un membre (e-mail)</Label>
|
||||
<Input
|
||||
id={`invite-${ws.id}`}
|
||||
type="email"
|
||||
placeholder="collegue@entreprise.com"
|
||||
value={inviteEmail[ws.id] || ''}
|
||||
onChange={(e) =>
|
||||
setInviteEmail((p) => ({ ...p, [ws.id]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={inviteRole[ws.id] || 'member'}
|
||||
onValueChange={(v) => setInviteRole((p) => ({ ...p, [ws.id]: v }))}
|
||||
>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Membre</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => inviteMember(ws.id)}
|
||||
disabled={busyWs === ws.id || !(inviteEmail[ws.id] || '').trim()}
|
||||
>
|
||||
{busyWs === ws.id ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-3.5" />
|
||||
)}
|
||||
Inviter
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{workspaces.length === 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-6 text-center text-sm text-muted-foreground">
|
||||
Aucun espace pour l'instant — créez le premier ci-dessus.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle2, Download, Plus, Loader2, FileText,
|
||||
Timer, Activity, TrendingUp,
|
||||
Timer, Activity, TrendingUp, BookOpenCheck,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNotification } from '@/components/ui/notification';
|
||||
@@ -157,6 +159,18 @@ export function TranslationComplete({
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-11 w-full gap-2"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/dashboard/reviews/${jobId}`}>
|
||||
<BookOpenCheck className="size-4" />
|
||||
Relire et corriger la traduction
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "وصول مميز",
|
||||
"dashboard.checkoutSyncError": "خطأ في مزامنة الدفع.",
|
||||
"dashboard.networkRefresh": "خطأ في الشبكة. يرجى تحديث الصفحة.",
|
||||
"dashboard.continueToTranslate": "متابعة إلى الترجمة"
|
||||
"dashboard.continueToTranslate": "متابعة إلى الترجمة",
|
||||
"dashboard.nav.teams": "الفريق"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Premium-Zugang",
|
||||
"dashboard.checkoutSyncError": "Fehler beim Synchronisieren der Zahlung.",
|
||||
"dashboard.networkRefresh": "Netzwerkfehler. Bitte aktualisieren Sie die Seite.",
|
||||
"dashboard.continueToTranslate": "Weiter zur Übersetzung"
|
||||
"dashboard.continueToTranslate": "Weiter zur Übersetzung",
|
||||
"dashboard.nav.teams": "Team"
|
||||
}
|
||||
|
||||
@@ -111,5 +111,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Premium Access",
|
||||
"dashboard.checkoutSyncError": "Error syncing payment.",
|
||||
"dashboard.networkRefresh": "Network error. Please refresh the page.",
|
||||
"dashboard.continueToTranslate": "Continue to translation"
|
||||
"dashboard.continueToTranslate": "Continue to translation",
|
||||
"dashboard.nav.teams": "Team"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Acceso Premium",
|
||||
"dashboard.checkoutSyncError": "Error al sincronizar el pago.",
|
||||
"dashboard.networkRefresh": "Error de red. Por favor, actualice la página.",
|
||||
"dashboard.continueToTranslate": "Continuar a la traducción"
|
||||
"dashboard.continueToTranslate": "Continuar a la traducción",
|
||||
"dashboard.nav.teams": "Equipo"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "دسترسی ویژه",
|
||||
"dashboard.checkoutSyncError": "خطا در همگامسازی پرداخت.",
|
||||
"dashboard.networkRefresh": "خطای شبکه. لطفاً صفحه را تازهسازی کنید.",
|
||||
"dashboard.continueToTranslate": "ادامه به ترجمه"
|
||||
"dashboard.continueToTranslate": "ادامه به ترجمه",
|
||||
"dashboard.nav.teams": "تیم"
|
||||
}
|
||||
|
||||
@@ -111,5 +111,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Accès Premium",
|
||||
"dashboard.checkoutSyncError": "Erreur lors de la synchronisation du paiement.",
|
||||
"dashboard.networkRefresh": "Erreur réseau. Veuillez rafraîchir la page.",
|
||||
"dashboard.continueToTranslate": "Continuer vers la traduction"
|
||||
"dashboard.continueToTranslate": "Continuer vers la traduction",
|
||||
"dashboard.nav.teams": "Équipe"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Accesso Premium",
|
||||
"dashboard.checkoutSyncError": "Errore di sincronizzazione del pagamento.",
|
||||
"dashboard.networkRefresh": "Errore di rete. Aggiorna la pagina.",
|
||||
"dashboard.continueToTranslate": "Vai alla traduzione"
|
||||
"dashboard.continueToTranslate": "Vai alla traduzione",
|
||||
"dashboard.nav.teams": "Squadra"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "プレミアムアクセス",
|
||||
"dashboard.checkoutSyncError": "支払いの同期エラー。",
|
||||
"dashboard.networkRefresh": "ネットワークエラー。ページを更新してください。",
|
||||
"dashboard.continueToTranslate": "翻訳に進む"
|
||||
"dashboard.continueToTranslate": "翻訳に進む",
|
||||
"dashboard.nav.teams": "チーム"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "프리미엄 액세스",
|
||||
"dashboard.checkoutSyncError": "결제 동기화 오류.",
|
||||
"dashboard.networkRefresh": "네트워크 오류. 페이지를 새로 고치세요.",
|
||||
"dashboard.continueToTranslate": "번역으로 계속"
|
||||
"dashboard.continueToTranslate": "번역으로 계속",
|
||||
"dashboard.nav.teams": "팀"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Premium-toegang",
|
||||
"dashboard.checkoutSyncError": "Fout bij synchroniseren van betaling.",
|
||||
"dashboard.networkRefresh": "Netwerkfout. Vernieuw de pagina.",
|
||||
"dashboard.continueToTranslate": "Doorgaan naar vertaling"
|
||||
"dashboard.continueToTranslate": "Doorgaan naar vertaling",
|
||||
"dashboard.nav.teams": "Team"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Acesso Premium",
|
||||
"dashboard.checkoutSyncError": "Erro ao sincronizar o pagamento.",
|
||||
"dashboard.networkRefresh": "Erro de rede. Atualize a página.",
|
||||
"dashboard.continueToTranslate": "Continuar para a tradução"
|
||||
"dashboard.continueToTranslate": "Continuar para a tradução",
|
||||
"dashboard.nav.teams": "Equipe"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "Премиум-доступ",
|
||||
"dashboard.checkoutSyncError": "Ошибка синхронизации платежа.",
|
||||
"dashboard.networkRefresh": "Ошибка сети. Обновите страницу.",
|
||||
"dashboard.continueToTranslate": "Перейти к переводу"
|
||||
"dashboard.continueToTranslate": "Перейти к переводу",
|
||||
"dashboard.nav.teams": "Команда"
|
||||
}
|
||||
|
||||
@@ -107,5 +107,6 @@
|
||||
"dashboard.topbar.premiumAccess": "高级访问",
|
||||
"dashboard.checkoutSyncError": "同步付款时出错。",
|
||||
"dashboard.networkRefresh": "网络错误。请刷新页面。",
|
||||
"dashboard.continueToTranslate": "继续翻译"
|
||||
"dashboard.continueToTranslate": "继续翻译",
|
||||
"dashboard.nav.teams": "团队"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user