Initial commit: thbd-portal, duplicated from elmi-portal

Rebranded for Thunderbird Portal (THBD): numbering prefix EL->TH, all
"ELMI"/"ЭЛМИ" UI strings genericized, ELMI's real contract templates and
company legal/bank details removed (not applicable to this company), fresh
VAPID keypair and encryption key, fresh single-migration schema history.
Same architecture as elmi-portal: Web Push notifications, unread-chat app
badge, cookie-session auth.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gh2UXUQUVBWroWEnn1FLFG
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-31 16:33:30 +00:00
commit 579528edf7
197 files changed
+25022

No files matched your search

@@ -0,0 +1,78 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { listTemplates } from "@/lib/documents/templates";
import { UploadTemplateForm } from "./upload-form";
const KIND_LABEL: Record<string, string> = {
power_of_attorney: "Доверенность",
forwarder_receipt: "Экспедиторская расписка",
contract_teo_forwarder: "Договор ТЭУ (наша компания — Экспедитор)",
contract_teo_customer: "Договор ТЭО (наша компания — Заказчик)",
appendix_order_forwarder: "Приложение — Поручение + Расписка (наша компания — Экспедитор)",
appendix_order_customer: "Приложение — Поручение + Расписка (наша компания — Заказчик)",
order_agreement_road_forwarder: "Договор-заявка на автоперевозку (наша компания — Экспедитор)",
order_agreement_teo_customer: "Договор-заявка на ТЭО (наша компания — Заказчик)",
other: "Другое",
};
export default async function TemplatesPage() {
const session = await getCurrentSession();
if (!session) redirect("/orders");
const templates = await listTemplates();
return (
<div>
<div className="mb-5 flex items-center justify-between">
<h1 className="font-display text-xl font-bold tracking-tight">Шаблоны документов</h1>
</div>
<p className="mb-4 text-sm text-text-muted">
Стартовые шаблоны загружаются в Настройки → Шаблоны. Загружая свой .docx взамен, используйте теги: заявка —{" "}
<code className="rounded bg-surface-hover px-1">{"{order_number}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{order_date}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{contract_date}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{cargo_description}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{route_from}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{route_to}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{flight_number}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{manager_name}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{load_date}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{unload_plan_date}"}</code>; доверенность —{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_number}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_date}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_valid_until}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_driver_name}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_driver_passport}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_vehicle_model}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{poa_vehicle_plate}"}</code>; реквизиты сторон — с
префиксом <code className="rounded bg-surface-hover px-1">{"company_*"}</code> (наша компания, из Настройки → Компания)
и <code className="rounded bg-surface-hover px-1">{"customer_*"}</code> (контрагент заявки), например{" "}
<code className="rounded bg-surface-hover px-1">{"{company_name}"}</code>,{" "}
<code className="rounded bg-surface-hover px-1">{"{customer_signatory_short}"}</code>. Полный список — в{" "}
<code className="rounded bg-surface-hover px-1">buildTemplateData</code>.
</p>
<div className="card mb-5 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-text-muted">
<th className="px-4 py-3 font-medium">Название</th>
<th className="px-4 py-3 font-medium">Тип</th>
</tr>
</thead>
<tbody>
{templates.map((t) => (
<tr key={t.id} className="border-b border-border last:border-0">
<td className="px-4 py-3">{t.name}</td>
<td className="px-4 py-3 text-text-muted">{KIND_LABEL[t.kind]}</td>
</tr>
))}
</tbody>
</table>
</div>
<UploadTemplateForm />
</div>
);
}
@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
const KIND_OPTIONS = [
{ value: "power_of_attorney", label: "Доверенность" },
{ value: "forwarder_receipt", label: "Экспедиторская расписка" },
{ value: "contract_teo_forwarder", label: "Договор ТЭУ (наша компания — Экспедитор)" },
{ value: "contract_teo_customer", label: "Договор ТЭО (наша компания — Заказчик)" },
{ value: "appendix_order_forwarder", label: "Приложение — Поручение + Расписка (наша компания — Экспедитор)" },
{ value: "appendix_order_customer", label: "Приложение — Поручение + Расписка (наша компания — Заказчик)" },
{ value: "order_agreement_road_forwarder", label: "Договор-заявка на автоперевозку (наша компания — Экспедитор)" },
{ value: "order_agreement_teo_customer", label: "Договор-заявка на ТЭО (наша компания — Заказчик)" },
{ value: "other", label: "Другое" },
];
export function UploadTemplateForm() {
const router = useRouter();
const [name, setName] = useState("");
const [kind, setKind] = useState("power_of_attorney");
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!file) return;
setLoading(true);
setError(null);
const form = new FormData();
form.set("name", name);
form.set("kind", kind);
form.set("file", file);
const res = await fetch("/api/templates", { method: "POST", body: form });
setLoading(false);
if (!res.ok) {
const data = await res.json().catch(() => null);
setError(data?.error ?? "Не удалось загрузить шаблон");
return;
}
setName("");
setFile(null);
router.refresh();
}
return (
<form onSubmit={handleSubmit} className="card flex flex-wrap items-end gap-2 p-4">
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Название</span>
<input
required
value={name}
onChange={(e) => setName(e.target.value)}
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Тип</span>
<select
value={kind}
onChange={(e) => setKind(e.target.value)}
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
>
{KIND_OPTIONS.map((k) => (
<option key={k.value} value={k.value}>
{k.label}
</option>
))}
</select>
</label>
<label className="text-sm">
<span className="mb-1 block font-medium text-text-muted">Файл .docx</span>
<input
type="file"
required
accept=".docx"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
className="rounded-md border border-border bg-surface px-3 py-2 outline-none focus:border-accent"
/>
</label>
<button type="submit" disabled={loading} className="btn btn-primary">
{loading ? "Загружаем…" : "Загрузить"}
</button>
{error && <p className="w-full text-sm text-danger-soft-text">{error}</p>}
</form>
);
}