Files
personal-ai-assistant/frontend/src/pages/admin/pdf-templates.tsx
dolgolyov.alexei bb53eeee8e 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>
2026-03-19 15:32:35 +03:00

184 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}