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:
@@ -38,7 +38,8 @@
|
||||
"context": "Context",
|
||||
"skills": "Skills",
|
||||
"personal_context": "My Context",
|
||||
"pdf": "PDF Reports"
|
||||
"pdf": "PDF Reports",
|
||||
"pdf_templates": "Templates"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Welcome, {{name}}",
|
||||
@@ -157,7 +158,29 @@
|
||||
"title": "PDF Reports",
|
||||
"generate": "Generate PDF",
|
||||
"title_placeholder": "Report title...",
|
||||
"no_pdfs": "No PDF reports generated yet."
|
||||
"no_pdfs": "No PDF reports generated yet.",
|
||||
"template": "Template",
|
||||
"default_template": "Default template"
|
||||
},
|
||||
"pdf_templates": {
|
||||
"title": "PDF Templates",
|
||||
"create": "Create Template",
|
||||
"edit": "Edit Template",
|
||||
"no_templates": "No templates yet.",
|
||||
"default": "Default",
|
||||
"inactive": "Inactive",
|
||||
"name": "Name",
|
||||
"locale": "Language",
|
||||
"description": "Description",
|
||||
"html_content": "HTML Template",
|
||||
"preview": "Preview",
|
||||
"variables_ref": "Template Variables Reference",
|
||||
"var_title": "Report title",
|
||||
"var_user_name": "User's full name",
|
||||
"var_generated_at": "Generation timestamp",
|
||||
"var_memories": "List of memory entries (category, title, content, importance)",
|
||||
"var_documents": "List of documents (original_filename, doc_type, excerpt)",
|
||||
"var_ai_summary": "AI-generated summary (optional)"
|
||||
},
|
||||
"admin_users": {
|
||||
"title": "User Management",
|
||||
|
||||
@@ -38,7 +38,8 @@
|
||||
"context": "Контекст",
|
||||
"skills": "Навыки",
|
||||
"personal_context": "Мой контекст",
|
||||
"pdf": "PDF отчёты"
|
||||
"pdf": "PDF отчёты",
|
||||
"pdf_templates": "Шаблоны"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Добро пожаловать, {{name}}",
|
||||
@@ -157,7 +158,29 @@
|
||||
"title": "PDF отчёты",
|
||||
"generate": "Сгенерировать PDF",
|
||||
"title_placeholder": "Название отчёта...",
|
||||
"no_pdfs": "PDF отчёты ещё не создавались."
|
||||
"no_pdfs": "PDF отчёты ещё не создавались.",
|
||||
"template": "Шаблон",
|
||||
"default_template": "Шаблон по умолчанию"
|
||||
},
|
||||
"pdf_templates": {
|
||||
"title": "Шаблоны PDF",
|
||||
"create": "Создать шаблон",
|
||||
"edit": "Редактировать шаблон",
|
||||
"no_templates": "Шаблонов пока нет.",
|
||||
"default": "По умолчанию",
|
||||
"inactive": "Неактивен",
|
||||
"name": "Название",
|
||||
"locale": "Язык",
|
||||
"description": "Описание",
|
||||
"html_content": "HTML шаблон",
|
||||
"preview": "Предпросмотр",
|
||||
"variables_ref": "Справка по переменным шаблона",
|
||||
"var_title": "Заголовок отчёта",
|
||||
"var_user_name": "Полное имя пользователя",
|
||||
"var_generated_at": "Дата и время генерации",
|
||||
"var_memories": "Список записей памяти (category, title, content, importance)",
|
||||
"var_documents": "Список документов (original_filename, doc_type, excerpt)",
|
||||
"var_ai_summary": "Резюме от ИИ (необязательно)"
|
||||
},
|
||||
"admin_users": {
|
||||
"title": "Управление пользователями",
|
||||
|
||||
59
frontend/src/api/pdf-templates.ts
Normal file
59
frontend/src/api/pdf-templates.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import api from "./client";
|
||||
|
||||
export interface PdfTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
locale: string;
|
||||
description: string | null;
|
||||
html_content: string;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PdfTemplateSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
locale: string;
|
||||
description: string | null;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
// Admin CRUD
|
||||
export async function getAdminTemplates(): Promise<PdfTemplate[]> {
|
||||
const { data } = await api.get<{ templates: PdfTemplate[] }>("/admin/pdf-templates");
|
||||
return data.templates;
|
||||
}
|
||||
|
||||
export async function createTemplate(template: {
|
||||
name: string;
|
||||
locale: string;
|
||||
description?: string;
|
||||
html_content: string;
|
||||
}): Promise<PdfTemplate> {
|
||||
const { data } = await api.post<PdfTemplate>("/admin/pdf-templates", template);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function updateTemplate(
|
||||
id: string,
|
||||
updates: Partial<{ name: string; locale: string; description: string; html_content: string; is_active: boolean }>
|
||||
): Promise<PdfTemplate> {
|
||||
const { data } = await api.patch<PdfTemplate>(`/admin/pdf-templates/${id}`, updates);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: string): Promise<void> {
|
||||
await api.delete(`/admin/pdf-templates/${id}`);
|
||||
}
|
||||
|
||||
export async function previewTemplate(html_content: string): Promise<string> {
|
||||
const { data } = await api.post<{ html: string }>("/admin/pdf-templates/preview", { html_content });
|
||||
return data.html;
|
||||
}
|
||||
|
||||
// User-facing
|
||||
export async function getAvailableTemplates(): Promise<PdfTemplateSummary[]> {
|
||||
const { data } = await api.get<{ templates: PdfTemplateSummary[] }>("/pdf/templates");
|
||||
return data.templates;
|
||||
}
|
||||
@@ -13,10 +13,11 @@ export interface PdfListResponse {
|
||||
pdfs: GeneratedPdf[];
|
||||
}
|
||||
|
||||
export async function compilePdf(title: string, documentIds?: string[]): Promise<GeneratedPdf> {
|
||||
export async function compilePdf(title: string, documentIds?: string[], templateId?: string): Promise<GeneratedPdf> {
|
||||
const { data } = await api.post<GeneratedPdf>("/pdf/compile", {
|
||||
title,
|
||||
document_ids: documentIds || [],
|
||||
template_id: templateId || undefined,
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const adminItems = [
|
||||
{ key: "admin_skills", to: "/admin/skills", label: "layout.skills" },
|
||||
{ key: "admin_users", to: "/admin/users", label: "layout.users" },
|
||||
{ key: "admin_settings", to: "/admin/settings", label: "layout.settings" },
|
||||
{ key: "admin_templates", to: "/admin/pdf-templates", label: "layout.pdf_templates" },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
|
||||
183
frontend/src/pages/admin/pdf-templates.tsx
Normal file
183
frontend/src/pages/admin/pdf-templates.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { listPdfs, compilePdf, downloadPdf } from "@/api/pdf";
|
||||
import { getAvailableTemplates } from "@/api/pdf-templates";
|
||||
import { FileText, Plus, Download } from "lucide-react";
|
||||
|
||||
export function PdfPage() {
|
||||
@@ -9,18 +10,25 @@ export function PdfPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [title, setTitle] = useState("");
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
|
||||
const { data: pdfs = [] } = useQuery({
|
||||
queryKey: ["pdfs"],
|
||||
queryFn: listPdfs,
|
||||
});
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ["pdf-templates-available"],
|
||||
queryFn: getAvailableTemplates,
|
||||
});
|
||||
|
||||
const compileMut = useMutation({
|
||||
mutationFn: () => compilePdf(title),
|
||||
mutationFn: () => compilePdf(title, undefined, templateId || undefined),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["pdfs"] });
|
||||
setCreating(false);
|
||||
setTitle("");
|
||||
setTemplateId("");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -44,6 +52,22 @@ export function PdfPage() {
|
||||
placeholder={t("pdf.title_placeholder")}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium">{t("pdf.template")}</label>
|
||||
<select
|
||||
value={templateId}
|
||||
onChange={(e) => setTemplateId(e.target.value)}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
|
||||
>
|
||||
<option value="">{t("pdf.default_template")}</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} ({t.locale.toUpperCase()})
|
||||
{t.is_default ? " *" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setCreating(false)} className="h-9 rounded-md border px-4 text-sm">{t("common.cancel")}</button>
|
||||
<button
|
||||
|
||||
@@ -15,6 +15,7 @@ import { NotificationsPage } from "@/pages/notifications";
|
||||
import { PdfPage } from "@/pages/pdf";
|
||||
import { AdminUsersPage } from "@/pages/admin/users";
|
||||
import { AdminSettingsPage } from "@/pages/admin/settings";
|
||||
import { AdminPdfTemplatesPage } from "@/pages/admin/pdf-templates";
|
||||
import { NotFoundPage } from "@/pages/not-found";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
@@ -45,6 +46,7 @@ export const router = createBrowserRouter([
|
||||
{ path: "admin/skills", element: <AdminSkillsPage /> },
|
||||
{ path: "admin/users", element: <AdminUsersPage /> },
|
||||
{ path: "admin/settings", element: <AdminSettingsPage /> },
|
||||
{ path: "admin/pdf-templates", element: <AdminPdfTemplatesPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user