Phase 8: Customizable PDF Templates — locale support, admin editor, seed templates

Backend:
- PdfTemplate model with locale field + UNIQUE(name, locale) constraint
- Migration 007: pdf_templates table + template_id FK on generated_pdfs
- Template service: CRUD, Jinja2 validation, render preview with sample data
- Admin endpoints: CRUD /admin/pdf-templates + POST preview
- User endpoint: GET /pdf/templates (active templates list)
- pdf_service: resolves template from DB by ID or falls back to default
  for the appropriate locale
- AI generate_pdf tool accepts optional template_id
- Seed script + 4 HTML template files:
  - Basic Report (en/ru) — general-purpose report
  - Medical Report (en/ru) — health-focused with disclaimers

Frontend:
- Admin PDF templates page with editor, locale selector, live preview (iframe),
  template variables reference panel
- PDF page: template selector dropdown in generation form
- API clients for admin CRUD + user template listing
- Sidebar: admin templates link
- English + Russian translations

Also added Phase 9 (OAuth) and Phase 10 (Rate Limits) placeholders to GeneralPlan.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 15:32:35 +03:00
parent b0790d719c
commit bb53eeee8e
26 changed files with 1077 additions and 16 deletions

View File

@@ -0,0 +1,183 @@
import { useState } from "react";
import { Navigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useAuthStore } from "@/stores/auth-store";
import {
getAdminTemplates,
createTemplate,
updateTemplate,
deleteTemplate,
previewTemplate,
type PdfTemplate,
} from "@/api/pdf-templates";
import { Plus, Pencil, Trash2, Eye, FileText, Info } from "lucide-react";
export function AdminPdfTemplatesPage() {
const { t } = useTranslation();
const user = useAuthStore((s) => s.user);
const queryClient = useQueryClient();
const [editing, setEditing] = useState<PdfTemplate | null>(null);
const [creating, setCreating] = useState(false);
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
const [showVars, setShowVars] = useState(false);
// Form state
const [name, setName] = useState("");
const [locale, setLocale] = useState("en");
const [description, setDescription] = useState("");
const [htmlContent, setHtmlContent] = useState("");
const { data: templates = [] } = useQuery({
queryKey: ["admin-pdf-templates"],
queryFn: getAdminTemplates,
});
const createMut = useMutation({
mutationFn: () => createTemplate({ name, locale, description, html_content: htmlContent }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-pdf-templates"] }); resetForm(); },
});
const updateMut = useMutation({
mutationFn: () => updateTemplate(editing!.id, { name, locale, description, html_content: htmlContent }),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["admin-pdf-templates"] }); resetForm(); },
});
const deleteMut = useMutation({
mutationFn: deleteTemplate,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["admin-pdf-templates"] }),
});
if (user?.role !== "admin") return <Navigate to="/" replace />;
function resetForm() {
setCreating(false); setEditing(null); setPreviewHtml(null);
setName(""); setLocale("en"); setDescription(""); setHtmlContent("");
}
function startEdit(t: PdfTemplate) {
setEditing(t); setName(t.name); setLocale(t.locale);
setDescription(t.description || ""); setHtmlContent(t.html_content);
setPreviewHtml(null);
}
async function handlePreview() {
const html = await previewTemplate(htmlContent);
setPreviewHtml(html);
}
if (creating || editing) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold">
{creating ? t("pdf_templates.create") : t("pdf_templates.edit")}
</h1>
<button onClick={() => setShowVars(!showVars)} className="inline-flex items-center gap-1 text-sm text-primary hover:underline">
<Info className="h-4 w-4" /> {t("pdf_templates.variables_ref")}
</button>
{showVars && (
<div className="rounded-lg border bg-muted/50 p-4 text-sm font-mono space-y-1">
<p><strong>{"{{ title }}"}</strong> {t("pdf_templates.var_title")}</p>
<p><strong>{"{{ user_name }}"}</strong> {t("pdf_templates.var_user_name")}</p>
<p><strong>{"{{ generated_at }}"}</strong> {t("pdf_templates.var_generated_at")}</p>
<p><strong>{"{{ memories }}"}</strong> {t("pdf_templates.var_memories")}</p>
<p><strong>{"{{ documents }}"}</strong> {t("pdf_templates.var_documents")}</p>
<p><strong>{"{{ ai_summary }}"}</strong> {t("pdf_templates.var_ai_summary")}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t("pdf_templates.name")}</label>
<input value={name} onChange={(e) => setName(e.target.value)} required
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("pdf_templates.locale")}</label>
<select value={locale} onChange={(e) => setLocale(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm">
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("pdf_templates.description")}</label>
<input value={description} onChange={(e) => setDescription(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm" />
</div>
<div className="space-y-2">
<label className="text-sm font-medium">{t("pdf_templates.html_content")}</label>
<textarea value={htmlContent} onChange={(e) => setHtmlContent(e.target.value)} required rows={16}
className="w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-xs font-mono" />
</div>
<div className="flex gap-2">
<button onClick={resetForm} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
<button onClick={handlePreview} disabled={!htmlContent.trim()}
className="inline-flex h-9 items-center gap-1 rounded-md border px-4 text-sm hover:bg-accent">
<Eye className="h-4 w-4" /> {t("pdf_templates.preview")}
</button>
<button
onClick={() => editing ? updateMut.mutate() : createMut.mutate()}
disabled={!name.trim() || !htmlContent.trim()}
className="h-9 rounded-md bg-primary px-4 text-sm text-primary-foreground disabled:opacity-50">
{t("common.save")}
</button>
</div>
{previewHtml && (
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("pdf_templates.preview")}</h3>
<iframe srcDoc={previewHtml} className="w-full h-96 rounded-lg border" sandbox="" />
</div>
)}
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">{t("pdf_templates.title")}</h1>
<button onClick={() => setCreating(true)}
className="inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground">
<Plus className="h-4 w-4" /> {t("common.create")}
</button>
</div>
{templates.length === 0 && (
<p className="text-center text-muted-foreground py-8">{t("pdf_templates.no_templates")}</p>
)}
<div className="space-y-2">
{templates.map((tmpl) => (
<div key={tmpl.id} className="flex items-center gap-3 rounded-lg border bg-card p-4">
<FileText className="h-5 w-5 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium">{tmpl.name}</p>
<span className="rounded-full bg-muted px-2 py-0.5 text-xs">{tmpl.locale.toUpperCase()}</span>
{tmpl.is_default && <span className="rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs">{t("pdf_templates.default")}</span>}
{!tmpl.is_active && <span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">{t("pdf_templates.inactive")}</span>}
</div>
{tmpl.description && <p className="text-sm text-muted-foreground truncate">{tmpl.description}</p>}
</div>
<div className="flex gap-1">
<button onClick={() => startEdit(tmpl)} className="rounded p-2 hover:bg-accent"><Pencil className="h-4 w-4" /></button>
{!tmpl.is_default && (
<button onClick={() => deleteMut.mutate(tmpl.id)} className="rounded p-2 hover:bg-destructive/10 hover:text-destructive">
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}